diff --git a/src/app/shared/application/store.spec.ts b/src/app/shared/application/store.spec.ts new file mode 100644 index 0000000..e93958b --- /dev/null +++ b/src/app/shared/application/store.spec.ts @@ -0,0 +1,32 @@ +import { ApplicationRef, effect } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { createStore } from './store'; + +describe('createStore', () => { + it('applies the pure update on dispatch', () => { + const store = createStore(0, (n: number, m: number) => n + m); + store.dispatch(5); + store.dispatch(3); + expect(store.model()).toBe(8); + }); + + // Regression: an effect that dispatches must NOT re-run because of its own write. + // dispatch used to read `model()` reactively (`set(update(model(), msg))`), so an + // effect calling dispatch subscribed to `model` and looped forever, livelocking the + // main thread (crashed the upload wizards). With `.update` the read is untracked. + it('dispatch from inside an effect does not self-loop', () => { + const store = createStore(0, (n: number, _m: 'inc') => n + 1); + let runs = 0; + TestBed.runInInjectionContext(() => { + effect(() => { + runs++; + if (runs < 100) store.dispatch('inc'); // bounded so the buggy version can't hang the test + }); + }); + TestBed.inject(ApplicationRef).tick(); // flush effects + + expect(runs).toBe(1); // effect ran once; its own dispatch did not retrigger it + expect(store.model()).toBe(1); + }); +}); diff --git a/src/app/shared/application/store.ts b/src/app/shared/application/store.ts index 86782bf..0e55de7 100644 --- a/src/app/shared/application/store.ts +++ b/src/app/shared/application/store.ts @@ -24,6 +24,10 @@ export function createStore( const model = signal(init); return { model: model.asReadonly(), - dispatch: (msg) => model.set(update(model(), msg)), + // Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`: + // dispatch is a command and must never subscribe its caller to `model`. Reading + // `model()` here inside an effect that also dispatches makes the effect depend on + // its own write and livelock the main thread (crashed the upload wizards). + dispatch: (msg) => model.update((m) => update(m, msg)), }; }