diff --git a/libs/ngxtension/connect/src/connect.spec.ts b/libs/ngxtension/connect/src/connect.spec.ts index a30fcc3d..5abda927 100644 --- a/libs/ngxtension/connect/src/connect.spec.ts +++ b/libs/ngxtension/connect/src/connect.spec.ts @@ -215,6 +215,50 @@ describe(connect.name, () => { }); }); + describe('connects a signal to a signal not in injection context', () => { + @Component({ + standalone: true, + template: '{{ text() }}-{{ someOtherText() }}', + }) + class TestComponent implements OnInit { + private injector = inject(Injector); + + text = signal(''); + someOtherText = signal(''); + + ngOnInit() { + connect(this.text, () => this.someOtherText(), { + injector: this.injector, + }); + } + } + + let component: TestComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + }); + + it('works fine', () => { + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('-'); + + component.someOtherText.set('text'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('text-text'); + + component.someOtherText.set('text2'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('text2-text2'); + + component.text.set('text3'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('text3-text2'); + }); + }); + describe('connects to a slice of a state signal', () => { it('should update properly', () => { const state = signal({ diff --git a/libs/ngxtension/connect/src/connect.ts b/libs/ngxtension/connect/src/connect.ts index 43a92782..66208c7c 100644 --- a/libs/ngxtension/connect/src/connect.ts +++ b/libs/ngxtension/connect/src/connect.ts @@ -33,53 +33,55 @@ type ConnectedSignal = { }; /** - * Connects a signal to an observable and returns a subscription. The subscription is automatically - * unsubscribed when the component is destroyed. If it's not called in an injection context, it must - * be called with an injector or DestroyRef. - * + * Connects a signal to another signal value. + * @param signal The signal to connect to. + * @param originSignal A callback fn that includes a signal call. The signal call will be tracked. * * Usage * ```ts - * @Component({}) * export class MyComponent { * private dataService = inject(DataService); * - * data = signal([] as string[]); + * name = signal(''); * * constructor() { - * connect(this.data, this.dataService.data$); + * connect(this.name, () => this.dataService.user().name); * } * } * ``` + * @param options An object that includes an injector or DestroyRef and a sync flag. */ export function connect( signal: WritableSignal, - injectorOrDestroyRef?: Injector | DestroyRef, - useUntracked?: boolean, -): ConnectedSignal; + originSignal: () => TSignalValue, + options?: { injectorOrDestroyRef?: Injector | DestroyRef; sync?: boolean }, +): EffectRef; /** - * Connects a signal to another signal value. - * @param signal The signal to connect to. - * @param originSignal A callback fn that includes a signal call. The signal call will be tracked. + * Connects a signal to an observable and returns a subscription. The subscription is automatically + * unsubscribed when the component is destroyed. If it's not called in an injection context, it must + * be called with an injector or DestroyRef. + * * * Usage * ```ts + * @Component({}) * export class MyComponent { - * private dataService = inject(DataService); + * private dataService = inject(DataService); * - * name = signal(''); + * data = signal([] as string[]); * * constructor() { - * connect(this.name, () => this.dataService.user().name); + * connect(this.data, this.dataService.data$); * } * } * ``` */ export function connect( signal: WritableSignal, - originSignal: () => TSignalValue, -): EffectRef; + injectorOrDestroyRef?: Injector | DestroyRef, + useUntracked?: boolean, +): ConnectedSignal; export function connect< TSignalValue, @@ -92,8 +94,10 @@ export function connect< ): Subscription; export function connect( signal: WritableSignal, - observable: Observable, - reducer: Reducer, + observable: Observable | (() => TObservableValue), + reducer: + | Reducer + | { injector?: Injector | DestroyRef; sync?: boolean }, injectorOrDestroyRef?: Injector | DestroyRef, useUntracked?: boolean, ): Subscription; @@ -104,6 +108,7 @@ export function connect(signal: WritableSignal, ...args: any[]) { injectorOrDestroyRef, useUntracked, originSignal, + isSync, ] = parseArgs(args); if (observable) { @@ -148,21 +153,21 @@ export function connect(signal: WritableSignal, ...args: any[]) { ? assertInjector(connect, injectorOrDestroyRef) : undefined; - return effect( - () => { - signal.update((prev) => { - if (!isObject(prev)) { - return originSignal(); - } + const updateSignal = () => { + signal.update((prev) => { + if (!isObject(prev)) { + return originSignal(); + } + return { ...prev, ...(originSignal() as object) }; + }); + }; - return { ...prev, ...(originSignal() as object) }; - }); - }, - { - allowSignalWrites: true, - injector, - }, - ); + if (isSync) { + // sync signals are updated immediately + updateSignal(); + } + + return effect(() => updateSignal(), { allowSignalWrites: true, injector }); } return { @@ -188,14 +193,13 @@ export function connect(signal: WritableSignal, ...args: any[]) { } // TODO: there must be a way to parse the args more efficiently -function parseArgs( - args: any[], -): [ - Observable | null, - Reducer | null, - Injector | DestroyRef | null, - boolean, - (() => unknown) | null, +function parseArgs(args: any[]): [ + Observable | null, // observable + Reducer | null, // reducer + Injector | DestroyRef | null, // injector or destroyRef + boolean, // useUntracked + (() => unknown) | null, // originSignal + boolean, // isSync ] { if (args.length > 3) { return [ @@ -204,6 +208,7 @@ function parseArgs( args[2] as Injector | DestroyRef, args[3] as boolean, null, + false, ]; } @@ -216,6 +221,7 @@ function parseArgs( args[1] as Injector | DestroyRef, args[2], null, + false, ]; } else { return [ @@ -224,6 +230,7 @@ function parseArgs( args[1] as Injector | DestroyRef, args[2], args[0] as () => unknown, + false, ]; } } @@ -234,12 +241,20 @@ function parseArgs( args[2] as Injector | DestroyRef, false, null, + false, ]; } if (args.length === 2) { if (typeof args[1] === 'boolean') { - return [null, null, args[0] as Injector | DestroyRef, args[1], null]; + return [ + null, + null, + args[0] as Injector | DestroyRef, + args[1], + null, + false, + ]; } if (typeof args[1] === 'function') { @@ -249,6 +264,7 @@ function parseArgs( null, false, null, + false, ]; } @@ -258,19 +274,31 @@ function parseArgs( args[1] as Injector | DestroyRef, false, null, + false, ]; } if (isObservable(args[0])) { - return [args[0] as Observable, null, null, false, null]; + return [args[0] as Observable, null, null, false, null, false]; } // to connect signals to other signals, we need to use a callback that includes a signal call if (typeof args[0] === 'function') { - return [null, null, null, false, args[0] as () => unknown]; + const { injectorOrDestroyRef, sync } = (args[1] || {}) as { + injectorOrDestroyRef?: Injector | DestroyRef; + sync?: boolean; + }; + return [ + null, + null, + injectorOrDestroyRef || null, + false, + args[0] as () => unknown, + sync || false, + ]; } - return [null, null, args[0] as Injector | DestroyRef, false, null]; + return [null, null, args[0] as Injector | DestroyRef, false, null, false]; } function isObject(val: any): val is object {