Skip to content

Commit

Permalink
feat(signals): add signalMethod (#4597)
Browse files Browse the repository at this point in the history
Closes #4581

Co-authored-by: Tim Deschryver <[email protected]>
Co-authored-by: Marko Stanimirović <[email protected]>
  • Loading branch information
3 people authored Dec 4, 2024
1 parent e626082 commit bdd1d3e
Show file tree
Hide file tree
Showing 5 changed files with 439 additions and 0 deletions.
201 changes: 201 additions & 0 deletions modules/signals/spec/signal-method.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { signalMethod } from '../src';
import { TestBed } from '@angular/core/testing';
import {
createEnvironmentInjector,
EnvironmentInjector,
runInInjectionContext,
signal,
} from '@angular/core';

describe('signalMethod', () => {
const createAdder = (processingFn: (value: number) => void) =>
TestBed.runInInjectionContext(() => signalMethod<number>(processingFn));

it('processes a non-signal input', () => {
let a = 1;
const adder = createAdder((value) => (a += value));
adder(2);
expect(a).toBe(3);
});

it('processes a signal input', () => {
let a = 1;
const summand = signal(1);
const adder = createAdder((value) => (a += value));

adder(summand);
expect(a).toBe(1);

TestBed.flushEffects();
expect(a).toBe(2);

summand.set(2);
summand.set(2);
TestBed.flushEffects();
expect(a).toBe(4);

TestBed.flushEffects();
expect(a).toBe(4);
});

it('throws if is a not created in an injection context', () => {
expect(() => signalMethod<void>(() => void true)).toThrowError();
});

describe('destroying signalMethod', () => {
it('stops signal tracking, when signalMethod gets destroyed', () => {
let a = 1;
const summand = signal(1);
const adder = createAdder((value) => (a += value));
adder(summand);

summand.set(2);
TestBed.flushEffects();
expect(a).toBe(3);

adder.destroy();

summand.set(2);
TestBed.flushEffects();
expect(a).toBe(3);
});

it('can also destroy a signalMethod that processes non-signal inputs', () => {
const adder = createAdder(() => void true);
expect(() => adder(1).destroy()).not.toThrowError();
});

it('stops tracking all signals on signalMethod destroy', () => {
let a = 1;
const summand1 = signal(1);
const summand2 = signal(2);
const adder = createAdder((value) => (a += value));
adder(summand1);
adder(summand2);

summand1.set(2);
summand2.set(3);
TestBed.flushEffects();
expect(a).toBe(6);

adder.destroy();

summand1.set(2);
summand2.set(3);
TestBed.flushEffects();
expect(a).toBe(6);
});

it('does not cause issues if destroyed signalMethodFn contains destroyed effectRefs', () => {
let a = 1;
const summand1 = signal(1);
const summand2 = signal(2);
const adder = createAdder((value) => (a += value));

const childInjector = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);

adder(summand1, { injector: childInjector });
adder(summand2);

TestBed.flushEffects();
expect(a).toBe(4);
childInjector.destroy();

summand1.set(2);
summand2.set(2);
TestBed.flushEffects();

adder.destroy();
expect(a).toBe(4);
});

it('uses the provided injector (source injector) on creation', () => {
let a = 1;
const sourceInjector = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);
const adder = signalMethod((value: number) => (a += value), {
injector: sourceInjector,
});
const value = signal(1);

adder(value);
TestBed.flushEffects();

sourceInjector.destroy();
value.set(2);
TestBed.flushEffects();

expect(a).toBe(2);
});

it('prioritizes the provided caller injector over source injector', () => {
let a = 1;
const callerInjector = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);
const sourceInjector = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);
const adder = signalMethod((value: number) => (a += value), {
injector: sourceInjector,
});
const value = signal(1);

TestBed.runInInjectionContext(() => {
adder(value, { injector: callerInjector });
});
TestBed.flushEffects();
expect(a).toBe(2);

sourceInjector.destroy();
value.set(2);
TestBed.flushEffects();

expect(a).toBe(4);
});

it('prioritizes the provided injector over source and caller injector', () => {
let a = 1;
const callerInjector = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);
const sourceInjector = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);
const providedInjector = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);

const adder = signalMethod((value: number) => (a += value), {
injector: sourceInjector,
});
const value = signal(1);

runInInjectionContext(callerInjector, () =>
adder(value, { injector: providedInjector })
);
TestBed.flushEffects();
expect(a).toBe(2);

sourceInjector.destroy();
value.set(2);
TestBed.flushEffects();
expect(a).toBe(4);

callerInjector.destroy();
value.set(1);
TestBed.flushEffects();
expect(a).toBe(5);
});
});
});
1 change: 1 addition & 0 deletions modules/signals/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { deepComputed } from './deep-computed';
export { DeepSignal } from './deep-signal';
export { signalMethod } from './signal-method';
export { signalState, SignalState } from './signal-state';
export { signalStore } from './signal-store';
export { signalStoreFeature, type } from './signal-store-feature';
Expand Down
66 changes: 66 additions & 0 deletions modules/signals/src/signal-method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
assertInInjectionContext,
effect,
EffectRef,
inject,
Injector,
isSignal,
Signal,
untracked,
} from '@angular/core';

type SignalMethod<Input> = ((
input: Input | Signal<Input>,
config?: { injector?: Injector }
) => EffectRef) &
EffectRef;

export function signalMethod<Input>(
processingFn: (value: Input) => void,
config?: { injector?: Injector }
): SignalMethod<Input> {
if (!config?.injector) {
assertInInjectionContext(signalMethod);
}

const watchers: EffectRef[] = [];
const sourceInjector = config?.injector ?? inject(Injector);

const signalMethodFn = (
input: Input | Signal<Input>,
config?: { injector?: Injector }
): EffectRef => {
if (isSignal(input)) {
const instanceInjector =
config?.injector ?? getCallerInjector() ?? sourceInjector;

const watcher = effect(
(onCleanup) => {
const value = input();
untracked(() => processingFn(value));
onCleanup(() => watchers.splice(watchers.indexOf(watcher), 1));
},
{ injector: instanceInjector }
);
watchers.push(watcher);

return watcher;
} else {
processingFn(input);
return { destroy: () => void true };
}
};

signalMethodFn.destroy = () =>
watchers.forEach((watcher) => watcher.destroy());

return signalMethodFn;
}

function getCallerInjector(): Injector | null {
try {
return inject(Injector);
} catch {
return null;
}
}
Loading

0 comments on commit bdd1d3e

Please sign in to comment.