fix(types,testing): tighten action context typing and update tests for stricter TypeScript checks
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-30 - 2.3.1 - fix(types,testing)
|
||||||
|
tighten action context typing and update tests for stricter TypeScript checks
|
||||||
|
|
||||||
|
- enable noImplicitAny in TypeScript configuration and remove the build flag that allowed implicit any
|
||||||
|
- require the action context parameter in IActionDef to reflect actual action usage
|
||||||
|
- update tests to use the tstest tapbundle import and add explicit guards for possibly undefined state access
|
||||||
|
- refresh dependency versions and remove the deprecated tapbundle dev dependency
|
||||||
|
|
||||||
## 2026-03-27 - 2.3.0 - feat(stateprocess)
|
## 2026-03-27 - 2.3.0 - feat(stateprocess)
|
||||||
add managed state processes with lifecycle controls, scheduled actions, and disposal safety
|
add managed state processes with lifecycle controls, scheduled actions, and disposal safety
|
||||||
|
|
||||||
|
|||||||
Generated
-16523
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -10,20 +10,19 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --verbose)",
|
"test": "(tstest test/ --verbose)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany && tsbundle npm)",
|
"build": "(tsbuild tsfolders && tsbundle npm)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.4.0",
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
"@git.zone/tsbundle": "^2.10.0",
|
"@git.zone/tsbundle": "^2.10.0",
|
||||||
"@git.zone/tsrun": "^2.0.2",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tstest": "^3.6.1",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@push.rocks/tapbundle": "^6.0.3",
|
"@types/node": "^25.6.0"
|
||||||
"@types/node": "^25.5.0"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smarthash": "^3.2.6",
|
"@push.rocks/smarthash": "^3.2.7",
|
||||||
"@push.rocks/smartjson": "^6.0.0",
|
"@push.rocks/smartjson": "^6.0.1",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/webstore": "^2.0.21"
|
"@push.rocks/webstore": "^2.0.21"
|
||||||
@@ -38,6 +37,7 @@
|
|||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
".smartconfig.json",
|
".smartconfig.json",
|
||||||
|
"license",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
],
|
],
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
@@ -66,5 +66,5 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://code.foss.global/push.rocks/smartstate.git"
|
"url": "https://code.foss.global/push.rocks/smartstate.git"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
"packageManager": "pnpm@10.28.2"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+46
-2149
File diff suppressed because it is too large
Load Diff
+29
-21
@@ -1,4 +1,4 @@
|
|||||||
import { expect, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartstate from '../ts/index.js';
|
import * as smartstate from '../ts/index.js';
|
||||||
|
|
||||||
type TTestStateParts = 'initTest' | 'persistTest' | 'forceTest';
|
type TTestStateParts = 'initTest' | 'persistTest' | 'forceTest';
|
||||||
@@ -10,6 +10,14 @@ interface ITestState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRequiredState = <TPayload>(statePart: smartstate.StatePart<any, TPayload>): TPayload => {
|
||||||
|
const state = statePart.getState();
|
||||||
|
if (state === undefined) {
|
||||||
|
throw new Error('Expected state part to have initialized state');
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Init mode tests
|
// Init mode tests
|
||||||
// ============================
|
// ============================
|
||||||
@@ -245,7 +253,7 @@ tap.test('middleware should transform state', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await statePart.setState({ value: 2, nested: { data: 'hello' } });
|
await statePart.setState({ value: 2, nested: { data: 'hello' } });
|
||||||
expect(statePart.getState().nested.data).toEqual('HELLO');
|
expect(getRequiredState(statePart).nested.data).toEqual('HELLO');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('middleware should reject state changes on throw', async () => {
|
tap.test('middleware should reject state changes on throw', async () => {
|
||||||
@@ -272,7 +280,7 @@ tap.test('middleware should reject state changes on throw', async () => {
|
|||||||
expect(error).not.toBeNull();
|
expect(error).not.toBeNull();
|
||||||
expect(error?.message).toEqual('Value must be non-negative');
|
expect(error?.message).toEqual('Value must be non-negative');
|
||||||
// State should be unchanged
|
// State should be unchanged
|
||||||
expect(statePart.getState().value).toEqual(1);
|
expect(getRequiredState(statePart).value).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('multiple middlewares should run in order', async () => {
|
tap.test('multiple middlewares should run in order', async () => {
|
||||||
@@ -297,7 +305,7 @@ tap.test('multiple middlewares should run in order', async () => {
|
|||||||
await statePart.setState({ value: 5, nested: { data: 'test' } });
|
await statePart.setState({ value: 5, nested: { data: 'test' } });
|
||||||
expect(order).toEqual([1, 2]);
|
expect(order).toEqual([1, 2]);
|
||||||
// (5 + 10) * 2 = 30
|
// (5 + 10) * 2 = 30
|
||||||
expect(statePart.getState().value).toEqual(30);
|
expect(getRequiredState(statePart).value).toEqual(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('middleware removal should work', async () => {
|
tap.test('middleware removal should work', async () => {
|
||||||
@@ -312,12 +320,12 @@ tap.test('middleware removal should work', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await statePart.setState({ value: 2, nested: { data: 'test' } });
|
await statePart.setState({ value: 2, nested: { data: 'test' } });
|
||||||
expect(statePart.getState().value).toEqual(200);
|
expect(getRequiredState(statePart).value).toEqual(200);
|
||||||
|
|
||||||
remove();
|
remove();
|
||||||
|
|
||||||
await statePart.setState({ value: 3, nested: { data: 'test' } });
|
await statePart.setState({ value: 3, nested: { data: 'test' } });
|
||||||
expect(statePart.getState().value).toEqual(3);
|
expect(getRequiredState(statePart).value).toEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
@@ -639,7 +647,7 @@ tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10
|
|||||||
const counter = await state.getStatePart<{ count: number }>('counter', { count: 0 });
|
const counter = await state.getStatePart<{ count: number }>('counter', { count: 0 });
|
||||||
|
|
||||||
const increment = counter.createAction<void>(async (statePart) => {
|
const increment = counter.createAction<void>(async (statePart) => {
|
||||||
const current = statePart.getState();
|
const current = getRequiredState(statePart);
|
||||||
return { count: current.count + 1 };
|
return { count: current.count + 1 };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -650,7 +658,7 @@ tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10
|
|||||||
}
|
}
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
expect(counter.getState().count).toEqual(10);
|
expect(getRequiredState(counter).count).toEqual(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('concurrent setState should serialize (no lost updates)', async () => {
|
tap.test('concurrent setState should serialize (no lost updates)', async () => {
|
||||||
@@ -669,7 +677,7 @@ tap.test('concurrent setState should serialize (no lost updates)', async () => {
|
|||||||
// At minimum, the final state should have been set 5 times without error
|
// 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
|
// The exact values depend on serialization timing, but state should be valid
|
||||||
expect(part.getState()).toBeTruthy();
|
expect(part.getState()).toBeTruthy();
|
||||||
expect(part.getState().values).toBeInstanceOf(Array);
|
expect(getRequiredState(part).values).toBeInstanceOf(Array);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('dispose should complete the Subject and notify subscribers', async () => {
|
tap.test('dispose should complete the Subject and notify subscribers', async () => {
|
||||||
@@ -737,7 +745,7 @@ tap.test('batch re-entrancy: setState during flush should not deadlock', async (
|
|||||||
// Wait for the fire-and-forget setState to complete
|
// Wait for the fire-and-forget setState to complete
|
||||||
await new Promise<void>((r) => setTimeout(r, 50));
|
await new Promise<void>((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
expect(part.getState().v).toEqual(2);
|
expect(getRequiredState(part).v).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('force mode should dispose old StatePart (Subject completes)', async () => {
|
tap.test('force mode should dispose old StatePart (Subject completes)', async () => {
|
||||||
@@ -774,13 +782,13 @@ tap.test('context.dispatch should not deadlock on same StatePart', async () => {
|
|||||||
const part = await state.getStatePart<{ count: number }>('reentrantAction', { count: 0 });
|
const part = await state.getStatePart<{ count: number }>('reentrantAction', { count: 0 });
|
||||||
|
|
||||||
const innerIncrement = part.createAction<void>(async (sp) => {
|
const innerIncrement = part.createAction<void>(async (sp) => {
|
||||||
return { count: sp.getState().count + 1 };
|
return { count: getRequiredState(sp).count + 1 };
|
||||||
});
|
});
|
||||||
|
|
||||||
const outerAction = part.createAction<void>(async (sp, _payload, context) => {
|
const outerAction = part.createAction<void>(async (sp, _payload, context) => {
|
||||||
// This would deadlock without the context.dispatch() mechanism
|
// This would deadlock without the context.dispatch() mechanism
|
||||||
await context.dispatch(innerIncrement, undefined);
|
await context.dispatch(innerIncrement, undefined);
|
||||||
return sp.getState();
|
return getRequiredState(sp);
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await part.dispatchAction(outerAction, undefined);
|
const result = await part.dispatchAction(outerAction, undefined);
|
||||||
@@ -793,23 +801,23 @@ tap.test('deeply nested context.dispatch should work (3 levels)', async () => {
|
|||||||
const part = await state.getStatePart<{ steps: string[] }>('deepNested', { steps: [] });
|
const part = await state.getStatePart<{ steps: string[] }>('deepNested', { steps: [] });
|
||||||
|
|
||||||
const appendStep = part.createAction<string>(async (sp, step) => {
|
const appendStep = part.createAction<string>(async (sp, step) => {
|
||||||
return { steps: [...sp.getState().steps, step] };
|
return { steps: [...getRequiredState(sp).steps, step] };
|
||||||
});
|
});
|
||||||
|
|
||||||
const level2 = part.createAction<void>(async (sp, _payload, context) => {
|
const level2 = part.createAction<void>(async (sp, _payload, context) => {
|
||||||
await context.dispatch(appendStep, 'level-2');
|
await context.dispatch(appendStep, 'level-2');
|
||||||
return sp.getState();
|
return getRequiredState(sp);
|
||||||
});
|
});
|
||||||
|
|
||||||
const level1 = part.createAction<void>(async (sp, _payload, context) => {
|
const level1 = part.createAction<void>(async (sp, _payload, context) => {
|
||||||
await context.dispatch(appendStep, 'level-1');
|
await context.dispatch(appendStep, 'level-1');
|
||||||
await context.dispatch(level2, undefined);
|
await context.dispatch(level2, undefined);
|
||||||
await context.dispatch(appendStep, 'level-1-after');
|
await context.dispatch(appendStep, 'level-1-after');
|
||||||
return sp.getState();
|
return getRequiredState(sp);
|
||||||
});
|
});
|
||||||
|
|
||||||
await part.dispatchAction(level1, undefined);
|
await part.dispatchAction(level1, undefined);
|
||||||
expect(part.getState().steps).toEqual(['level-1', 'level-2', 'level-1-after']);
|
expect(getRequiredState(part).steps).toEqual(['level-1', 'level-2', 'level-1-after']);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('circular context.dispatch should throw max depth error', async () => {
|
tap.test('circular context.dispatch should throw max depth error', async () => {
|
||||||
@@ -820,12 +828,12 @@ tap.test('circular context.dispatch should throw max depth error', async () => {
|
|||||||
// Create a self-referencing action that will loop forever
|
// Create a self-referencing action that will loop forever
|
||||||
const circularAction: smartstate.StateAction<{ count: number }, void> = part.createAction<void>(
|
const circularAction: smartstate.StateAction<{ count: number }, void> = part.createAction<void>(
|
||||||
async (sp, _payload, context) => {
|
async (sp, _payload, context) => {
|
||||||
const current = sp.getState();
|
const current = getRequiredState(sp);
|
||||||
if (current.count < 100) {
|
if (current.count < 100) {
|
||||||
// This should eventually hit the depth limit
|
// This should eventually hit the depth limit
|
||||||
await context.dispatch(circularAction, undefined);
|
await context.dispatch(circularAction, undefined);
|
||||||
}
|
}
|
||||||
return sp.getState();
|
return getRequiredState(sp);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -851,7 +859,7 @@ tap.test('actions without context arg should still work (backward compat)', asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
await part.dispatchAction(simpleAction, 42);
|
await part.dispatchAction(simpleAction, 42);
|
||||||
expect(part.getState().value).toEqual(42);
|
expect(getRequiredState(part).value).toEqual(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('concurrent dispatches still serialize correctly with context feature', async () => {
|
tap.test('concurrent dispatches still serialize correctly with context feature', async () => {
|
||||||
@@ -860,7 +868,7 @@ tap.test('concurrent dispatches still serialize correctly with context feature',
|
|||||||
const part = await state.getStatePart<{ count: number }>('concurrentWithContext', { count: 0 });
|
const part = await state.getStatePart<{ count: number }>('concurrentWithContext', { count: 0 });
|
||||||
|
|
||||||
const increment = part.createAction<void>(async (sp) => {
|
const increment = part.createAction<void>(async (sp) => {
|
||||||
const current = sp.getState();
|
const current = getRequiredState(sp);
|
||||||
return { count: current.count + 1 };
|
return { count: current.count + 1 };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -871,7 +879,7 @@ tap.test('concurrent dispatches still serialize correctly with context feature',
|
|||||||
}
|
}
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
expect(part.getState().count).toEqual(10);
|
expect(getRequiredState(part).count).toEqual(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── distinctUntilChanged on selectors ──────────────────────────────────
|
// ── distinctUntilChanged on selectors ──────────────────────────────────
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import { expect, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import * as smartstate from '../ts/index.js';
|
import * as smartstate from '../ts/index.js';
|
||||||
|
|
||||||
type TMyStateParts = 'testStatePart';
|
type TMyStateParts = 'testStatePart';
|
||||||
@@ -39,7 +39,7 @@ tap.test('should select something', async () => {
|
|||||||
tap.test('should dispatch a state action', async (tools) => {
|
tap.test('should dispatch a state action', async (tools) => {
|
||||||
const done = tools.defer();
|
const done = tools.defer();
|
||||||
const addFavourite = testStatePart.createAction<string>(async (statePart, payload) => {
|
const addFavourite = testStatePart.createAction<string>(async (statePart, payload) => {
|
||||||
const currentState = statePart.getState();
|
const currentState = statePart.getState()!;
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
currentFavorites: [...currentState.currentFavorites, payload],
|
currentFavorites: [...currentState.currentFavorites, payload],
|
||||||
@@ -51,9 +51,9 @@ tap.test('should dispatch a state action', async (tools) => {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
done.resolve();
|
done.resolve();
|
||||||
});
|
});
|
||||||
await testStatePart.dispatchAction(addFavourite, 'my favourite things');
|
await testStatePart.dispatchAction(addFavourite, 'my favourite things');
|
||||||
expect(testStatePart.getState().currentFavorites).toContain('my favourite things');
|
expect(testStatePart.getState()!.currentFavorites).toContain('my favourite things');
|
||||||
await done.promise;
|
await done.promise;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartstate',
|
name: '@push.rocks/smartstate',
|
||||||
version: '2.3.0',
|
version: '2.3.1',
|
||||||
description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
|
description: 'A TypeScript-first reactive state management library with middleware, computed state, batching, persistence, and Web Component Context Protocol support.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface IActionContext<TStateType> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IActionDef<TStateType, TActionPayloadType> {
|
export interface IActionDef<TStateType, TActionPayloadType> {
|
||||||
(stateArg: StatePart<any, TStateType>, actionPayload: TActionPayloadType, context?: IActionContext<TStateType>): Promise<TStateType>;
|
(stateArg: StatePart<any, TStateType>, actionPayload: TActionPayloadType, context: IActionContext<TStateType>): Promise<TStateType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
|
"noImplicitAny": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
|
|||||||
Reference in New Issue
Block a user