Skip to content
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
161 changes: 102 additions & 59 deletions libs/ngxtension/reactive-on/src/on.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ApplicationRef,
Injector,
afterRenderEffect,
computed,
effect,
signal,
Expand Down Expand Up @@ -43,7 +44,6 @@ describe(on.name, () => {

effect(
on(count, (c) => {
// accessing 'other' which is not in deps
log.push(c + other());
}),
{ injector },
Expand All @@ -54,12 +54,10 @@ describe(on.name, () => {

other.set(20);
appRef.tick();
// Should NOT run again because 'other' is not in deps list passed to on()
expect(log).toEqual([10]);

count.set(1);
appRef.tick();
// Should run now, seeing the new value of 'other' ONLY because 'count' changed
expect(log).toEqual([10, 21]);
});

Expand All @@ -69,9 +67,12 @@ describe(on.name, () => {
const log: number[] = [];

effect(
on([a, b], ([valA, valB]) => {
log.push(valA + valB);
}),
on(
() => [a(), b()],
([valA, valB]) => {
log.push(valA + valB);
},
),
{ injector },
);

Expand All @@ -93,9 +94,12 @@ describe(on.name, () => {
const log: number[] = [];

effect(
on({ a, b }, ({ a: valA, b: valB }) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking more about it, I don't think we should implicitly invoke the signals.

log.push(valA * valB);
}),
on(
() => ({ a: a(), b: b() }),
({ a: valA, b: valB }) => {
log.push(valA * valB);
},
),
{ injector },
);

Expand All @@ -111,33 +115,12 @@ describe(on.name, () => {
expect(log).toEqual([2, 6, 12]);
});

it('should pass previous input correctly', () => {
const count = signal(0);
const log: string[] = [];

effect(
on(count, (input, prevInput) => {
const result = `cur: ${input}, prevIn: ${prevInput}`;
log.push(result);
return undefined;
}),
{ injector },
);

appRef.tick();
expect(log).toEqual(['cur: 0, prevIn: undefined']);

count.set(1);
appRef.tick();
expect(log).toEqual(['cur: 0, prevIn: undefined', 'cur: 1, prevIn: 0']);
});

it('should support cleanup function', () => {
const count = signal(0);
const log: string[] = [];

const effectRef = effect(
on(count, (c, _, __, onCleanup) => {
on(count, (c, onCleanup) => {
log.push(`run: ${c}`);
onCleanup(() => {
log.push(`cleanup: ${c}`);
Expand All @@ -157,31 +140,6 @@ describe(on.name, () => {
expect(log).toEqual(['run: 0', 'cleanup: 0', 'run: 1', 'cleanup: 1']);
});

it('should pass previous value correctly', () => {
const count = signal(1);
const log: number[] = [];

effect(
on(count, (c, _, prevValue) => {
const result = c + ((prevValue as number) || 0);
log.push(result);
return result;
}),
{ injector },
);

appRef.tick();
expect(log).toEqual([1]); // 1 + 0 (undefined prevValue treated as 0)

count.set(2);
appRef.tick();
expect(log).toEqual([1, 3]); // 2 + 1 (prevValue was 1)

count.set(3);
appRef.tick();
expect(log).toEqual([1, 3, 6]); // 3 + 3 (prevValue was 3)
});

it('should work with computed signals', () => {
const count = signal(1);
const doubleCount = computed(() => count() * 2);
Expand Down Expand Up @@ -218,14 +176,99 @@ describe(on.name, () => {
);

appRef.tick();
expect(log).toEqual([]); // Should not run on initial tick due to defer
expect(log).toEqual([]);

count.set(1);
appRef.tick();
expect(log).toEqual([1]); // Should run now because count changed
expect(log).toEqual([1]);

count.set(1);
appRef.tick();
expect(log).toEqual([1]);
});

it('should work seamlessly with computed', () => {
const count = signal(1);

const doubled = computed(on(count, (val) => val * 2));

expect(doubled()).toEqual(2);

count.set(3);
expect(doubled()).toEqual(6);
});

it('should work seamlessly with afterRenderEffect', () => {
const count = signal(0);
const log: number[] = [];

afterRenderEffect(
on(count, (c) => {
log.push(c);
}),
{ injector },
);

appRef.tick();
expect(log).toEqual([0]);

count.set(1);
appRef.tick();
expect(log).toEqual([0, 1]);
});

it('should work with afterRenderEffect phases', () => {
const count = signal(0);
const log: string[] = [];

const earlyRead = on(count, (c) => {
log.push(`earlyRead:${c}`);
return c;
});

const write = on(count, (c) => {
log.push(`write:${c}`);
return c;
});

const mixedReadWrite = on(count, (c) => {
log.push(`mixedReadWrite:${c}`);
return c;
});

const read = on(count, (c) => {
log.push(`read:${c}`);
});

afterRenderEffect(
{
earlyRead: () => earlyRead(),
write: () => write(),
mixedReadWrite: () => mixedReadWrite(),
read: () => read(),
},
{ injector },
);

appRef.tick();
expect(log).toEqual([
'earlyRead:0',
'write:0',
'mixedReadWrite:0',
'read:0',
]);

count.set(1);
appRef.tick();
expect(log).toEqual([1]); // Should NOT run again because count did not change
expect(log).toEqual([
'earlyRead:0',
'write:0',
'mixedReadWrite:0',
'read:0',
'earlyRead:1',
'write:1',
'mixedReadWrite:1',
'read:1',
]);
});
});
141 changes: 34 additions & 107 deletions libs/ngxtension/reactive-on/src/on.ts
Original file line number Diff line number Diff line change
@@ -1,122 +1,49 @@
import { EffectCleanupRegisterFn, Signal, untracked } from '@angular/core';

export type Accessor<T> = Signal<T> | (() => T);
import { untracked } from '@angular/core';

/**
* Makes dependencies of a computation explicit

* @param deps list of reactive dependencies or a single reactive dependency
* @param fn computation on input; the current previous content(s) of input and the previous value are given as arguments and it returns a new value
* @returns an effect function that is passed into `effect`. For example:
* Makes dependencies of a computation explicit.
*
* ```typescript
* Works with `effect`, `computed`, and `afterRenderEffect`.
*
* @example
* // With effect
* effect(on(a, (v) => console.log(v, b())));
*
* // is equivalent to:
* effect(() => {
* const v = a();
* untracked(() => console.log(v, b()));
* });
* ```
*
* @example
* // With afterRenderEffect and phases
* afterRenderEffect({
* read: on(a, (v) => {
* console.log('read phase', v);
* return v;
* }),
* write: on(b, (v, phaseValue) => {
* console.log('write phase', v, phaseValue);
* })
* });
*/
Comment on lines +3 to 29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The previous JSDoc included a helpful example and an explanation of how on works (by using untracked internally). Removing these details makes the utility harder to understand for new users. It would be beneficial to keep the explanation and update the example to include the new supported use cases like computed.

/**
 * Makes dependencies of a computation explicit.
 *
 * Works with `effect`, `computed`, and `afterRenderEffect`.
 *
 * @example
 * ```typescript
 * effect(on(a, (v) => console.log(v, b())));
 *
 * // is equivalent to:
 * effect(() => {
 *   const v = a();
 *   untracked(() => console.log(v, b()));
 * });
 *
 * // also works with computed:
 * const doubled = computed(on(count, (val) => val * 2));
 * ```
 */

export function on<
const Deps extends readonly Accessor<unknown>[],
U,
V = U | undefined,
>(
deps: readonly [...Deps],
fn: (
input: {
-readonly [K in keyof Deps]: Deps[K] extends Accessor<infer T>
? T
: never;
},
prevInput:
| {
-readonly [K in keyof Deps]: Deps[K] extends Accessor<infer T>
? T
: never;
}
| undefined,
prevValue: V | undefined,
cleanupFn: EffectCleanupRegisterFn,
) => U,
options?: { defer?: boolean },
): (onCleanup: EffectCleanupRegisterFn) => void;

export function on<
const Deps extends Record<string, Accessor<unknown>>,
U,
V = U | undefined,
>(
deps: Deps,
fn: (
input: { [K in keyof Deps]: Deps[K] extends Accessor<infer T> ? T : never },
prevInput:
| { [K in keyof Deps]: Deps[K] extends Accessor<infer T> ? T : never }
| undefined,
prevValue: V | undefined,
cleanupFn: EffectCleanupRegisterFn,
) => U,
options?: { defer?: boolean },
): (onCleanup: EffectCleanupRegisterFn) => void;

export function on<S, U, V = U | undefined>(
deps: Accessor<S>,
fn: (
input: S,
prevInput: S | undefined,
prevValue: V | undefined,
cleanupFn: EffectCleanupRegisterFn,
) => U,
options?: { defer?: boolean },
): (onCleanup: EffectCleanupRegisterFn) => void;

export function on(
deps:
| Accessor<unknown>
| readonly Accessor<unknown>[]
| Record<string, Accessor<unknown>>,
fn: (
input: any,
prevInput: any,
prevValue: any,
cleanupFn: EffectCleanupRegisterFn,
) => any,
options?: { defer?: boolean },
): (onCleanup: EffectCleanupRegisterFn) => void {
const isArray = Array.isArray(deps);
const isAccessor = typeof deps === 'function';
let prevInput: unknown;
let prevValue: unknown;
let defer = options && options.defer;

return (onCleanup: EffectCleanupRegisterFn) => {
let input: unknown;

if (isArray) {
input = (deps as readonly Accessor<unknown>[]).map((d) => d());
} else if (isAccessor) {
input = (deps as Accessor<unknown>)();
} else {
// Object
input = Object.keys(deps).reduce(
(acc, key) => {
acc[key] = (deps as Record<string, Accessor<unknown>>)[key]();
return acc;
},
{} as Record<string, unknown>,
);
}
export interface OnOptions {
defer?: boolean;
}

if (defer) {
defer = false;
return;
export function on<T, Ret, Args extends any[]>(
track: () => T,
execute: (tracked: T, ...args: Args) => Ret,
options?: OnOptions,
): (...args: Args) => Ret | undefined {
let isFirstRun = true;
return (...args: Args) => {
const trackedValue = track();
if (options?.defer && isFirstRun) {
isFirstRun = false;
return undefined as Ret;
}

untracked(() => {
prevValue = fn(input, prevInput, prevValue, onCleanup);
prevInput = input;
});
isFirstRun = false;
return untracked(() => execute(trackedValue, ...args));
};
}
Loading