Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial implementation for sync connect between signals #413

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions libs/ngxtension/connect/src/connect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestComponent>;

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({
Expand Down
120 changes: 74 additions & 46 deletions libs/ngxtension/connect/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,53 +33,55 @@ type ConnectedSignal<TSignalValue> = {
};

/**
* 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<TSignalValue>(
signal: WritableSignal<TSignalValue>,
injectorOrDestroyRef?: Injector | DestroyRef,
useUntracked?: boolean,
): ConnectedSignal<TSignalValue>;
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<TSignalValue>(
signal: WritableSignal<TSignalValue>,
originSignal: () => TSignalValue,
): EffectRef;
injectorOrDestroyRef?: Injector | DestroyRef,
useUntracked?: boolean,
): ConnectedSignal<TSignalValue>;

export function connect<
TSignalValue,
Expand All @@ -92,8 +94,10 @@ export function connect<
): Subscription;
export function connect<TSignalValue, TObservableValue>(
signal: WritableSignal<TSignalValue>,
observable: Observable<TObservableValue>,
reducer: Reducer<TSignalValue, TObservableValue>,
observable: Observable<TObservableValue> | (() => TObservableValue),
reducer:
| Reducer<TSignalValue, TObservableValue>
| { injector?: Injector | DestroyRef; sync?: boolean },
injectorOrDestroyRef?: Injector | DestroyRef,
useUntracked?: boolean,
): Subscription;
Expand All @@ -104,6 +108,7 @@ export function connect(signal: WritableSignal<unknown>, ...args: any[]) {
injectorOrDestroyRef,
useUntracked,
originSignal,
isSync,
] = parseArgs(args);

if (observable) {
Expand Down Expand Up @@ -148,21 +153,21 @@ export function connect(signal: WritableSignal<unknown>, ...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 {
Expand All @@ -188,14 +193,13 @@ export function connect(signal: WritableSignal<unknown>, ...args: any[]) {
}

// TODO: there must be a way to parse the args more efficiently
function parseArgs(
args: any[],
): [
Observable<unknown> | null,
Reducer<unknown, unknown> | null,
Injector | DestroyRef | null,
boolean,
(() => unknown) | null,
function parseArgs(args: any[]): [
Observable<unknown> | null, // observable
Reducer<unknown, unknown> | null, // reducer
Injector | DestroyRef | null, // injector or destroyRef
boolean, // useUntracked
(() => unknown) | null, // originSignal
boolean, // isSync
] {
if (args.length > 3) {
return [
Expand All @@ -204,6 +208,7 @@ function parseArgs(
args[2] as Injector | DestroyRef,
args[3] as boolean,
null,
false,
];
}

Expand All @@ -216,6 +221,7 @@ function parseArgs(
args[1] as Injector | DestroyRef,
args[2],
null,
false,
];
} else {
return [
Expand All @@ -224,6 +230,7 @@ function parseArgs(
args[1] as Injector | DestroyRef,
args[2],
args[0] as () => unknown,
false,
];
}
}
Expand All @@ -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') {
Expand All @@ -249,6 +264,7 @@ function parseArgs(
null,
false,
null,
false,
];
}

Expand All @@ -258,19 +274,31 @@ function parseArgs(
args[1] as Injector | DestroyRef,
false,
null,
false,
];
}

if (isObservable(args[0])) {
return [args[0] as Observable<unknown>, null, null, false, null];
return [args[0] as Observable<unknown>, 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 {
Expand Down
Loading