Skip to content

Commit

Permalink
feat(signal-slice): add lazySources (#197)
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuamorony authored Dec 10, 2023
1 parent 8c8263e commit 44cf5f9
Show file tree
Hide file tree
Showing 3 changed files with 143 additions and 51 deletions.
111 changes: 72 additions & 39 deletions libs/ngxtension/connect/src/connect.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { DestroyRef, Injector, type WritableSignal } from '@angular/core';
import {
DestroyRef,
Injector,
untracked,
type WritableSignal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { assertInjector } from 'ngxtension/assert-injector';
import { Subscription, isObservable, type Observable } from 'rxjs';
import { isObservable, Subscription, type Observable } from 'rxjs';

export type PartialOrValue<TValue> = TValue extends object
? Partial<TValue>
Expand Down Expand Up @@ -45,6 +50,7 @@ type ConnectedSignal<TSignalValue> = {
export function connect<TSignalValue>(
signal: WritableSignal<TSignalValue>,
injectorOrDestroyRef?: Injector | DestroyRef,
useUntracked?: boolean,
): ConnectedSignal<TSignalValue>;
export function connect<
TSignalValue,
Expand All @@ -53,22 +59,18 @@ export function connect<
signal: WritableSignal<TSignalValue>,
observable: Observable<TObservableValue>,
injectorOrDestroyRef?: Injector | DestroyRef,
useUntracked?: boolean,
): Subscription;
export function connect<TSignalValue, TObservableValue>(
signal: WritableSignal<TSignalValue>,
observable: Observable<TObservableValue>,
reducer: Reducer<TSignalValue, TObservableValue>,
injectorOrDestroyRef?: Injector | DestroyRef,
useUntracked?: boolean,
): Subscription;
export function connect(
signal: WritableSignal<unknown>,
...args: [
(Observable<unknown> | (Injector | DestroyRef))?,
(Reducer<unknown, unknown> | (Injector | DestroyRef))?,
(Injector | DestroyRef)?,
]
) {
const [observable, reducer, injectorOrDestroyRef] = parseArgs(args);
export function connect(signal: WritableSignal<unknown>, ...args: any[]) {
const [observable, reducer, injectorOrDestroyRef, useUntracked] =
parseArgs(args);

if (observable) {
let destroyRef = null;
Expand All @@ -81,13 +83,21 @@ export function connect(
}

return observable.pipe(takeUntilDestroyed(destroyRef)).subscribe((x) => {
signal.update((prev) => {
if (typeof prev === 'object' && !Array.isArray(prev)) {
return { ...prev, ...((reducer?.(prev, x) || x) as object) };
}
const update = () => {
signal.update((prev) => {
if (typeof prev === 'object' && !Array.isArray(prev)) {
return { ...prev, ...((reducer?.(prev, x) || x) as object) };
}

return reducer?.(prev, x) || x;
});
return reducer?.(prev, x) || x;
});
};

if (useUntracked) {
untracked(update);
} else {
update();
}
});
}

Expand All @@ -103,7 +113,8 @@ export function connect(
connect(
signal,
...(args as any),
injectorOrDestroyRef,
injectorOrDestroyRef as any,
useUntracked,
) as unknown as Subscription,
);
return this;
Expand All @@ -112,45 +123,67 @@ export function connect(
} as ConnectedSignal<unknown>;
}

// TODO: there must be a way to parse the args more efficiently
function parseArgs(
args: [
(Observable<unknown> | (Injector | DestroyRef))?,
(Reducer<unknown, unknown> | (Injector | DestroyRef))?,
(Injector | DestroyRef)?,
],
args: any[],
): [
Observable<unknown> | null,
Reducer<unknown, unknown> | null,
Injector | DestroyRef | null,
boolean,
] {
if (args.length > 2) {
if (args.length > 3) {
return [
args[0] as Observable<unknown>,
args[1] as Reducer<unknown, unknown>,
args[2] as Injector | DestroyRef,
args[3] as boolean,
];
}

if (args.length === 3) {
if (typeof args[2] === 'boolean') {
return [
args[0] as Observable<unknown>,
null,
args[1] as Injector | DestroyRef,
args[2],
];
}

return [
args[0] as Observable<unknown>,
args[1] as Reducer<unknown, unknown>,
args[2] as Injector | DestroyRef,
false,
];
}

if (args.length === 2) {
const [arg, arg2] = args;
const parsedArgs: [
Observable<unknown>,
Reducer<unknown, unknown> | null,
Injector | DestroyRef | null,
] = [arg as Observable<unknown>, null, null];
if (typeof arg2 === 'function') {
parsedArgs[1] = arg2;
} else {
parsedArgs[2] = arg2 as Injector | DestroyRef;
if (typeof args[1] === 'boolean') {
return [null, null, args[0] as Injector | DestroyRef, args[1]];
}

if (typeof args[1] === 'function') {
return [
args[0] as Observable<unknown>,
args[1] as Reducer<unknown, unknown>,
null,
false,
];
}

return parsedArgs;
return [
args[0] as Observable<unknown>,
null,
args[1] as Injector | DestroyRef,
false,
];
}

const arg = args[0];
if (isObservable(arg)) {
return [arg, null, null];
if (isObservable(args[0])) {
return [args[0] as Observable<unknown>, null, null, false];
}

return [null, null, arg as Injector | DestroyRef];
return [null, null, args[0] as Injector | DestroyRef, false];
}
35 changes: 35 additions & 0 deletions libs/ngxtension/signal-slice/src/signal-slice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,41 @@ describe(signalSlice.name, () => {
});
});

describe('lazySources', () => {
const testFn = jest.fn();
const testSource$ = of('test').pipe(
map(() => {
testFn();
return {};
}),
);

let state: SignalSlice<typeof initialState, any, any, any, any>;

beforeEach(() => {
TestBed.runInInjectionContext(() => {
state = signalSlice({
initialState,
lazySources: [testSource$],
});
});
});

it('should be not connect lazy source initially', () => {
expect(testFn).not.toHaveBeenCalled();
});

it('should connect lazy source after selector is accessed', () => {
state.age();
expect(testFn).toHaveBeenCalled();
});

it('should connect lazy source after signal value is accessed', () => {
state.age();
expect(testFn).toHaveBeenCalled();
});
});

describe('actionSources', () => {
it('should create action that updates signal', () => {
TestBed.runInInjectionContext(() => {
Expand Down
48 changes: 36 additions & 12 deletions libs/ngxtension/signal-slice/src/signal-slice.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
DestroyRef,
Injector,
computed,
effect,
inject,
signal,
type EffectRef,
type Signal,
type WritableSignal,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { connect, type PartialOrValue, type Reducer } from 'ngxtension/connect';
Expand Down Expand Up @@ -112,6 +114,9 @@ type ActionStreams<
};

export type Source<TSignalValue> = Observable<PartialOrValue<TSignalValue>>;
type SourceConfig<TSignalValue> = Array<
Source<TSignalValue> | ((state: Signal<TSignalValue>) => Source<TSignalValue>)
>;

export type SignalSlice<
TSignalValue extends NoOptionalProperties<TSignalValue>,
Expand All @@ -135,10 +140,8 @@ export function signalSlice<
TActionEffects extends NamedActionEffects<TActionSources>,
>(config: {
initialState: TSignalValue;
sources?: Array<
| Source<TSignalValue>
| ((state: Signal<TSignalValue>) => Source<TSignalValue>)
>;
sources?: SourceConfig<TSignalValue>;
lazySources?: SourceConfig<TSignalValue>;
actionSources?: TActionSources;
selectors?: (
state: SignalSlice<
Expand Down Expand Up @@ -175,10 +178,12 @@ export function signalSlice<
TActionEffects
> {
const destroyRef = inject(DestroyRef);
const injector = inject(Injector);

const {
initialState,
sources = [],
lazySources = [],
actionSources = {},
selectors = (() => ({})) as unknown as Exclude<
(typeof config)['selectors'],
Expand All @@ -197,6 +202,7 @@ export function signalSlice<
const state = signal(initialState);
const readonlyState = state.asReadonly();
const state$ = toObservable(state);
let lazySourcesLoaded = false;

const subs: Subject<any>[] = [];

Expand All @@ -208,13 +214,7 @@ export function signalSlice<
TActionEffects
>;

for (const source of sources) {
if (isObservable(source)) {
connect(state, source);
} else {
connect(state, source(readonlyState));
}
}
connectSources(state, sources);

for (const [key, actionSource] of Object.entries(
actionSources as TActionSources,
Expand Down Expand Up @@ -286,7 +286,31 @@ export function signalSlice<
subs.forEach((sub) => sub.complete());
});

return slice;
return new Proxy(slice, {
get(target, property, receiver) {
if (!lazySourcesLoaded) {
lazySourcesLoaded = true;
connectSources(state, lazySources, injector, true);
}

return Reflect.get(target, property, receiver);
},
});
}

function connectSources<TSignalValue>(
state: WritableSignal<TSignalValue>,
sources: SourceConfig<TSignalValue>,
injector?: Injector,
useUntracked = false,
) {
for (const source of sources) {
if (isObservable(source)) {
connect(state, source, injector, useUntracked);
} else {
connect(state, source(state.asReadonly()), injector, useUntracked);
}
}
}

function addReducerProperties(
Expand Down

0 comments on commit 44cf5f9

Please sign in to comment.