fix(types,testing): tighten action context typing and update tests for stricter TypeScript checks

This commit is contained in:
2026-04-30 09:58:42 +00:00
parent a66518bde8
commit a62fa83afc
9 changed files with 97 additions and 18706 deletions
+8
View File
@@ -1,5 +1,13 @@
# 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)
add managed state processes with lifecycle controls, scheduled actions, and disposal safety
-16523
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -10,20 +10,19 @@
"license": "MIT",
"scripts": {
"test": "(tstest test/ --verbose)",
"build": "(tsbuild tsfolders --allowimplicitany && tsbundle npm)",
"build": "(tsbuild tsfolders && tsbundle npm)",
"buildDocs": "tsdoc"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.1",
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^25.5.0"
"@git.zone/tstest": "^3.6.3",
"@types/node": "^25.6.0"
},
"dependencies": {
"@push.rocks/smarthash": "^3.2.6",
"@push.rocks/smartjson": "^6.0.0",
"@push.rocks/smarthash": "^3.2.7",
"@push.rocks/smartjson": "^6.0.1",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/webstore": "^2.0.21"
@@ -38,6 +37,7 @@
"assets/**/*",
"cli.js",
".smartconfig.json",
"license",
"readme.md"
],
"browserslist": [
@@ -66,5 +66,5 @@
"type": "git",
"url": "https://code.foss.global/push.rocks/smartstate.git"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
"packageManager": "pnpm@10.28.2"
}
+46 -2149
View File
File diff suppressed because it is too large Load Diff
+29 -21
View File
@@ -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';
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
// ============================
@@ -245,7 +253,7 @@ tap.test('middleware should transform state', async () => {
});
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 () => {
@@ -272,7 +280,7 @@ tap.test('middleware should reject state changes on throw', async () => {
expect(error).not.toBeNull();
expect(error?.message).toEqual('Value must be non-negative');
// 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 () => {
@@ -297,7 +305,7 @@ tap.test('multiple middlewares should run in order', async () => {
await statePart.setState({ value: 5, nested: { data: 'test' } });
expect(order).toEqual([1, 2]);
// (5 + 10) * 2 = 30
expect(statePart.getState().value).toEqual(30);
expect(getRequiredState(statePart).value).toEqual(30);
});
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' } });
expect(statePart.getState().value).toEqual(200);
expect(getRequiredState(statePart).value).toEqual(200);
remove();
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 increment = counter.createAction<void>(async (statePart) => {
const current = statePart.getState();
const current = getRequiredState(statePart);
return { count: current.count + 1 };
});
@@ -650,7 +658,7 @@ tap.test('concurrent dispatchAction should serialize (counter reaches exactly 10
}
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 () => {
@@ -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
// The exact values depend on serialization timing, but state should be valid
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 () => {
@@ -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
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 () => {
@@ -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 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) => {
// This would deadlock without the context.dispatch() mechanism
await context.dispatch(innerIncrement, undefined);
return sp.getState();
return getRequiredState(sp);
});
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 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) => {
await context.dispatch(appendStep, 'level-2');
return sp.getState();
return getRequiredState(sp);
});
const level1 = part.createAction<void>(async (sp, _payload, context) => {
await context.dispatch(appendStep, 'level-1');
await context.dispatch(level2, undefined);
await context.dispatch(appendStep, 'level-1-after');
return sp.getState();
return getRequiredState(sp);
});
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 () => {
@@ -820,12 +828,12 @@ tap.test('circular context.dispatch should throw max depth error', async () => {
// Create a self-referencing action that will loop forever
const circularAction: smartstate.StateAction<{ count: number }, void> = part.createAction<void>(
async (sp, _payload, context) => {
const current = sp.getState();
const current = getRequiredState(sp);
if (current.count < 100) {
// This should eventually hit the depth limit
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);
expect(part.getState().value).toEqual(42);
expect(getRequiredState(part).value).toEqual(42);
});
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 increment = part.createAction<void>(async (sp) => {
const current = sp.getState();
const current = getRequiredState(sp);
return { count: current.count + 1 };
});
@@ -871,7 +879,7 @@ tap.test('concurrent dispatches still serialize correctly with context feature',
}
await Promise.all(promises);
expect(part.getState().count).toEqual(10);
expect(getRequiredState(part).count).toEqual(10);
});
// ── distinctUntilChanged on selectors ──────────────────────────────────
+3 -3
View File
@@ -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';
type TMyStateParts = 'testStatePart';
@@ -39,7 +39,7 @@ tap.test('should select something', async () => {
tap.test('should dispatch a state action', async (tools) => {
const done = tools.defer();
const addFavourite = testStatePart.createAction<string>(async (statePart, payload) => {
const currentState = statePart.getState();
const currentState = statePart.getState()!;
return {
...currentState,
currentFavorites: [...currentState.currentFavorites, payload],
@@ -53,7 +53,7 @@ tap.test('should dispatch a state action', async (tools) => {
done.resolve();
});
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;
});
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
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.'
}
+1 -1
View File
@@ -10,7 +10,7 @@ export interface IActionContext<TStateType> {
}
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>;
}
/**
+1
View File
@@ -5,6 +5,7 @@
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noImplicitAny": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"types": ["node"]