Skip to content

Commit 4bb7fdd

Browse files
feat(signals): add withLinkedState() (#4818)
1 parent 4a4a5db commit 4bb7fdd

File tree

9 files changed

+761
-6
lines changed

9 files changed

+761
-6
lines changed

modules/signals/spec/signal-store.spec.ts

Lines changed: 230 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ import {
33
inject,
44
InjectionToken,
55
isSignal,
6+
linkedSignal,
67
signal,
78
} from '@angular/core';
89
import { TestBed } from '@angular/core/testing';
910
import {
11+
getState,
1012
patchState,
1113
signalStore,
14+
signalStoreFeature,
1215
withComputed,
1316
withHooks,
17+
withLinkedState,
1418
withMethods,
1519
withProps,
1620
withState,
@@ -500,7 +504,7 @@ describe('signalStore', () => {
500504
})
501505
);
502506
TestBed.inject(Store);
503-
TestBed.resetTestEnvironment();
507+
TestBed.resetTestingModule();
504508

505509
expect(messages).toEqual(['ending...']);
506510
});
@@ -565,4 +569,229 @@ describe('signalStore', () => {
565569
expect(secretStore[SECRET]).toBe('not your business');
566570
});
567571
});
572+
573+
describe('withLinkedState', () => {
574+
describe('updates automatically if the source changes', () =>
575+
[
576+
{
577+
name: 'automatic',
578+
linkedStateFeature: signalStoreFeature(
579+
withState({ userId: 1 }),
580+
581+
withLinkedState(({ userId }) => ({
582+
books: () => {
583+
userId();
584+
585+
return [] as string[];
586+
},
587+
}))
588+
),
589+
},
590+
{
591+
name: 'manual',
592+
linkedStateFeature: signalStoreFeature(
593+
withState({ userId: 1 }),
594+
withLinkedState(({ userId }) => ({
595+
books: linkedSignal({
596+
source: userId,
597+
computation: () => [] as string[],
598+
}),
599+
}))
600+
),
601+
},
602+
].forEach(({ name, linkedStateFeature }) => {
603+
it(name, () => {
604+
const BookStore = signalStore(
605+
{ providedIn: 'root', protectedState: false },
606+
linkedStateFeature,
607+
withState({ version: 1 }),
608+
withMethods((store) => ({
609+
updateUser() {
610+
patchState(store, (value) => value);
611+
patchState(store, ({ userId }) => ({ userId: userId + 1 }));
612+
},
613+
addBook(title: string) {
614+
patchState(store, ({ books }) => ({
615+
books: [...books, title],
616+
}));
617+
},
618+
increaseVersion() {
619+
patchState(store, ({ version }) => ({ version: version + 1 }));
620+
},
621+
}))
622+
);
623+
624+
const bookStore = TestBed.inject(BookStore);
625+
bookStore.addBook('The Neverending Story');
626+
bookStore.increaseVersion();
627+
expect(bookStore.books()).toEqual(['The Neverending Story']);
628+
expect(bookStore.version()).toEqual(2);
629+
630+
patchState(bookStore, { userId: 2 });
631+
expect(bookStore.books()).toEqual([]);
632+
expect(bookStore.version()).toEqual(2);
633+
});
634+
}));
635+
636+
describe('updates also a spread linkedSignal', () => {
637+
[
638+
{
639+
name: 'automatic',
640+
linkedStateFeature: signalStoreFeature(
641+
withState({ userId: 1 }),
642+
withLinkedState(({ userId }) => ({
643+
user: () => {
644+
userId();
645+
return { name: 'John Doe' };
646+
},
647+
location: () => {
648+
userId();
649+
return { city: 'Berlin', country: 'Germany' };
650+
},
651+
}))
652+
),
653+
},
654+
{
655+
name: 'manual',
656+
linkedStateFeature: signalStoreFeature(
657+
withState({ userId: 1 }),
658+
withLinkedState(({ userId }) => ({
659+
user: linkedSignal({
660+
source: userId,
661+
computation: () => ({ name: 'John Doe' }),
662+
}),
663+
location: linkedSignal({
664+
source: userId,
665+
computation: () => ({ city: 'Berlin', country: 'Germany' }),
666+
}),
667+
}))
668+
),
669+
},
670+
].forEach(({ name, linkedStateFeature }) => {
671+
it(name, () => {
672+
const UserStore = signalStore(
673+
{ providedIn: 'root', protectedState: false },
674+
linkedStateFeature,
675+
withMethods((store) => ({
676+
updateUser(name: string) {
677+
patchState(store, () => ({
678+
user: { name },
679+
}));
680+
},
681+
updateLocation(city: string, country: string) {
682+
patchState(store, () => ({
683+
location: { city, country },
684+
}));
685+
},
686+
}))
687+
);
688+
689+
const userStore = TestBed.inject(UserStore);
690+
691+
userStore.updateUser('Jane Doe');
692+
userStore.updateLocation('London', 'UK');
693+
694+
expect(getState(userStore)).toEqual({
695+
userId: 1,
696+
user: { name: 'Jane Doe' },
697+
location: { city: 'London', country: 'UK' },
698+
});
699+
700+
patchState(userStore, { userId: 2 });
701+
expect(getState(userStore)).toEqual({
702+
userId: 2,
703+
user: { name: 'John Doe' },
704+
location: { city: 'Berlin', country: 'Germany' },
705+
});
706+
});
707+
});
708+
});
709+
710+
describe('can depend on a Signal from another SignalStore', () => {
711+
const UserStore = signalStore(
712+
{ providedIn: 'root', protectedState: false },
713+
withState({ userId: 1 })
714+
);
715+
716+
[
717+
{
718+
name: 'automatic',
719+
linkedStateFeature: signalStoreFeature(
720+
withLinkedState(() => ({
721+
books: () => {
722+
TestBed.inject(UserStore).userId();
723+
724+
return [] as string[];
725+
},
726+
}))
727+
),
728+
},
729+
{
730+
name: 'manual',
731+
linkedStateFeature: signalStoreFeature(
732+
withLinkedState(() => {
733+
const userStore = TestBed.inject(UserStore);
734+
735+
return {
736+
books: linkedSignal({
737+
source: userStore.userId,
738+
computation: () => [] as string[],
739+
}),
740+
};
741+
})
742+
),
743+
},
744+
].forEach(({ name, linkedStateFeature }) => {
745+
it(name, () => {
746+
const BookStore = signalStore(
747+
{ providedIn: 'root' },
748+
linkedStateFeature,
749+
withMethods((store) => ({
750+
addBook(title: string) {
751+
patchState(store, ({ books }) => ({
752+
books: [...books, title],
753+
}));
754+
},
755+
}))
756+
);
757+
758+
const userStore = TestBed.inject(UserStore);
759+
const bookStore = TestBed.inject(BookStore);
760+
761+
bookStore.addBook('The Neverending Story');
762+
expect(bookStore.books()).toEqual(['The Neverending Story']);
763+
764+
patchState(userStore, { userId: 2 });
765+
766+
expect(bookStore.books()).toEqual([]);
767+
});
768+
});
769+
});
770+
771+
describe('InnerSignalStore access', () => {
772+
it('can access the state signals', () => {
773+
const UserStore = signalStore(
774+
{ providedIn: 'root' },
775+
withState({ userId: 1 }),
776+
withLinkedState(({ userId }) => ({ value: userId }))
777+
);
778+
779+
const userStore = TestBed.inject(UserStore);
780+
781+
expect(userStore.value()).toBe(1);
782+
});
783+
784+
it('can access the props', () => {
785+
const UserStore = signalStore(
786+
{ providedIn: 'root' },
787+
withProps(() => ({ userId: 1 })),
788+
withLinkedState(({ userId }) => ({ value: () => userId }))
789+
);
790+
791+
const userStore = TestBed.inject(UserStore);
792+
793+
expect(userStore.value()).toBe(1);
794+
});
795+
});
796+
});
568797
});

modules/signals/spec/state-source.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe('StateSource', () => {
211211
});
212212
});
213213

214-
it("sets all root properties and relies on the Signal's equals function", () => {
214+
it('sets only root properties which values have changed (equal check)', () => {
215215
let updateCounter = 0;
216216
const userSignal = signal(
217217
{
@@ -235,18 +235,18 @@ describe('StateSource', () => {
235235
expect(updateCounter).toBe(0);
236236

237237
patchState(store, { city: 'Xian' });
238-
expect(updateCounter).toBe(1);
238+
expect(updateCounter).toBe(0);
239239

240240
patchState(store, (state) => state);
241-
expect(updateCounter).toBe(2);
241+
expect(updateCounter).toBe(0);
242242

243243
patchState(store, ({ user }) => ({ user }));
244-
expect(updateCounter).toBe(3);
244+
expect(updateCounter).toBe(0);
245245

246246
patchState(store, ({ user }) => ({
247247
user: { ...user, firstName: 'Jane' },
248248
}));
249-
expect(updateCounter).toBe(4);
249+
expect(updateCounter).toBe(1);
250250
});
251251
});
252252

0 commit comments

Comments
 (0)