fix(core): serialize state mutations, fix batch flushing/reentrancy, handle falsy initial values, dispose old StatePart on force, and improve notification/error handling
This commit is contained in:
@@ -629,4 +629,139 @@ tap.test('attachContextProvider should ignore non-matching contexts', async () =
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Enterprise hardening tests
|
||||
// ============================
|
||||
|
||||
tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10)', async () => {
|
||||
type TParts = 'counter';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const counter = await state.getStatePart<{ count: number }>('counter', { count: 0 });
|
||||
|
||||
const increment = counter.createAction<void>(async (statePart) => {
|
||||
const current = statePart.getState();
|
||||
return { count: current.count + 1 };
|
||||
});
|
||||
|
||||
// Fire 10 concurrent increments (no await)
|
||||
const promises: Promise<any>[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(counter.dispatchAction(increment, undefined));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(counter.getState().count).toEqual(10);
|
||||
});
|
||||
|
||||
tap.test('concurrent setState should serialize (no lost updates)', async () => {
|
||||
type TParts = 'concurrent';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ values: number[] }>('concurrent', { values: [] });
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
promises.push(
|
||||
part.setState({ values: [...(part.getState()?.values || []), i] })
|
||||
);
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
// At minimum, the final state should have been set 5 times without error
|
||||
// The exact values depend on serialization timing, but state should be valid
|
||||
expect(part.getState()).toBeTruthy();
|
||||
expect(part.getState().values).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
tap.test('dispose should complete the Subject and notify subscribers', async () => {
|
||||
type TParts = 'disposable';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('disposable', { v: 1 });
|
||||
|
||||
let completed = false;
|
||||
part.select().subscribe({
|
||||
complete: () => { completed = true; },
|
||||
});
|
||||
|
||||
part.dispose();
|
||||
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('falsy state {count: 0} should trigger notification', async () => {
|
||||
type TParts = 'falsy';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ count: number }>('falsy', { count: 0 });
|
||||
|
||||
const values: number[] = [];
|
||||
part.select((s) => s.count).subscribe((v) => values.push(v));
|
||||
|
||||
// Initial value should include 0
|
||||
expect(values).toContain(0);
|
||||
|
||||
await part.setState({ count: 0 });
|
||||
// Should not duplicate since hash is the same, but the initial notification should have fired
|
||||
expect(values.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
tap.test('waitUntilPresent should resolve for falsy non-null values like false', async () => {
|
||||
type TParts = 'falsyWait';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ flag: boolean }>('falsyWait', { flag: false });
|
||||
|
||||
const result = await part.waitUntilPresent((s) => s.flag as any, 2000);
|
||||
// false is not null/undefined, so it should resolve
|
||||
// Actually false IS falsy for `value !== undefined && value !== null` — false passes that check
|
||||
expect(result).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('batch re-entrancy: setState during flush should not deadlock', async () => {
|
||||
type TParts = 'reentrant';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const part = await state.getStatePart<{ v: number }>('reentrant', { v: 0 });
|
||||
|
||||
let flushSetStateDone = false;
|
||||
|
||||
// Subscribe and trigger a setState during notification flush
|
||||
part.select((s) => s.v).subscribe((v) => {
|
||||
if (v === 1 && !flushSetStateDone) {
|
||||
flushSetStateDone = true;
|
||||
// Fire-and-forget setState during notification — should not deadlock
|
||||
part.setState({ v: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
await state.batch(async () => {
|
||||
await part.setState({ v: 1 });
|
||||
});
|
||||
|
||||
// Wait for the fire-and-forget setState to complete
|
||||
await new Promise<void>((r) => setTimeout(r, 50));
|
||||
|
||||
expect(part.getState().v).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('force mode should dispose old StatePart (Subject completes)', async () => {
|
||||
type TParts = 'forceDispose';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
const old = await state.getStatePart<{ v: number }>('forceDispose', { v: 1 });
|
||||
|
||||
let oldCompleted = false;
|
||||
old.select().subscribe({
|
||||
complete: () => { oldCompleted = true; },
|
||||
});
|
||||
|
||||
await state.getStatePart<{ v: number }>('forceDispose', { v: 2 }, 'force');
|
||||
|
||||
expect(oldCompleted).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('getStatePart should accept 0 as initial value', async () => {
|
||||
type TParts = 'zeroInit';
|
||||
const state = new smartstate.Smartstate<TParts>();
|
||||
|
||||
// 0 is falsy but should be accepted as a valid initial value
|
||||
const part = await state.getStatePart<number>('zeroInit', 0);
|
||||
expect(part.getState()).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
Reference in New Issue
Block a user