From 63785844f5e4d5fa0233f9515d5be3a15321de88 Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 11:29:51 -0700 Subject: [PATCH 01/13] Add proven async-action catch ESLint rule Agent-Id: agent-2ffe0b0c-df0e-4a02-acab-3e9fe3e382aa --- eslint-plugins/README.md | 32 ++++++- eslint-plugins/index.mjs | 1 + eslint-plugins/plugins/index.mjs | 4 + .../fixtures/invalid.ts | 21 +++++ .../fixtures/valid.ts | 49 ++++++++++ .../redundant-async-action-catch/plugin.mjs | 93 +++++++++++++++++++ package.json | 4 + scripts/architecture-validation.test.mjs | 24 +++++ scripts/package-validation.test.mjs | 28 +++++- scripts/validate-architecture.mjs | 1 + scripts/validate-release.mjs | 2 + 11 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 eslint-plugins/store/redundant-async-action-catch/fixtures/invalid.ts create mode 100644 eslint-plugins/store/redundant-async-action-catch/fixtures/valid.ts create mode 100644 eslint-plugins/store/redundant-async-action-catch/plugin.mjs diff --git a/eslint-plugins/README.md b/eslint-plugins/README.md index ddecfa5..0bcdd38 100644 --- a/eslint-plugins/README.md +++ b/eslint-plugins/README.md @@ -46,10 +46,10 @@ Each root composes the lower layers (core, then store, then its domain rules); r | Root | Composition | Enabled rules | Use for | | --- | --- | --- | --- | | `core` | core only | 4 | Any JS/TS package (package/source hygiene only) | -| `store` | core + store | 40 | Packages that define state/sagas but no UI | -| `svelte` | core + store + svelte | 43 | Svelte consumer projects | -| `react` | core + store + react | 44 | React consumer projects | -| `streaming` | core + store | 40 | Node/server/worker consumers; gains streaming rules when they exist | +| `store` | core + store | 42 | Packages that define state/sagas but no UI | +| `svelte` | core + store + svelte | 45 | Svelte consumer projects | +| `react` | core + store + react | 46 | React consumer projects | +| `streaming` | core + store | 42 | Node/server/worker consumers; gains streaming rules when they exist | `plugins` is not a raw ESLint plugin object. It is a named map of per-rule flat-config entries for selected composition. @@ -166,6 +166,30 @@ createAction("todos/addTodo"); Remediate by creating action creators in the slice owner module. `createAsyncAction` is intentionally not covered by this placement gate until the architecture convention explicitly requires the same treatment. +### `themis/redundant-async-action-catch` + +Themis already observes ignored async-action rejections. A defensive catch on the original action promise is redundant, and explicit awaiters still receive the original rejection. + +Invalid: + +```ts +import { createAsyncAction } from "@augmentcode/themis/utils/store/create-action"; +const loadTodos = createAsyncAction("todos/load", "todos/loadStage"); +const action = loadTodos(); +action.promise.catch(() => undefined); +``` + +Valid: + +```ts +store.dispatch(loadTodos()); // Fire and forget; no defensive catch needed. +const result = await store.dispatch(loadTodos()); // Use try/catch for recovery. +``` + +The rule reports any catch callback on a proven original action promise, not just no-op callbacks. It follows runtime named/namespace imports from the public `create-action` subpath, local factory/creator/action/promise aliases, and direct creator invocations. Static string member names, optional chains, and TypeScript assertions are supported. Shadowed or reassigned bindings, type-only imports, unproven imported creators, arbitrary promise-bearing objects, and unrelated factories are not treated as Themis async actions. It does not resolve other modules or infer origins from structural types, and does not report catches on derived promises (for example, `action.promise.then(transform).catch(recover)`). + +This store-domain rule is enabled in the `store`, `svelte`, `react`, and `streaming` configs and architecture validation. No automatic fix is offered because removing a recovery callback can change application behavior; migrate meaningful recovery to `try/catch` around awaited dispatch. + ### `themis/direct-local-storage-usage` Invalid: diff --git a/eslint-plugins/index.mjs b/eslint-plugins/index.mjs index e98d531..752db6b 100644 --- a/eslint-plugins/index.mjs +++ b/eslint-plugins/index.mjs @@ -27,6 +27,7 @@ const svelteStoreIgnores = [ ]; const ruleDefinitions = { + "redundant-async-action-catch": { files: sourceFiles }, "duplicate-action-type": { files: sourceFiles }, "duplicate-selector-export": { files: sourceFiles }, "duplicate-selector-implementation": { files: sourceFiles }, diff --git a/eslint-plugins/plugins/index.mjs b/eslint-plugins/plugins/index.mjs index a2b9a0c..4141021 100644 --- a/eslint-plugins/plugins/index.mjs +++ b/eslint-plugins/plugins/index.mjs @@ -27,6 +27,7 @@ import { plugin as reactComponentLifecycleBoundaryPlugin } from "../react/react- import { plugin as reactForbiddenComponentImportPlugin } from "../react/react-forbidden-component-import/plugin.mjs"; import { plugin as reactPreferDirectSelectorPlugin } from "../react/react-prefer-direct-selector/plugin.mjs"; import { plugin as reducerSideEffectPlugin } from "../store/reducer-side-effect/plugin.mjs"; +import { plugin as redundantAsyncActionCatchPlugin } from "../store/redundant-async-action-catch/plugin.mjs"; import { plugin as removedMiddlewareSourcePlugin } from "../core/removed-middleware-source/plugin.mjs"; import { plugin as sagaLocalSelectorPlugin } from "../store/saga-local-selector/plugin.mjs"; import { plugin as sagaWatcherActionTypePlugin } from "../store/saga-watcher-action-type/plugin.mjs"; @@ -77,6 +78,7 @@ export { reactForbiddenComponentImportPlugin, reactPreferDirectSelectorPlugin, reducerSideEffectPlugin, + redundantAsyncActionCatchPlugin, removedMiddlewareSourcePlugin, sagaLocalSelectorPlugin, sagaWatcherActionTypePlugin, @@ -147,6 +149,7 @@ export const stateCollectionReducerRulePlugins = { }; export const sagaSelectorChannelRulePlugins = { + "redundant-async-action-catch": redundantAsyncActionCatchPlugin, "saga-watcher-action-type": sagaWatcherActionTypePlugin, "inline-saga-selector": inlineSagaSelectorPlugin, "saga-local-selector": sagaLocalSelectorPlugin, @@ -174,6 +177,7 @@ export const coreRulePlugins = { }; export const storeRulePlugins = { + "redundant-async-action-catch": redundantAsyncActionCatchPlugin, "duplicate-action-type": duplicateActionTypePlugin, "duplicate-selector-export": duplicateSelectorExportPlugin, "duplicate-selector-implementation": duplicateSelectorImplementationPlugin, diff --git a/eslint-plugins/store/redundant-async-action-catch/fixtures/invalid.ts b/eslint-plugins/store/redundant-async-action-catch/fixtures/invalid.ts new file mode 100644 index 0000000..45e8985 --- /dev/null +++ b/eslint-plugins/store/redundant-async-action-catch/fixtures/invalid.ts @@ -0,0 +1,21 @@ +import { createAsyncAction as createRequest } from "@augmentcode/themis/utils/store/create-action"; +import * as actions from "@augmentcode/themis/utils/store/create-action"; + +const loadTodos = createRequest("todos/load", "todos/loadStage"); +loadTodos().promise.catch(() => undefined); +const action = loadTodos(); +action.promise.catch(() => {}); +action.promise.catch(reportError); +const creatorAlias = loadTodos; +const actionAlias = creatorAlias(); +const promiseAlias = actionAlias.promise; +promiseAlias.catch(() => undefined); +createRequest("todos/loadMore", "todos/loadMoreStage")().promise.catch(() => undefined); +const factoryAlias = actions.createAsyncAction; +const refresh = factoryAlias("todos/refresh", "todos/refreshStage"); +refresh()["promise"]["catch"](() => undefined); +(action as unknown as AsyncAction).promise!.catch(() => undefined); +action?.promise?.catch?.(() => undefined); +actions["createAsyncAction"]("todos/clear", "todos/clearStage")()[`promise`][`catch`](() => undefined); +let stableAction = loadTodos(); +stableAction.promise.catch(() => undefined); \ No newline at end of file diff --git a/eslint-plugins/store/redundant-async-action-catch/fixtures/valid.ts b/eslint-plugins/store/redundant-async-action-catch/fixtures/valid.ts new file mode 100644 index 0000000..1e56689 --- /dev/null +++ b/eslint-plugins/store/redundant-async-action-catch/fixtures/valid.ts @@ -0,0 +1,49 @@ +import { createAsyncAction, createAction } from "@augmentcode/themis/utils/store/create-action"; +import { createAsyncAction as unrelatedFactory } from "other-library"; +import { loadExternal } from "./external-actions"; +import type { createAsyncAction as typeOnlyFactory } from "@augmentcode/themis/utils/store/create-action"; +import { type createAsyncAction as inlineTypeFactory } from "@augmentcode/themis/utils/store/create-action"; + +const loadTodos = createAsyncAction("todos/load", "todos/loadStage"); +const action = loadTodos(); +store.dispatch(action); +await store.dispatch(loadTodos()); +await action.promise; +action.promise.then(transform).catch(reportError); +Promise.resolve().catch(reportError); +fetch("/todos").catch(reportError); +const object = { promise: Promise.resolve() }; +object.promise.catch(reportError); +unrelatedFactory("load")().promise.catch(reportError); +loadExternal().promise.catch(reportError); +typeOnlyFactory("load")().promise.catch(reportError); +inlineTypeFactory("load")().promise.catch(reportError); +createAction("todos/ordinary")().promise.catch(reportError); + +function shadowFactory(createAsyncAction) { + const load = createAsyncAction("todos/load", "todos/stage"); + load().promise.catch(reportError); +} +function shadowCreator(loadTodos) { + loadTodos().promise.catch(reportError); +} +function shadowAction(action) { + action.promise.catch(reportError); +} +const promise = action.promise; +function shadowPromise(promise) { + promise.catch(reportError); +} +let replacedAction = loadTodos(); +replacedAction = object; +replacedAction.promise.catch(reportError); +let replacedCreator = loadTodos; +replacedCreator = unrelatedFactory("load"); +replacedCreator().promise.catch(reportError); +const changedPromise = loadTodos(); +changedPromise.promise = Promise.resolve(); +changedPromise.promise.catch(reportError); +const promiseKey = "unrelated"; +action[promiseKey].catch(reportError); +const catchKey = "then"; +action.promise[catchKey](transform); \ No newline at end of file diff --git a/eslint-plugins/store/redundant-async-action-catch/plugin.mjs b/eslint-plugins/store/redundant-async-action-catch/plugin.mjs new file mode 100644 index 0000000..159b469 --- /dev/null +++ b/eslint-plugins/store/redundant-async-action-catch/plugin.mjs @@ -0,0 +1,93 @@ +import { staticPropertyName, staticString, unwrapExpression } from "../../ast-utils.mjs"; +import { createArchitectureRule, createArchitectureRulePlugin } from "../../rule-utils.mjs"; + +export const ruleId = "redundant-async-action-catch"; + +const actionModule = "@augmentcode/themis/utils/store/create-action"; + +function propertyName(node) { + return node?.type === "MemberExpression" + ? node.computed ? staticString(node.property) : staticPropertyName(node.property) + : undefined; +} + +function isMemberWrite(identifier) { + let node = identifier; + while (node.parent && ( + (node.parent.type === "MemberExpression" && node.parent.object === node) || + unwrapExpression(node.parent) === node + )) node = node.parent; + return node !== identifier && ( + (node.parent?.type === "AssignmentExpression" && node.parent.left === node) || + node.parent?.type === "UpdateExpression" || + (node.parent?.type === "UnaryExpression" && node.parent.operator === "delete") + ); +} + +function createProvenanceTracker(sourceCode) { + function variableFor(node) { + for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) { + const variable = scope.set?.get(node.name); + if (variable) return variable; + } + return undefined; + } + + function isFromActionModule(node, kind, seen = new Set()) { + const current = unwrapExpression(node); + if (!current) return false; + + if (current.type === "Identifier") { + const variable = variableFor(current); + if (!variable || seen.has(variable) || variable.defs.length !== 1) return false; + // Do not infer provenance from a stale initializer or a replaced promise. + if (variable.references.some((reference) => + (reference.isWrite() && !reference.init) || isMemberWrite(reference.identifier) + )) return false; + const nextSeen = new Set(seen).add(variable); + const definition = variable.defs[0]; + const declaration = definition.node; + const parent = declaration.parent ?? definition.parent; + if (parent?.type === "ImportDeclaration") { + if (staticString(parent.source) !== actionModule) return false; + if ([parent.importKind, declaration.importKind].some((value) => value === "type" || value === "typeof")) return false; + return kind === "namespace" + ? declaration.type === "ImportNamespaceSpecifier" + : kind === "factory" && declaration.type === "ImportSpecifier" && staticPropertyName(declaration.imported) === "createAsyncAction"; + } + return declaration.type === "VariableDeclarator" && declaration.id.type === "Identifier" && + isFromActionModule(declaration.init, kind, nextSeen); + } + + if (current.type === "CallExpression") { + if (kind === "action") return isFromActionModule(current.callee, "creator", seen); + if (kind === "creator") return isFromActionModule(current.callee, "factory", seen); + } + if (current.type === "MemberExpression") { + if (kind === "promise" && propertyName(current) === "promise") return isFromActionModule(current.object, "action", seen); + if (kind === "factory" && propertyName(current) === "createAsyncAction") return isFromActionModule(current.object, "namespace", seen); + } + return false; + } + + return (node) => isFromActionModule(node, "promise"); +} + +export const rule = createArchitectureRule({ + ruleId, + summary: "Redundant async-action promise catch: Themis already observes ignored rejections; await store.dispatch(action) when you need the outcome.", + why: "Themis observes ignored async-action rejections without changing the promise or hiding failures from explicit awaiters.", + fix: "Remove the action.promise.catch(...) call. When the outcome matters, await store.dispatch(action) and handle failures with try/catch.", + create(_context, { sourceCode, report }) { + const isAsyncActionPromise = createProvenanceTracker(sourceCode); + return { + CallExpression(node) { + const callee = unwrapExpression(node.callee); + if (propertyName(callee) === "catch" && isAsyncActionPromise(callee.object)) report({ node }); + }, + }; + }, +}); + +export const plugin = createArchitectureRulePlugin(ruleId, rule); +export default plugin; \ No newline at end of file diff --git a/package.json b/package.json index e61fe15..df01ae5 100644 --- a/package.json +++ b/package.json @@ -249,6 +249,10 @@ "import": "./eslint-plugins/store/reducer-side-effect/plugin.mjs", "default": "./eslint-plugins/store/reducer-side-effect/plugin.mjs" }, + "./eslint-plugins/plugins/redundant-async-action-catch": { + "import": "./eslint-plugins/store/redundant-async-action-catch/plugin.mjs", + "default": "./eslint-plugins/store/redundant-async-action-catch/plugin.mjs" + }, "./eslint-plugins/plugins/removed-middleware-source": { "import": "./eslint-plugins/core/removed-middleware-source/plugin.mjs", "default": "./eslint-plugins/core/removed-middleware-source/plugin.mjs" diff --git a/scripts/architecture-validation.test.mjs b/scripts/architecture-validation.test.mjs index a92e831..1e64236 100644 --- a/scripts/architecture-validation.test.mjs +++ b/scripts/architecture-validation.test.mjs @@ -55,6 +55,30 @@ describe("architecture validation gate", () => { expect(result.files).toHaveLength(3); }); + it("checks redundant async-action catches only for proven Themis promises", async () => { + const ruleId = architectureRules.redundantAsyncActionCatch; + const [valid, invalid] = await Promise.all([readRuleFixture(ruleId, "valid"), readRuleFixture(ruleId, "invalid")]); + const root = await createFixture({ "src/valid-slice.ts": valid, "src/invalid-slice.ts": invalid }); + const result = await validateArchitecture({ root, paths: ["src"] }); + const diagnostics = result.diagnostics.filter((diagnostic) => diagnostic.rule === ruleId); + expect(diagnostics).toHaveLength(10); + expect(diagnostics.every(({ file }) => file === "src/invalid-slice.ts")).toBe(true); + expect(diagnostics.every(({ message }) => message.includes("await store.dispatch(action)"))).toBe(true); + expect(result.diagnostics.some(({ rule }) => rule === "parse-error")).toBe(false); + }); + + it("supports reviewed async-action catch suppressions", async () => { + const root = await createFixture({ + "src/todos-slice.ts": ` + import { createAsyncAction } from "@augmentcode/themis/utils/store/create-action"; + const load = createAsyncAction("todos/load", "todos/stage"); + // eslint-disable-next-line architecture/redundant-async-action-catch -- migration recovery path + load().promise.catch(reportError); + `, + }); + expect((await validateArchitecture({ root, paths: ["src"] })).diagnostics).toEqual([]); + }); + it("createAction placement accepts slice owners and reports non-slice modules", async () => { const root = await createFixture({ "src/todos-slice.ts": ` diff --git a/scripts/package-validation.test.mjs b/scripts/package-validation.test.mjs index 350aa24..4939c9a 100644 --- a/scripts/package-validation.test.mjs +++ b/scripts/package-validation.test.mjs @@ -279,7 +279,7 @@ describe("package metadata", () => { react: [...storeRootRuleIds, ...architectureRuleDomains.react], streaming: storeRootRuleIds, }; - const expectedRootRuleCounts = { core: 4, store: 41, svelte: 44, react: 45, streaming: 41 }; + const expectedRootRuleCounts = { core: 4, store: 42, svelte: 45, react: 46, streaming: 42 }; expect(Object.keys(architectureRootModule).sort()).toEqual(["core", "plugins", "react", "store", "streaming", "svelte"]); expect(architectureRootModule.full).toBeUndefined(); @@ -1094,6 +1094,32 @@ describe("package metadata", () => { expect(selectedRuleIdsFromConfig(streaming)).toContain("selector-argument-stability"); }); + it.each([ + ["Babel", architectureValidationLanguageOptions], + ["TypeScript ESLint", { parser: typescriptEslintParser }], + ])("proves async-action catch provenance without reporting unrelated promises with %s", async (_name, languageOptions) => { + const ruleId = "redundant-async-action-catch"; + const [valid, invalid] = await Promise.all(["valid", "invalid"].map((name) => + readFile(new URL(`../eslint-plugins/store/${ruleId}/fixtures/${name}.ts`, import.meta.url), "utf8") + )); + const plugin = architectureRulePlugins[ruleId]; + expect(lintArchitectureRule(ruleId, plugin, valid, "src/todos-slice.ts", languageOptions)).toEqual([]); + const messages = lintArchitectureRule(ruleId, plugin, invalid, "src/todos-slice.ts", languageOptions); + expect(messages.map(({ line }) => line)).toEqual([5, 7, 8, 12, 13, 16, 17, 18, 19, 21]); + for (const message of messages) { + expect(message.ruleId).toBe(namespacedRuleId(ruleId)); + expect(message.message).toContain("Themis already observes ignored rejections"); + expect(message.message).toContain("await store.dispatch(action)"); + expect(message.fix).toBeUndefined(); + } + for (const root of [store, svelte, react, streaming]) expect(selectedRuleIdsFromConfig(root)).toContain(ruleId); + expect(selectedRuleIdsFromConfig(core)).not.toContain(ruleId); + const standalone = await import("@augmentcode/themis/eslint-plugins/plugins/redundant-async-action-catch"); + const collection = await import("@augmentcode/themis/eslint-plugins/plugins"); + expect(standalone.default).toBe(plugin); + expect(collection.redundantAsyncActionCatchPlugin).toBe(plugin); + }); + it("keeps custom ESLint rule messages concise while preserving detailed metadata", () => { const rule = architectureRulePlugins["test-selector-select"].rules["test-selector-select"]; const messages = lintArchitectureRule( diff --git a/scripts/validate-architecture.mjs b/scripts/validate-architecture.mjs index af9b494..4b69fc2 100644 --- a/scripts/validate-architecture.mjs +++ b/scripts/validate-architecture.mjs @@ -26,6 +26,7 @@ function architectureErrorRules(rulePlugins) { } export const architectureRules = { + redundantAsyncActionCatch: "redundant-async-action-catch", duplicateActionType: "duplicate-action-type", duplicateSelectorExport: "duplicate-selector-export", duplicateSelectorImplementation: "duplicate-selector-implementation", diff --git a/scripts/validate-release.mjs b/scripts/validate-release.mjs index 35707f1..6ce8ece 100644 --- a/scripts/validate-release.mjs +++ b/scripts/validate-release.mjs @@ -52,6 +52,7 @@ export const standaloneArchitectureRuleIds = [ "source-shaped-package-import", "state-type-name", "store-constructor-saga-map", + "redundant-async-action-catch", "suspicious-state-field", "test-selector-select", "typed-saga-call-mock-guard", @@ -68,6 +69,7 @@ export const architectureRuleDomains = { "removed-middleware-source", ], store: [ + "redundant-async-action-catch", "duplicate-action-type", "duplicate-selector-export", "duplicate-selector-implementation", From 12016a3a231038cd1b269307466c7805e9309906 Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 11:30:01 -0700 Subject: [PATCH 02/13] Return async action promises from store dispatch Agent-Id: agent-f28d77d3-3e2a-40fc-b37c-8f9d0b8422c5 --- src/store-runtime.ts | 25 ++++++- src/store-state.test.ts | 31 +++++++- src/store.test.ts | 104 +++++++++++++++++++++++++- src/types.ts | 5 ++ src/utils/store/create-action.test.ts | 23 +++++- src/utils/store/create-action.ts | 2 + 6 files changed, 180 insertions(+), 10 deletions(-) diff --git a/src/store-runtime.ts b/src/store-runtime.ts index d078a19..918cd49 100644 --- a/src/store-runtime.ts +++ b/src/store-runtime.ts @@ -2,6 +2,7 @@ import { applyMiddleware, combineReducers, legacy_createStore as createStore, + type UnknownAction, } from 'redux'; import Kefir, { type Emitter, @@ -16,6 +17,8 @@ import { type NormalizedStoreOptions, type ReducersMap, type StoreOptions, + type StoreDispatch, + type StoreAsyncAction, type StoreMiddleware, type StoreReducerFunction, type StoreState, @@ -68,6 +71,16 @@ import type { const MAX_SELECTOR_SOURCE_SNIPPET_LINES = 5; const MAX_SELECTOR_SOURCE_SNIPPET_LENGTH = 500; +const isStoreAsyncAction = (action: UnknownAction): action is StoreAsyncAction => ( + typeof action.asyncActionType === 'string' && + typeof action.success === 'function' && + typeof action.failure === 'function' && + typeof action.promise === 'object' && + action.promise !== null && + 'then' in action.promise && + typeof action.promise.then === 'function' +); + type StateDiff = Record; class ChangesPayload { @@ -306,6 +319,7 @@ export abstract class StoreRuntime< private readonly reduxLoggerMiddleware: StoreMiddleware | undefined; private tasksStarted: Task[] = []; private storeContext: ReduxStoreContext | undefined; + private storeDispatch: StoreDispatch | undefined; private selectorCadenceSource: SelectorCadenceSource | undefined; private cadencedStoreStateStream: | RuntimeStoreStateStream> @@ -606,13 +620,13 @@ export abstract class StoreRuntime< return this.storeContext.store.getState() as StoreBoundState; } - get dispatch(): ReduxStoreContext['store']['dispatch'] { - if (!this.storeContext) { + get dispatch(): StoreDispatch { + if (!this.storeDispatch) { throw new Error( 'Cannot access Store.dispatch before Store.init() has been called.' ); } - return this.storeContext.store.dispatch; + return this.storeDispatch; } /** @@ -642,6 +656,10 @@ export abstract class StoreRuntime< store, }; this.storeContext = storeContext; + this.storeDispatch = ((action: UnknownAction) => { + const result = store.dispatch(action); + return isStoreAsyncAction(action) ? action.promise : result; + }) as StoreDispatch; this.cadencedStoreStateStream = createCadencedStoreStateStream>( store, this.getOrCreateSelectorCadenceSource() @@ -921,6 +939,7 @@ export abstract class StoreRuntime< this.disposeCadencedStoreStateStream(); this.stopSagas(); this.storeContext = undefined; + this.storeDispatch = undefined; this.disposeSelectorCadenceSource(); } diff --git a/src/store-state.test.ts b/src/store-state.test.ts index 963fde2..c25da73 100644 --- a/src/store-state.test.ts +++ b/src/store-state.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import type { Store as ReduxStore, UnknownAction } from 'redux'; import type { Observable as KefirObservable } from 'kefir'; import type { ReadonlySignal } from '@preact/signals-react'; import { createReducer } from '@augmentcode/themis/utils/store/create-reducer'; -import type { StoreInstanceState, StoreOptions, StoreState } from '@augmentcode/themis/types'; +import { createAction, createAsyncAction } from '@augmentcode/themis/utils/store/create-action'; +import type { StoreDispatch, StoreInstanceState, StoreOptions, StoreState } from '@augmentcode/themis/types'; import { Store } from './svelte-store'; import { StreamingStore } from './streaming-store'; import { ReactStore } from './react-store'; @@ -75,8 +75,33 @@ type _ReactStoreStateMatchesStore = Assert>; type StoreWithCounterDispatchGetter = typeof storeWithCounter.dispatch; -type _StoreDispatchGetterMatchesReduxDispatch = Assert['dispatch']>>; +type _StoreDispatchGetterMatchesStoreDispatch = Assert>; if (false) { + const loadCounter = createAsyncAction<[number], CounterState>('counter/loadAsync', 'counter/load'); + const request = loadCounter(1); + const result = storeWithCounter.dispatch(request); + const streamingResult = streamingStoreWithCounter.dispatch(request); + const reactResult = reactStoreWithCounter.dispatch(request); + type _DispatchReturnsTypedPromise = Assert>>; + type _AwaitedDispatchReturnsResponse = Assert, CounterState>>; + type _StreamingDispatchReturnsTypedPromise = Assert>>; + type _ReactDispatchReturnsTypedPromise = Assert>>; + const setCounter = createAction<[number]>('counter/set'); + const ordinaryAction = setCounter(1); + const ordinaryResult = storeWithCounter.dispatch(ordinaryAction); + type _OrdinaryDispatchReturnsAction = Assert>; + const plainAction = { type: 'counter/plain', value: 1 }; + const plainResult = storeWithCounter.dispatch(plainAction); + type _PlainDispatchReturnsAction = Assert>; + const unrelatedAction = { type: 'counter/unrelated', promise: Promise.resolve(1) }; + const unrelatedResult = storeWithCounter.dispatch(unrelatedAction); + type _UnrelatedPromiseActionReturnsAction = Assert>; + // @ts-expect-error async dispatch returns the response promise, not the action. + const wrongAction: typeof request = result; + // @ts-expect-error async dispatch preserves the response type. + const wrongResponse: Promise = result; + // @ts-expect-error dispatch still requires an action type. + storeWithCounter.dispatch({ payload: 1 }); storeWithCounter.runSaga(counterSaga); // @ts-expect-error runSaga accepts a saga function, not a saga name string. storeWithCounter.runSaga('counterSaga'); diff --git a/src/store.test.ts b/src/store.test.ts index d0a4f25..c916797 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -3,6 +3,9 @@ import { setContext } from 'svelte'; import { writable } from 'svelte/store'; import createSagaMiddleware from 'redux-saga'; import { Store } from './svelte-store'; +import { ReactStore } from './react-store'; +import { StreamingStore } from './streaming-store'; +import { createAsyncAction } from './utils/store/create-action'; import { getStoreContext } from './utils/runtime-svelte/utils'; import { registerGlobalDevTools } from './global-dev-tools'; import { @@ -14,7 +17,7 @@ import { storeUtilityReducer } from './slices/store-utility/store-utility-slice' import { sagaManager } from './slices/saga-manager/sagas/manager'; import { deriveSagaName } from './utils/sagas/derive-saga-name'; import { DEFAULT_THROTTLED_SELECTOR_FREQUENCY } from './store-options'; -import type { ReducersMap } from './types'; +import type { ReducersMap, StoreMiddleware } from './types'; vi.mock('svelte', () => ({ setContext: vi.fn(), @@ -822,10 +825,105 @@ describe('Store', () => { }); describe('dispatch', () => { - it('returns the initialized Redux store dispatch function', () => { + it('returns a stable dispatch function that preserves ordinary actions', () => { store.init(); + const { dispatch } = store; + const action = { type: 'TEST', payload: 1 }; - expect(store.dispatch({ type: 'TEST' })).toEqual({ type: 'TEST' }); + expect(store.dispatch).toBe(dispatch); + expect(dispatch(action)).toBe(action); + }); + + it.each([Store, ReactStore, StreamingStore])( + 'returns the original async promise after dispatch through %s middleware and reducers', + async (StoreClass) => { + const load = createAsyncAction('test/loadAsync', 'test/load'); + const action = load(); + const actions: unknown[] = []; + const nextResults: unknown[] = []; + const middleware: StoreMiddleware = () => (next) => (received) => { + actions.push(received); + nextResults.push(next(received)); + return 'middleware result'; + }; + const reducer = vi.fn((state = {}) => state); + const mappedStore = new StoreClass({ test: reducer }, middleware); + const dispose = mappedStore.init(); + reducer.mockClear(); + + try { + const result = mappedStore.dispatch(action); + + expect(actions).toEqual([action]); + expect(nextResults).toEqual([action]); + expect(reducer).toHaveBeenCalledExactlyOnceWith({}, action); + expect(result).toBe(action.promise); + mappedStore.dispatch(action.success('loaded')); + await expect(result).resolves.toBe('loaded'); + } finally { + dispose(); + } + } + ); + + it.each([Store, ReactStore, StreamingStore])( + 'preserves the original rejection when awaiting dispatch through %s', + async (StoreClass) => { + const load = createAsyncAction('test/loadAsync', 'test/load'); + const action = load(); + const error = new Error('load failed'); + const mappedStore = new StoreClass(); + const dispose = mappedStore.init(); + + try { + const result = mappedStore.dispatch(action); + mappedStore.dispatch(action.failure(error)); + + expect(result).toBe(action.promise); + await expect(result).rejects.toBe(error); + } finally { + dispose(); + } + } + ); + + it('preserves middleware return values for ordinary actions', () => { + const middlewareResult = { handled: true }; + store.addMiddleware(() => (next) => (action) => { + next(action); + return middlewareResult; + }); + const dispose = store.init(); + + try { + expect(store.dispatch({ type: 'TEST' })).toBe(middlewareResult); + } finally { + dispose(); + } + }); + + it('does not unwrap unrelated promise-bearing actions', () => { + const action = { type: 'TEST', promise: Promise.resolve('ordinary') }; + const dispose = store.init(); + + try { + expect(store.dispatch(action)).toBe(action); + } finally { + dispose(); + } + }); + + it('preserves synchronous middleware errors for async actions', () => { + const load = createAsyncAction('test/loadAsync', 'test/load'); + const error = new Error('dispatch failed'); + store.addMiddleware(() => () => () => { throw error; }); + const dispose = store.init(); + + try { + expect(() => store.dispatch(load())).toThrow(error); + } finally { + dispose(); + } }); it('throws if init() has not been called', () => { diff --git a/src/types.ts b/src/types.ts index abb94e7..074487e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,11 @@ export type StoreAsyncAction = { failure: StoreActionCreator<[Error], ErrorResponse>; }; +export type StoreDispatch = { + (action: StoreAsyncAction): Promise; + (action: T): T; +}; + export type StoreAsyncActionCreator = { (...args: ARGS): StoreAsyncAction; type: string; diff --git a/src/utils/store/create-action.test.ts b/src/utils/store/create-action.test.ts index d4e7b58..341c4ff 100644 --- a/src/utils/store/create-action.test.ts +++ b/src/utils/store/create-action.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import process from "node:process"; +import { describe, expect, it, vi } from "vitest"; import { createAction, createAsyncAction } from "./create-action"; describe("createAction", () => { @@ -72,6 +73,26 @@ describe("createAsyncAction", () => { await rejection; }); + it("observes ignored rejections without changing the original promise", async () => { + const loadUser = createAsyncAction("user/loadAsync", "user/load"); + const request = loadUser(); + const promise = request.promise; + const error = new Error("ignored failure"); + const onUnhandledRejection = vi.fn(); + process.on("unhandledRejection", onUnhandledRejection); + + try { + request.failure(error); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onUnhandledRejection).not.toHaveBeenCalled(); + expect(request.promise).toBe(promise); + await expect(promise).rejects.toBe(error); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + it("exposes static success and failure action creators", () => { const loadUser = createAsyncAction<[id: string], { id: string }, { name: string }>( "user/loadAsync", diff --git a/src/utils/store/create-action.ts b/src/utils/store/create-action.ts index 3c311fe..8519dce 100644 --- a/src/utils/store/create-action.ts +++ b/src/utils/store/create-action.ts @@ -102,6 +102,8 @@ export function createAsyncAction undefined); if (payloadModifier) { payload = (payloadModifier as any)(...args); From 6d5862be5f93cd17b0169faa3d2a772a2ed62258 Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 11:34:33 -0700 Subject: [PATCH 03/13] docs: clarify async action rejection handling Agent-Id: agent-11afba21-3d10-4bfa-aac0-c4d55951e808 --- skills/core/actions/SKILL.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/skills/core/actions/SKILL.md b/skills/core/actions/SKILL.md index f150a83..800b7fe 100644 --- a/skills/core/actions/SKILL.md +++ b/skills/core/actions/SKILL.md @@ -43,6 +43,8 @@ triggers: - `createAsyncAction<[Args], Success>(asyncType, stagesType)` creates the request creator plus static `.success` and `.failure` creators. - A dispatched request action carries `payload`, `promise`, and per-instance `success`/`failure` creators. +- Themis internally observes ignored async-action rejections, preventing unhandled-rejection events without changing the original promise; explicit awaiters still receive the original rejection. +- Prefer `try/catch` around `await store.dispatch(asyncAction(...))` when handling a result or failure; dispatch returns the request's original, typed promise. - Reducers normally handle the request creator, `.success`, and `.failure` to update loading/data/error fields. - Sagas watch the request creator unless they are intentionally reacting to success/failure events. @@ -103,6 +105,19 @@ const success = request.success({ id: "todo-1", title: "Ship docs" }); success.payload.request.id satisfies string; ``` +### Await dispatch when the caller needs the result + +Using `loadTodo` above and an initialized Themis `store` whose saga settles the request: + +```ts +try { + const todo = await store.dispatch(loadTodo("todo-1")); + todo satisfies Todo; +} catch (error) { + console.error("Unable to load todo", error); +} +``` + ### Watch request creators directly in sagas ```ts @@ -142,6 +157,7 @@ const [{ id, title }] = renameTodoFromList({ id: "todo-1", title: "Ship docs" }) - Do not pass `.type` to saga watchers; pass the creator. - Do not use object payload types for one-argument actions unless an existing public contract already requires that shape. - Do not place generated timestamps or IDs in reducers; generate them before dispatch. +- Do not attach `action.promise.catch(...)` to Themis async actions, including defensive `action.promise.catch(() => undefined)`: it is redundant and reported by `redundant-async-action-catch` when the promise is statically proven to come from a Themis async action. Use the dispatch-await pattern above for caller-owned error handling. ## Verification cues From 970cebc75631a7fc39fa660b1e51130d8912d8c5 Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 13:09:21 -0700 Subject: [PATCH 04/13] Simplify trusted async action dispatch guard Agent-Id: agent-5704ae01-a07b-4226-bb49-ba4cd3ca7a4b --- src/store-runtime.ts | 16 +++++++--------- src/store.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/store-runtime.ts b/src/store-runtime.ts index 918cd49..ac776c0 100644 --- a/src/store-runtime.ts +++ b/src/store-runtime.ts @@ -71,15 +71,13 @@ import type { const MAX_SELECTOR_SOURCE_SNIPPET_LINES = 5; const MAX_SELECTOR_SOURCE_SNIPPET_LENGTH = 500; -const isStoreAsyncAction = (action: UnknownAction): action is StoreAsyncAction => ( - typeof action.asyncActionType === 'string' && - typeof action.success === 'function' && - typeof action.failure === 'function' && - typeof action.promise === 'object' && - action.promise !== null && - 'then' in action.promise && - typeof action.promise.then === 'function' -); +const isStoreAsyncAction = (action: UnknownAction): action is StoreAsyncAction => { + if (!('promise' in action)) { + return false; + } + + return typeof action.asyncActionType === 'string'; +}; type StateDiff = Record; diff --git a/src/store.test.ts b/src/store.test.ts index c916797..dd29439 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -913,6 +913,49 @@ describe('Store', () => { } }); + it('does not inspect the async marker when no promise field exists', () => { + const readAsyncActionType = vi.fn(() => 'test/loadAsync'); + const action = { type: 'TEST', get asyncActionType() { return readAsyncActionType(); } }; + const dispose = store.init(); + + try { + expect(store.dispatch(action)).toBe(action); + expect(readAsyncActionType).not.toHaveBeenCalled(); + } finally { + dispose(); + } + }); + + it.each([ + { label: 'a promise without action callbacks', promise: Promise.resolve('loaded') }, + { label: 'an object without Promise methods', promise: {} }, + { label: 'a null promise field', promise: null }, + { label: 'an undefined promise field', promise: undefined }, + ])('trusts a string async marker with $label', ({ promise }) => { + const action = { type: 'TEST', asyncActionType: 'test/loadAsync', promise }; + const dispose = store.init(); + + try { + expect(store.dispatch(action)).toBe(promise); + } finally { + dispose(); + } + }); + + it.each([undefined, null, 1, {}])( + 'does not unwrap promise-bearing actions with a non-string async marker %s', + (asyncActionType) => { + const action = { type: 'TEST', asyncActionType, promise: Promise.resolve('ordinary') }; + const dispose = store.init(); + + try { + expect(store.dispatch(action)).toBe(action); + } finally { + dispose(); + } + } + ); + it('preserves synchronous middleware errors for async actions', () => { const load = createAsyncAction('test/loadAsync', 'test/load'); const error = new Error('dispatch failed'); From 393d4cbf4206163aa13f0d98a0d6508f41a72d1c Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 13:24:44 -0700 Subject: [PATCH 05/13] docs: align async action promise and error handling guidance Agent-Id: agent-8a7a3630-fa68-40f5-a0b4-85d0b922408e --- docs/REDUCERS.md | 21 ++++++++++++++++++++- docs/SAGAS.md | 21 ++++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/REDUCERS.md b/docs/REDUCERS.md index 20d18db..412aabe 100644 --- a/docs/REDUCERS.md +++ b/docs/REDUCERS.md @@ -82,10 +82,29 @@ An async action creator returns: - `type` — The action type string - `asyncActionType` — The async operation type - `payload` — The request payload -- `promise` — A promise that resolves with the response +- `promise` — The original promise, resolved with the response by `action.success(response)` or rejected with the original error by `action.failure(error)` - `success` — Action creator for the success case - `failure` — Action creator for the failure case +### Awaiting Results and Handling Failures + +Themis internally observes ignored async-action rejections, so fire-and-forget requests do not produce unhandled-rejection events. This does not replace the original promise or swallow errors for explicit awaiters: awaiting `action.promise` or the result of `store.dispatch(action)` still receives the original rejection. + +When the caller needs the result or must recover from failure, prefer `try/catch` around `await store.dispatch(asyncAction(...))`. Dispatch on an initialized Themis `Store`, `ReactStore`, or `StreamingStore` returns the request's original, typed promise. Using `fetchItems` above with a running saga that settles the request: + +```typescript +try { + const response = await store.dispatch(fetchItems("active")); + console.log(response.items, response.total); +} catch (error) { + console.error("Unable to fetch items", error); +} +``` + +If the caller does not need the result, use `store.dispatch(fetchItems("active"))` without a defensive catch. Attaching `action.promise.catch(...)`, including `action.promise.catch(() => undefined)`, is redundant; the `themis/redundant-async-action-catch` ESLint rule reports catch calls when the promise is statically proven to come from a Themis async action. Move meaningful recovery into the dispatch-await `try/catch` pattern instead. + +Sagas should settle each request with its per-instance `action.success(...)` or `action.failure(...)` creators; reducers still handle the creator's static `.success` and `.failure` stages as shown below. See [Async Action Error Flow](./SAGAS.md#async-action-error-flow). + ### Handling Async Actions in Reducers ```typescript diff --git a/docs/SAGAS.md b/docs/SAGAS.md index 22b6c52..f79494e 100644 --- a/docs/SAGAS.md +++ b/docs/SAGAS.md @@ -427,16 +427,16 @@ Wrap individual workers in try/catch: function* handleFetchItems(action: ReturnType) { try { const items = yield* call(api.fetchItems, action.payload[0]); - yield* put(fetchItems.success({ items })); + yield* put(action.success({ items })); } catch (error) { - yield* put(fetchItems.failure(error instanceof Error ? error : new Error(String(error)))); + yield* put(action.failure(error instanceof Error ? error : new Error(String(error)))); } } ``` ### Async Action Error Flow -With `createAsyncAction`, use `.success` and `.failure` sub-actions: +With `createAsyncAction`, use the request's per-instance `action.success(...)` and `action.failure(...)` sub-actions. They settle that request's original promise with the response or original error, and dispatching the sub-action updates reducers. The creator's static `fetchItems.success(...)` and `fetchItems.failure(...)` produce lifecycle actions but do not settle a particular request's promise; use those static creators as reducer patterns, not to complete a request in its worker. ```typescript export function* mySaga() { @@ -451,6 +451,21 @@ export function* mySaga() { } ``` +Themis internally observes ignored async-action rejections, so fire-and-forget dispatch does not need `action.promise.catch(...)` to prevent unhandled-rejection events. This does not swallow errors for explicit awaiters: `await action.promise` and `await store.dispatch(action)` still receive the original rejection. The `themis/redundant-async-action-catch` ESLint rule reports catch calls on original action promises when their Themis provenance is statically known, including defensive `action.promise.catch(() => undefined)` calls. + +Outside the saga, callers that need the result or recovery should use `try/catch` around `await store.dispatch(asyncAction(...))`. With an initialized Themis store and the request watcher running: + +```typescript +try { + const result = await store.dispatch(fetchItems("active")); + console.log(result); +} catch (error) { + console.error("Unable to fetch items", error); +} +``` + +Dispatch returns the request's original, typed promise. If the caller does not need the result, `store.dispatch(fetchItems("active"))` is sufficient; the saga's success/failure flow remains unchanged. See [Awaiting Results and Handling Failures](./REDUCERS.md#awaiting-results-and-handling-failures). + ### Channel Cleanup Always close channels in `finally` blocks: From 4a5bc1b1440561bcdf8bbd5592300b0fe692e2ed Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 14:16:15 -0700 Subject: [PATCH 06/13] docs(skills): consolidate core ownership and lifecycle references Agent-Id: agent-211a0a93-dece-45d4-8720-f90ef4649955 --- skills/core/SKILL.md | 42 +++++++-------- skills/core/core-policy/SKILL.md | 40 +++++++------- skills/core/file-structure/SKILL.md | 32 ++++++----- skills/core/redux-action-logging/SKILL.md | 66 +++++++++++++++++------ skills/core/redux-saga/SKILL.md | 16 +++++- skills/core/saga-manager/SKILL.md | 21 +++++--- skills/core/sagas/SKILL.md | 37 ++++++++----- skills/core/selector-tracing/SKILL.md | 44 +++++---------- 8 files changed, 172 insertions(+), 126 deletions(-) diff --git a/skills/core/SKILL.md b/skills/core/SKILL.md index 8b4f4c0..1a60a39 100644 --- a/skills/core/SKILL.md +++ b/skills/core/SKILL.md @@ -13,18 +13,6 @@ triggers: - redux core - core redux - shared state - - canonical state - - createAction - - createReducer - - typed-redux-saga - - saga manager - - saga channels - - store pruning - - redux action logging - - logReduxActions - - state serialization - - redux testing - - verifier --- # Core Redux and saga routing @@ -32,6 +20,13 @@ Use this skill for framework-independent Redux/redux-saga work in the `themis` package. It routes to the core skills that are not owned by Store-family-specific taxonomy waves. +## Routing ownership + +This index owns core routing, not leaf operational contracts. Match specialized +requests to the relative `SKILL.md` paths in **Core leaf routes** below; each +leaf owns its implementation rules. Loading this index through a leaf's +`requires: core` provides preflight context, not a competing implementation owner. + > This package uses a CUSTOM Redux setup — not Redux Toolkit (RTK). Do not use > `createSlice`, `configureStore`, `createAsyncThunk`, or any RTK API. @@ -64,27 +59,28 @@ by Store-family-specific taxonomy waves. | Route | Use when | | --- | --- | | `./core-policy/SKILL.md` | Redux ownership, side-effect boundaries, serializability, and utility reuse rules. | -| `./state-integrity/SKILL.md` | Preventing derived/duplicated Redux state and duplicate action/selector/saga ownership. | -| `./store-pruning/SKILL.md` | Explicit-only pruning of unused Redux selectors, actions, handlers, sagas, and orphaned store logic when the user asks for pruning. | +| `./state-integrity/SKILL.md` — **Preflight search protocol** | Preventing derived/duplicated Redux state and duplicate action/selector/saga ownership. | +| `./store-pruning/SKILL.md` — **Agent preflight** | Explicit-only pruning of unused Redux selectors, actions, handlers, sagas, and orphaned store logic when the user asks for pruning. | | `./import-boundaries/SKILL.md` | Public package imports, saga import boundaries, and Store-first public subpackages. | | `./file-structure/SKILL.md` | Slice file layout, type modules, sagas, and Store registration patterns. | -| `./state-serialization/SKILL.md` | Structured-clone-safe Redux state values. | -| `./actions/SKILL.md` | `createAction` and `createAsyncAction` action creators. | -| `./reducers/SKILL.md` | Immutable chained reducers and no-op reference equality behavior. | -| `./sagas/SKILL.md` | typed-redux-saga flows, watchers, debounce, retry/timeout, and side-effect orchestration. | -| `./saga-manager/SKILL.md` | Package-owned saga crash tracking, lifecycle, restart, and backoff mechanics. | +| `./state-serialization/SKILL.md` — **Do** and **Don't** | Structured-clone-safe Redux state values. | +| `./actions/SKILL.md` — **Do** and **Async action cues** | `createAction` and `createAsyncAction` action creators. | +| `./reducers/SKILL.md` — **Do** and **Implementation cues** | `createReducer`, immutable chained reducers, and no-op reference equality behavior. | +| `./sagas/SKILL.md` — **Do** and **Implementation cues** | typed-redux-saga flows, watchers, debounce, retry/timeout, and side-effect orchestration. | +| `./saga-manager/SKILL.md` — **Store saga lifecycle** and **Start, stop, restart, and backoff mechanics** | `store.runSaga`, per-owner cancellation, whole-Store disposal, and package-owned crash/restart behavior. | | `./channel-effects/SKILL.md` | Generic EventChannel consumers for IPC, websocket, or DOM channels. | | `./selector-channels/SKILL.md` | Saga reactions to selector value changes. | | `./wait-for/SKILL.md` | One-shot saga waits for selector predicates. | | `./local-storage/SKILL.md` | Safe app-local localStorage persistence from sagas. | -| `./redux-action-logging/SKILL.md` | Construction-time `logReduxActions` diagnostics and grouped action/state diffs across Store families. | +| `./redux-action-logging/SKILL.md` — **Store-owned logging streams** and **Logger factory lifecycle** | Construction-time `logReduxActions`, action/state diffs, and shared stream/logger ownership across Store families. | +| `./selector-tracing/SKILL.md` | Selector performance, tracing configuration, privacy-safe interval aggregates, and lifetime summaries. | | `./collections/SKILL.md` | Normalized `Collection` entity state. | | `./domain-scoped-state/SKILL.md` | State keyed by workspace, project, tenant, or domain id. | | `./boolean-preference/SKILL.md` | Boolean set/toggle preference helper registration. | -| `./testing/SKILL.md` | Reducer/saga testing, typed-redux-saga mocks, and reference equality assertions. | +| `./testing/SKILL.md` — **Layer rules** and **Verification cues** | Reducer/saga testing, typed-redux-saga mocks, and reference equality assertions. | | `./debugging/SKILL.md` | Runtime inspection and reducer reference-equality diagnostics. | -| `./verifier/SKILL.md` | Review quality gates for instruction drift, duplicate owners, and evidence. | -| `./redux-saga/SKILL.md` | Generic upstream redux-saga API reference. | +| `./verifier/SKILL.md` — **Required gate sequence** | Review quality gates for instruction drift, duplicate owners, and evidence. | +| `./redux-saga/SKILL.md` — **Package guidance takes precedence** | Generic upstream API details only; use `./sagas/SKILL.md` for Themis typed effect rules and `./saga-manager/SKILL.md` for Store-owned lifecycle. | ## Related non-core routes diff --git a/skills/core/core-policy/SKILL.md b/skills/core/core-policy/SKILL.md index 3602cfb..474f901 100644 --- a/skills/core/core-policy/SKILL.md +++ b/skills/core/core-policy/SKILL.md @@ -72,7 +72,7 @@ Before adding any helper, wrapper, or shared utility: Before adding Redux state, actions, selectors, or sagas, load `core/state-integrity` and search for existing canonical owners. The handoff must list the searched paths/terms and say whether the change reused, extended, or created the canonical owner. -### When to use Redux vs component-local state (monolith §8) +### When to use Redux vs component-local state **Use Redux when:** @@ -134,7 +134,7 @@ type TodosState = { items: Todo[]; itemsById: Record; activeCount: ```typescript // CORRECT — canonical collection; selector derives the count -import { store } from "$lib/store/store"; +import { store } from "../store"; type TodosState = { items: Collection }; export const selectActiveCount = store.createSelector((state) => @@ -150,7 +150,7 @@ State becomes invisible to Redux state inspection and unreachable from sagas or ```typescript // feature-local-store.ts — new family-local shared store file (WRONG) -let items = $state([]); +let items: Item[] = []; export const itemsStore = { get items() { return items; }, add(i) { items = [...items, i]; } @@ -166,28 +166,28 @@ export const featureReducer = createReducer(initialState) })); ``` -Source: `../SKILL.md` §1, `@augmentcode/themis/README.md` · **Priority: CRITICAL** +Source: [When to use Redux vs component-local state](./SKILL.md#when-to-use-redux-vs-component-local-state), `@augmentcode/themis/README.md` · **Priority: CRITICAL** -### ❌ Using `$effect` for cross-component side effects +### ❌ Using component effects for cross-component side effects -`$effect` runs only while the component is mounted; business logic dies with the component and cannot be inspected or tested like a saga. +Component-owned effects are the wrong owner for shared/domain work that must +outlive that component. Keep DOM-local effects in the component; dispatch shared +intent to the canonical saga owner instead. -```typescript -// WRONG — effect dies on unmount, invisible to devtools/tests -$effect(() => { - fetch(`/api/items/${$id$}`).then(r => r.json()).then(setItems); -}); -``` +- **Wrong:** fetch shared items from a component-owned reactive effect and keep + the result in a parallel local store. +- **Correct:** dispatch the existing load action and let its canonical saga + update Redux. For watcher/worker implementation, follow + [Do](../sagas/SKILL.md#do) and [Implementation cues](../sagas/SKILL.md#implementation-cues). + For selector-triggered work instead of action-triggered work, follow + [Choose the helper](../selector-channels/SKILL.md#choose-the-helper). -```typescript -// CORRECT — saga survives component lifetime and is observable -yield* takeLatest(selectItemId, function* ({ payload }) { - const res = yield* call(fetch, `/api/items/${payload}`); - yield* put(itemsLoaded(yield* call([res, 'json']))); -}); -``` +A saga does not inherently outlive a component. Choose its lifetime owner using +[Application saga startup](../sagas/SKILL.md#application-saga-startup) and follow +[Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle) for cancellation; +the selected Store family supplies component/runtime lifecycle wiring. -Source: `../SKILL.md` §1, §8 · **Priority: HIGH** +Source: [When to use Redux vs component-local state](./SKILL.md#when-to-use-redux-vs-component-local-state) · **Priority: HIGH** ### ❌ Defining slice types inline in `-slice.ts` diff --git a/skills/core/file-structure/SKILL.md b/skills/core/file-structure/SKILL.md index 98787e7..a8d5517 100644 --- a/skills/core/file-structure/SKILL.md +++ b/skills/core/file-structure/SKILL.md @@ -3,10 +3,9 @@ name: core/file-structure description: >- Per-slice directory layout ({name}-types.ts, {name}-slice.ts, {name}-selectors.ts, sagas/{name}-saga.ts plus tests). Saga-only slices skip - reducer registration. Register reducers in the Store constructor map; store.init() - wires the Redux store and package saga manager but does NOT auto-start - app sagas — start each one explicitly via store.runSaga(sagaFn). - Do not manually register package @internal_ sagas. + reducer registration. Owns app reducer registration and explicit saga startup + placement; delegates Store init/run/cancel/dispose mechanics to + ../saga-manager/SKILL.md (Store saga lifecycle). Ownership: exactly one {name}-slice.ts and one {name}-selectors.ts module per slice directory; split multiple slices into separate directories. Naming: {Feature}State, {feature}Reducer, camelCase slice identity keys/namespaces, @@ -22,7 +21,7 @@ triggers: --- # File Structure & Registration -> Source: @augmentcode/themis/docs/ARCHITECTURE.md → Slice File Structure + Store Initialization; ../SKILL.md §9. +> Source: @augmentcode/themis/docs/ARCHITECTURE.md → Slice File Structure + Store Initialization; see [Core leaf routes](../SKILL.md#core-leaf-routes) for related owners. ## Setup — slice directory layout @@ -56,11 +55,15 @@ export const store = new Store({ mySlice: mySliceReducer }); export type AppState = StoreState; ``` -Use `StoreState` for app state typing after constructing the store with app reducer maps. Constructor reducer maps preserve reducer-state inference without an explicit `: Store` annotation. Register only app-owned reducers. `Store` manages package-owned internals automatically under reserved `@internal_` names: internal reducers such as `@internal_storeUtility` are always package-managed, and the internal saga manager starts during `Store` initialization. Do not add app reducers/sagas with that prefix or couple selectors/tests to the internal state shape. +Use `StoreState` for app state typing after constructing the store with app reducer maps. Constructor reducer maps preserve reducer-state inference without an explicit `: Store` annotation. Register only app-owned reducers. `Store` manages internal reducers such as `@internal_storeUtility` automatically under reserved `@internal_` names; do not use that prefix for app-owned registrations or couple selectors/tests to the internal state shape. For the package-owned saga manager boundary, follow [Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle). Then in the application's selected Store family root lifecycle, initialize the store and start each app saga explicitly by function. Use the selected Store family skill for component/runtime lifecycle details; core owns the file layout, saga registration, and reducer ownership rules. -`store.init()` combines the registered reducers, creates the Redux store with middleware, lets the concrete Store variant create its selector state resources, and starts the package saga manager. It does **not** start app sagas — start each one with `store.runSaga(sagaFn)` from the family-appropriate root lifecycle, or imperatively and keep the returned cancel function. It derives the manager name from the saga function and rejects direct `@internal_sagaManager` usage. Register the `store.init()` disposer with the selected Store family lifecycle cleanup; that disposer delegates to `store.dispose()`, which tears down the initialized Store runtime and stops Store-owned saga tasks when the whole Store lifetime ends. +For initialization order, `store.runSaga(sagaFn)`, manager naming, matching cancels, +and Store-wide teardown, follow [Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle). +When deciding between root, component/layout, service, or test ownership, follow +[Application saga startup](../sagas/SKILL.md#application-saga-startup); keep the +explicit startup calls next to that owner's lifecycle wiring, not in a constructor saga map. ### Saga-only slice (no state, no reducer) @@ -117,7 +120,7 @@ export const store = new Store({ triggers: noopReducer }); export const store = new Store({}); ``` -Source: `../SKILL.md` §9 (Saga-only slices) · **Priority: MEDIUM** +Source: [Saga-only slice (no state, no reducer)](./SKILL.md#saga-only-slice-no-state-no-reducer) · **Priority: MEDIUM** ### ❌ Adding multiple slice or selectors owner files to one directory @@ -139,7 +142,7 @@ src/slices/theme/theme-slice.ts src/slices/theme/theme-selectors.ts ``` -Source: `../SKILL.md` §9 (File structure) · **Priority: HIGH** +Source: `./SKILL.md` — **Setup — slice directory layout** · **Priority: HIGH** ### ❌ Using kebab-case or snake_case as the logical slice identity @@ -157,7 +160,7 @@ export const updateTheme = createAction("userPreferences/updateTheme"); export const store = new Store({ userPreferences: userPreferencesReducer }); ``` -Source: `../SKILL.md` §9 (Naming) · **Priority: HIGH** +Source: [Naming conventions](./SKILL.md#naming-conventions) · **Priority: HIGH** ### ❌ Naming selectors without the `select` prefix @@ -173,7 +176,7 @@ export const isLoading = store.createSelector(...); export const selectIsLoading = store.createSelector(...); ``` -Source: `../SKILL.md` §9 (Naming) · **Priority: MEDIUM** +Source: [Naming conventions](./SKILL.md#naming-conventions) · **Priority: MEDIUM** ### ❌ Defining state types inline in `{slice-name}-slice.ts` @@ -193,10 +196,11 @@ export type FeatureState = { items: Collection }; import type { FeatureState } from './feature-types'; ``` -Source: `../SKILL.md` §1, §9 · **Priority: MEDIUM** +Source: `../core-policy/SKILL.md` — **Types live in `{slice-name}-types.ts`**; `./SKILL.md` — **Setup — slice directory layout** · **Priority: MEDIUM** ## See also -- `core/core-policy/SKILL.md` — why types live in `-types.ts` -- `core/actions/SKILL.md` — action naming and namespacing +- `../core-policy/SKILL.md` — **Types live in `{slice-name}-types.ts`** +- `../actions/SKILL.md` — action naming and namespacing +- [Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle) — init/run/cancel/dispose mechanics - Selected Store family skill — Store initialization wiring \ No newline at end of file diff --git a/skills/core/redux-action-logging/SKILL.md b/skills/core/redux-action-logging/SKILL.md index 7b98696..5dc0e2a 100644 --- a/skills/core/redux-action-logging/SKILL.md +++ b/skills/core/redux-action-logging/SKILL.md @@ -4,7 +4,7 @@ description: >- Opt-in Redux action logging for Store, ReactStore, and StreamingStore. Covers the construction-time logReduxActions option, grouped console records, presentation styles, immutable `reduxAction` stream events, unchanged-state - output, and lazy path-keyed changes. + output, lazy path-keyed changes, and shared traceStreams/loggerFactory lifecycle. type: sub-skill requires: - core @@ -13,6 +13,8 @@ triggers: - logReduxActions - redux dispatch logging - action state diff + - Store loggerFactory + - Store traceStreams --- # Redux action logging @@ -40,12 +42,25 @@ Use the constructor corresponding to the app's Store family; do not combine Svelte, React, and Streaming lifecycle patterns in one app. The option is shared by all three families and is disabled when omitted or set to `false`. +## Store-owned logging streams + The Store exposes six read-only Kefir streams through `traceStreams`: `selectorDetail`, `selectorSummary`, `selectorCadence`, `sagaMonitor`, -`runtimeError`, and `reduxAction`. `reduxAction` is produced by pure middleware: +`runtimeError`, and `reduxAction`. The collection exposes no emitters and does not +permit consumers to publish events. Public `StoreTraceStreams` and +`StoreLoggerFactory` types are available from `@augmentcode/themis/types` and +re-exported by each Store-family entrypoint. + +When `logReduxActions: true`, `reduxAction` is produced by pure middleware: it calls `next(action)` before publishing one shallow-immutable event with the action, previous/next state references, and `stateChanged`. Errors and return values from `next` are preserved, and failed dispatches do not publish an event. +The event does not eagerly compute a state diff; default rendering computes it +lazily as described in **Read one action's group** below. Action/state payloads +may contain application data; redact secrets and sensitive values before sharing. + +Selector metadata has a separate privacy contract; follow +[Scope and safety rules](../selector-tracing/SKILL.md#1-scope-and-safety-rules). ## 2. Read one action's group @@ -85,21 +100,17 @@ changes from the group title alone. ## 3. Keep logging opt-in and temporary There is no dev-mode switch, localStorage toggle, global debug-console toggle, -or runtime enable/disable API for this logger. The pure logger middleware and -the default StoreRuntime rendering are installed only when the normalized -constructor option is `true`; omitted and `false` options do not publish action -events or attach the default logger. +or runtime enable/disable API for action logging. The pure logger middleware and +default action rendering are enabled only when the normalized constructor option +is `true`; omitted and `false` options do not publish action events or enable +action console groups. Other diagnostic streams have independent options. -Pass a typed `loggerFactory` to replace default console rendering. It receives -the same six read-only streams and may return a disposer; it does not also -attach the built-in legend or Redux console groups. The factory is attached at -initialization, disposed with the Store, and reattached on a later successful -initialization. +For default/custom rendering, factory attachment, and cleanup, follow +[Logger factory lifecycle](./SKILL.md#logger-factory-lifecycle). -Selector aggregation is independent of action logging: `summaryEnabled: true` -is the sole switch that allocates and periodically publishes selector summaries. -Detailed selector categories may be enabled without allocating a summary -collector, and action events never change that behavior. +Selector aggregation is independent of action logging. For `summaryEnabled` +and collector allocation/publication, follow +[Aggregate summaries](../selector-tracing/SKILL.md#4-aggregate-summaries). To disable logging, omit the option or set `logReduxActions: false` **and construct a new Store instance**. Changing an options object, calling `init()` @@ -109,6 +120,31 @@ middleware pipeline. After reproducing the issue, dispose the diagnostic Store through its normal family lifecycle and remove the temporary `true` option from application code. +## Logger factory lifecycle + +With no `loggerFactory`, StoreRuntime attaches its default console logger, +preserving severity and `[themis]` diagnostic prefixes; enabled action logging +uses the legend/groups described in **Read one action's group** above. + +Pass a typed `loggerFactory` to replace default console rendering. It receives +only this Store instance's six read-only streams and may return one disposer; +it does not also attach the built-in legend or default console output. + +```ts +import type { StoreLoggerFactory } from '@augmentcode/themis/types'; + +const loggerFactory: StoreLoggerFactory = (streams) => { + const subscription = streams.runtimeError.observe(reportRuntimeError); + return () => subscription.unsubscribe(); +}; +``` + +Pass the factory in the third Store constructor options object. The factory +attaches during initialization; its disposer runs during `store.dispose()` and +before a later successful initialization attaches it again. Dispose custom stream +subscriptions in that callback and retain the Store initializer's disposer for +the end of the owning Store lifetime. + ## 4. Common mistakes - Do not look for a localStorage key or development-mode gate; neither controls diff --git a/skills/core/redux-saga/SKILL.md b/skills/core/redux-saga/SKILL.md index c63fa11..588ece2 100644 --- a/skills/core/redux-saga/SKILL.md +++ b/skills/core/redux-saga/SKILL.md @@ -5,7 +5,10 @@ description: >- middleware.run, runSaga, Effect creators, effect combinators, watcher helpers, channel support, buffers, Task/Channel/Buffer/SagaMonitor interfaces, cancellation, context, blocking vs non-blocking semantics, and testing helpers. -type: core +type: sub-skill +requires: + - core + - core/sagas sources: - https://redux-saga.js.org/docs/api triggers: @@ -22,7 +25,16 @@ triggers: > Source: official redux-saga API Reference, https://redux-saga.js.org/docs/api, retrieved 2026-05-15. -Use this skill for generic redux-saga API behavior. When editing this repository's `themis` code, also follow the package-specific core saga skills in sibling `../*` skill folders, especially the `typed-redux-saga` `yield*` conventions and canonical watcher ownership rules. +## Package guidance takes precedence + +Use this leaf only for generic upstream redux-saga API behavior; it is not a +second core router. Read [Preflight](../SKILL.md#preflight), then follow +[Do](../sagas/SKILL.md#do) and [Implementation cues](../sagas/SKILL.md#implementation-cues) +for Themis `typed-redux-saga` / `yield*`, watcher ownership, and effect choices. +For the configured Store's `store.runSaga` API rather than upstream `runSaga`, +follow [Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle). +Upstream examples below do not authorize replacing Store-owned middleware or +overriding those package-specific rules. ## Agent Preflight Compliance Contract diff --git a/skills/core/saga-manager/SKILL.md b/skills/core/saga-manager/SKILL.md index da8e2a3..9b6cf80 100644 --- a/skills/core/saga-manager/SKILL.md +++ b/skills/core/saga-manager/SKILL.md @@ -11,7 +11,6 @@ type: sub-skill library: themis requires: - core - - core/sagas - core/import-boundaries - core/state-serialization sources: @@ -41,13 +40,19 @@ Use this skill when an agent must explain, verify, or minimally adjust saga mana - **SHOULD** run targeted saga-manager tests when behavior claims change. - **NEVER** document `addCrash`, `clearCrashes`, reducer state paths, or `@internal_sagaManager` as public app APIs unless a separate public export task approves it. -## Setup — where the manager fits +## Store saga lifecycle -- `Store.init()` starts the package-owned saga manager internally. -- App sagas are started explicitly with `store.runSaga(sagaFn)`; Store derives a manager name from the saga function. +This section owns the shared Store saga lifecycle contract. For where app startup +belongs, follow [Application saga startup](../sagas/SKILL.md#application-saga-startup); +the selected Store family supplies framework-specific wiring. + +- Initialize the Store before starting app sagas. `Store.init()` wires the Redux store and middleware, creates the selected Store variant's selector resources, and starts the package-owned saga manager internally; it does not auto-start app sagas. +- App sagas are started explicitly with `store.runSaga(sagaFn)` after initialization; Store derives a manager name from the saga function. - `store.runSaga(sagaFn)` dispatches `startSaga(name, sagaFn)` and returns a cancel function that dispatches `stopSaga(name)`; the manager listens for those lifecycle actions. +- Retain each returned cancel function and invoke it when that lifetime owner ends. Shared-task reference counting is defined in [Start, stop, restart, and backoff mechanics](./SKILL.md#start-stop-restart-and-backoff-mechanics). - `Store.dispose()` and the disposer returned by `Store.init()` tear down the initialized Store runtime and stop Store-owned saga tasks, including running app sagas forked by the manager. -- The reserved manager name is `@internal_sagaManager`; do not register, run, or expose it as an app saga. +- Whole-Store teardown belongs only to the owner ending the entire Store context; it is not a substitute for an individual saga owner's cancel function. +- The reserved manager name is `@internal_sagaManager`; do not register, run, or expose it as an app saga. App code must not import package-internal actions such as `addCrash` or `clearCrashes`. ## Core Patterns @@ -69,11 +74,11 @@ Use this skill when an agent must explain, verify, or minimally adjust saga mana - Clearing one saga does not clear reports for other saga names. - Treat `clearCrashes` as package-internal until a public export/API is intentionally added. -### 4. Start, stop, restart, and backoff mechanics +### Start, stop, restart, and backoff mechanics - Multiple overlapping `store.runSaga(sagaFn)` calls for the same derived saga name and function share one running task and increment a reference counter. - The saga stops only after every returned cancel function has been invoked. -- Full Store disposal is a separate lifecycle boundary: use `store.dispose()` only when ending the whole Store context, not as a replacement for normal per-mount `store.runSaga(sagaFn)` cancels. +- Full Store disposal is a separate lifecycle boundary: follow [Store saga lifecycle](./SKILL.md#store-saga-lifecycle), not disposal as a replacement for per-owner cancels. - If the managed saga throws an unhandled error, `autoRestart` records the crash, logs it, waits, and restarts the saga automatically. - `getBackOffDelay(restarts)` is `min(1000 * 2^restarts, 10 minutes)`: first restart waits 1s, then 2s, 4s, and so on up to the cap. - Restart pressure decays after stable runtime: before incrementing, the manager subtracts one restart count per full minute since the last start, bounded at zero. @@ -166,6 +171,6 @@ function closeDetailsPanelSafely(cancelSyncTodos: () => void) { ## See also - `@augmentcode/themis/docs/SAGAS.md#saga-manager` — canonical human-facing explanation. -- `core/sagas` — general typed-redux-saga implementation rules. +- [Do](../sagas/SKILL.md#do) and [Application saga startup](../sagas/SKILL.md#application-saga-startup) — typed-redux-saga implementation rules and framework-neutral startup ownership. - `core/import-boundaries` — public package exports and forbidden deep imports. - `core/testing` — saga/reducer verification patterns. \ No newline at end of file diff --git a/skills/core/sagas/SKILL.md b/skills/core/sagas/SKILL.md index fdf40a7..3367af7 100644 --- a/skills/core/sagas/SKILL.md +++ b/skills/core/sagas/SKILL.md @@ -2,8 +2,8 @@ name: core/sagas description: >- Concise agent rules for typed-redux-saga work in this package. Use for saga - watchers/workers, canonical saga ownership, Store saga registration/startup, - Store-first saga startup, cancellation-friendly debounce, retryWithTimeout, + watchers/workers, canonical saga ownership, application saga startup placement, + cancellation-friendly debounce, retryWithTimeout, wrapStreamingGenerator, and routing to saga-manager crash/restart guidance. For conceptual API explanations and examples, link to @augmentcode/themis/docs/SAGAS.md instead of duplicating them. type: sub-skill @@ -13,13 +13,12 @@ requires: - core/state-integrity triggers: - takeEvery saga - - store.runSaga - - saga manager - - saga crash + - application saga startup - debounce saga - retryWithTimeout - wrapStreamingGenerator - typed redux saga + - typed-redux-saga --- # Sagas — agent implementation rules @@ -45,7 +44,7 @@ Use this skill when editing saga code or writing instructions for saga changes. - Import the named selectors from the owning slice's `[slice]-selectors.ts` file; saga modules must not declare local `select*` functions/factories, even when they are not exported. - Subscribe with concrete action creators, action-creator arrays, or selector-channel helpers; never use `take('*')` or other wildcard takes. - Search before adding watchers: trigger action, worker name, registration name, and operation terms must have one canonical owner unless fan-out is intentional and documented. -- Start app-owned sagas explicitly with `store.runSaga(sagaFn)` after `store.init()`. +- Choose an explicit owner for app saga startup; follow [Application saga startup](./SKILL.md#application-saga-startup) and the linked lifecycle contract. - Close manually-created channels in `finally`. - Handle async action failures with `.failure(error)` and normalize non-`Error` throws. - Keep retried work idempotent when using `retryWithTimeout`. @@ -59,9 +58,7 @@ Use this skill when editing saga code or writing instructions for saga changes. - Do not declare or factory-construct `select*` selectors inside saga modules, even as module-private locals; move them to the owning `[slice]-selectors.ts` and import them. - Do not subscribe to every action with `take('*')`, `takeEvery('*', ...)`, or other wildcard patterns; it wakes the saga on every dispatch and is especially harmful during streaming flows where chunk actions fire continuously. - Do not add a parallel watcher for an action already owned by another saga. -- Do not manually add or start `@internal_sagaManager`; it is package-owned and started by Store initialization. -- Do not import package-internal saga-manager files/actions such as `addCrash` or `clearCrashes` from app code; route crash-storage/restart questions to `core/saga-manager`. -- Do not assume `store.init()` auto-starts app sagas; start each app saga explicitly with `store.runSaga(sagaFn)`. +- Do not treat initialization or manager internals as app saga registration; follow [Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle) for startup and the public/internal boundary. - Do not leave channels, retries, or long-running loops without cancellation/error paths. - Do not introduce detached `spawn`; use attached `fork` so child work is cancelled when the parent fails or is cancelled. - Do not monkey-patch redux-saga globally or replace Store-owned saga middleware to observe effects; pass `{ sagaMonitor: true }` in Store options instead. @@ -74,9 +71,23 @@ Use this skill when editing saga code or writing instructions for saga changes. - Do not add new wrapper-action debounce flows or recommend `debounceSaga`/`debounceWithKeySaga` for new work; those exports remain for compatibility only. - For transient failures, use `retryWithTimeout` and branch on all outcomes: `success`, `retries-exhausted`, and `timeout`. - For async generators, use `wrapStreamingGenerator` from saga code and handle stream errors locally at the call site if app reporting is needed. -- For saga lifetimes, call `store.runSaga(sagaFn)` from `onMount` when component/layout lifetime owns the work, or from services/tests when imperative control owns the returned cancel function. Use `store.dispose()` only for whole-Store teardown; it stops running saga tasks owned by the initialized Store context. +- For saga lifetime placement in components, services, or tests, follow [Application saga startup](./SKILL.md#application-saga-startup); per-owner cancellation and whole-Store teardown belong to [Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle). - For saga monitoring, keep the normal constructor shape and pass `new Store(reducers, middleware, { throttledSelectorFrequency, sagaMonitor: true })` or the equivalent `ReactStore`/`StreamingStore` options object. -- For saga manager crash records, cleanup, serialized storage, auto-restart, or backoff behavior, use the dedicated `core/saga-manager` skill instead of expanding this general saga checklist. +- For saga manager crash records and cleanup, read [Core Patterns](../saga-manager/SKILL.md#core-patterns); for auto-restart and backoff, read [Start, stop, restart, and backoff mechanics](../saga-manager/SKILL.md#start-stop-restart-and-backoff-mechanics). + +## Application saga startup + +Choose the lifetime owner before wiring app sagas: app-wide work belongs to the +application root or service lifetime; component/layout work belongs to that +component/layout lifetime; tests own their setup and cleanup explicitly. Use the +selected Store family's lifecycle skill for its framework hook or runtime boundary, +not a hook prescribed by core. + +Place explicit app saga startup beside that owner's initialization/cleanup wiring. +Follow [Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle) for +initialization order, `store.runSaga(sagaFn)`, matching cancel functions, and the +whole-Store disposal boundary. For slice modules and registration placement, use +[Register a normal slice](../file-structure/SKILL.md#register-a-normal-slice). ## Examples @@ -234,7 +245,7 @@ function* watchReady() { - Passing `myAction.type` to watcher effects; pass `myAction`. - Adding a second watcher or Store registration for an existing trigger/name. - Forgetting `finally` for `channel.close()`. -- Treating saga-manager internals as app-owned sagas. +- Treating saga-manager internals as app-owned sagas; check [Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle). - Using `spawn` or wrapper-action debounce helpers for new saga work. - Declaring `select*` selectors locally inside a saga module instead of importing them from `[slice]-selectors.ts`. - Using `take('*')`, `takeEvery('*', ...)`, or similar wildcard patterns instead of concrete action creators or selector-channel helpers. @@ -242,7 +253,7 @@ function* watchReady() { ## See also - `@augmentcode/themis/docs/SAGAS.md` — full saga concepts, APIs, and examples. -- `core/saga-manager` — package-owned crash tracking, cleanup, serialized crash storage, `store.runSaga` lifecycle, restart, and backoff mechanics. +- [Store saga lifecycle](../saga-manager/SKILL.md#store-saga-lifecycle) and [Core Patterns](../saga-manager/SKILL.md#core-patterns) — canonical lifecycle, crash storage/cleanup, restart, and backoff mechanics. - `core/selector-channels` — selector change watchers and selector-backed channels. - `core/wait-for` — one-shot selector predicate waits. - `core/channel-effects` — generic `EventChannel` consumers. diff --git a/skills/core/selector-tracing/SKILL.md b/skills/core/selector-tracing/SKILL.md index ef603e8..18184e6 100644 --- a/skills/core/selector-tracing/SKILL.md +++ b/skills/core/selector-tracing/SKILL.md @@ -3,7 +3,7 @@ name: core/selector-tracing description: >- Diagnose Store-created selector performance from opt-in interval aggregates and privacy-safe lifetime summaries. Covers the flat traceSelectors contract, - execution, cache, invalidation, argument, result, cadence, and Redux action records, + execution, cache, invalidation, argument, result, and cadence records, bounded p95 interpretation, lifecycle, and production safety across all Store families. type: sub-skill @@ -86,37 +86,18 @@ finite and non-negative; category and `summaryEnabled` fields must be boolean. ## 2a. Store-owned logging streams -Every Store family exposes the same read-only `traceStreams` collection. The -public `StoreTraceStreams` and `StoreLoggerFactory` types are available from -`@augmentcode/themis/types` and re-exported by each Store-family entrypoint. -The collection contains six Kefir observables: `selectorDetail`, -`selectorSummary`, `selectorCadence`, `sagaMonitor`, `runtimeError`, and -`reduxAction`. It does not expose emitters or permit consumers to publish events. - -When `logReduxActions: true`, Store-owned Redux middleware produces one -`reduxAction` event only after `next(action)` succeeds. The event contains the -action plus previous/next state references and a `stateChanged` flag; it does not -eagerly compute a diff. StoreRuntime's default logger renders that stream with -the existing legend, grouped titles, action record, and lazy path-keyed state -diff. A `loggerFactory` replaces the default logger while still receiving all -six streams. Action and state payloads may contain application data; redact -secrets before sharing them. - -With no `loggerFactory`, StoreRuntime attaches the default console logger and -preserves the existing severity and `[themis]` prefixes. A custom factory -receives only this Store instance's streams and may return one disposer, so -custom logging does not duplicate default console output: +Selector diagnostics use `traceStreams.selectorDetail`, `selectorSummary`, and +`selectorCadence`; their events follow this skill's selector privacy contract. +For the full stream collection, public types, and the separate `reduxAction` +event enabled by `logReduxActions`, follow +[Store-owned logging streams](../redux-action-logging/SKILL.md#store-owned-logging-streams). +Action/state logging has different payload-safety rules from selector metadata. -```ts -const loggerFactory: StoreLoggerFactory = (streams) => { - const subscription = streams.runtimeError.observe(reportRuntimeError); - return () => subscription.unsubscribe(); -}; -``` - -The returned disposer runs during `store.dispose()` and before a successful -re-initialization attaches the factory again. Dispose stream subscriptions and -the Store initializer when the owning code path ends. +When replacing console output with `loggerFactory` or wiring stream subscription +cleanup, follow [Logger factory lifecycle](../redux-action-logging/SKILL.md#logger-factory-lifecycle). +That section owns default/custom logger behavior, the factory example, and +disposal/re-initialization; selector summary intervals remain covered below in +[Aggregate summaries](./SKILL.md#4-aggregate-summaries). The legacy `store.traceSelectors()` compatibility method can activate the same event preset in any build when construction used omitted or `false` tracing @@ -316,5 +297,6 @@ when the task also changed runtime code. reference. - `../debugging/SKILL.md` — Store lifecycle and runtime inspection boundaries. - `../testing/SKILL.md` — focused selector and Store verification guidance. +- [Store-owned logging streams](../redux-action-logging/SKILL.md#store-owned-logging-streams) and [Logger factory lifecycle](../redux-action-logging/SKILL.md#logger-factory-lifecycle) — action events and logger ownership, separate from selector trace metadata. - The selected Store-family selector skill — family-specific call modes; keep one concrete Store family per app/code path. \ No newline at end of file From a4edf0e4d5c4773d59a7b9d3932967e6b0225de7 Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 14:17:20 -0700 Subject: [PATCH 07/13] docs(skills): preserve frozen trace stream contract Agent-Id: agent-211a0a93-dece-45d4-8720-f90ef4649955 --- skills/core/redux-action-logging/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/core/redux-action-logging/SKILL.md b/skills/core/redux-action-logging/SKILL.md index 5dc0e2a..10552ca 100644 --- a/skills/core/redux-action-logging/SKILL.md +++ b/skills/core/redux-action-logging/SKILL.md @@ -44,7 +44,7 @@ shared by all three families and is disabled when omitted or set to `false`. ## Store-owned logging streams -The Store exposes six read-only Kefir streams through `traceStreams`: +The Store exposes a frozen `traceStreams` collection of six read-only Kefir streams: `selectorDetail`, `selectorSummary`, `selectorCadence`, `sagaMonitor`, `runtimeError`, and `reduxAction`. The collection exposes no emitters and does not permit consumers to publish events. Public `StoreTraceStreams` and From 564923328d4aff628837b4a9d504fbe9eef6866d Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 14:17:59 -0700 Subject: [PATCH 08/13] docs(skills): consolidate React ownership and canonical references Agent-Id: agent-0e6be072-16d8-439f-b85c-08c321ebbfff --- skills/react/SKILL.md | 32 ++- skills/react/component-integration/SKILL.md | 108 ++++------ skills/react/migration/SKILL.md | 67 +++---- skills/react/migration/assessment/SKILL.md | 35 ++-- .../migration/component-migration/SKILL.md | 71 ++----- .../react/migration/derived-stores/SKILL.md | 21 +- skills/react/migration/setup/SKILL.md | 119 +++-------- skills/react/migration/side-effects/SKILL.md | 31 +-- .../react/migration/writable-stores/SKILL.md | 71 +++---- skills/react/selector-lifecycle/SKILL.md | 36 ++-- skills/react/selector-scheduling/SKILL.md | 109 +++------- skills/react/selectors/SKILL.md | 189 ++++-------------- skills/react/signals/SKILL.md | 66 ++---- skills/react/store/SKILL.md | 14 +- 14 files changed, 326 insertions(+), 643 deletions(-) diff --git a/skills/react/SKILL.md b/skills/react/SKILL.md index 1b42205..58eb079 100644 --- a/skills/react/SKILL.md +++ b/skills/react/SKILL.md @@ -56,35 +56,31 @@ selector consumption. Generic Redux/redux-saga guidance remains in `../core/`. | Route | Use when | | --- | --- | | `./signals/SKILL.md` | General Preact Signals guidance for ReactStore apps: `ReadonlySignal`, `.value`, `computed`, Babel transform/`useSignals()` tracking, direct JSX signal rendering, component-local signal hooks, and avoiding module-level shared signal state. | -| `./store/SKILL.md` | Choosing/importing `ReactStore`, initialization/disposal, shared Store runtime behavior, or Store-family contrast. | -| `./selectors/SKILL.md` | Authoring selectors whose direct calls return cached `ReadonlySignal` outputs, preferring direct signals in React consumers, using `.useValue(...args)` only for hook/plain-value fallback paths, plus `.withStore`, `.select`, and saga-only `.effect`. | +| `./store/SKILL.md` | Public `ReactStore` import and runtime API summary; app bootstrap mechanics live in component-integration. | +| `./selectors/SKILL.md` | Authoring Store-bound selectors, pure composition, argument tracking/stability, and cached `ReadonlySignal` outputs; consumer call modes live in selector-lifecycle. | | `./component-integration/SKILL.md` | Wiring `ReactStore` into JSX/TSX React apps, bootstrap/root init and disposal ownership, app saga startup through `reactStore.runSaga(sagaFn)`, React component reads through direct signals first, and Store-first dispatch. | | `./selector-lifecycle/SKILL.md` | Choosing React selector call modes across component render/custom hooks, direct signal-aware code, handlers/callbacks/tests, sagas, selector composition, and explicit `.withStore(...)` binding. | | `./selector-scheduling/SKILL.md` | React `ReadonlySignal`/`.useValue(...args)` scheduling guidance: same source + selector + args output reuse, Store-owned selector coalescing, `throttledSelectorFrequency`, package-private scheduler boundaries, and no manual debounce/audit-log misuse. | | `./migration/SKILL.md` | React migration/adoption work from local React state/context/hooks/effects/external stores to `ReactStore`, selectors, actions, reducers, sagas, signal-first component consumption, and cleanup. | -First-time app setup starts at the canonical root setup skill: `../setup/SKILL.md`. +First-time installation and family choice start at +[Store-family decision gate](../setup/SKILL.md#store-family-decision-gate). +Once React is selected, [Create and configure ReactStore](./component-integration/SKILL.md#create-and-configure-reactstore) +owns runtime bootstrap. Adoption of existing state uses the migration +[Adoption checkpoint](./migration/setup/SKILL.md#adoption-checkpoint), not another bootstrap procedure. ## Routing rules - Use `ReactStore` only from `@augmentcode/themis/react-store`. - Create production app-local React selectors through the configured `ReactStore` instance: `reactStore.createSelector(...)`. -- Direct selector calls return `ReadonlySignal` values and are the preferred - React component/custom-hook integration path when consumers can accept signals. -- Components that read direct selector `.value` must rely on the Preact Signals - Babel transform or an explicit `useSignals()` runtime fallback; passing or - intentionally rendering signals in JSX is valid when the consumer is - signal-aware. -- Use `.useValue(...args)` only when a React hook/plain value is necessary and adapting - the consumer to accept a signal is impractical. -- Direct signal outputs and `.useValue(...args)` are throttled by - `throttledSelectorFrequency`. -- Direct `ReadonlySignal` outputs are cached for the same ReactStore instance + selector + args; do not add memoize/cache/debounce/throttle wrappers for selector performance. -- Selector trace output is a default-off diagnostic; pass - `{ traceSelectors: true }` only while diagnosing selector scheduling. -- `.effect(...args)` stays saga-only; it is not a hook or render subscription. -- `.select(state, ...args)` stays the pure selector path for tests/composition. +- Signal-first consumers and necessary plain-value fallbacks follow + [Call-mode map](./selector-lifecycle/SKILL.md#call-mode-map); tracking and + wrong-shape boundaries follow [React signal consumption guardrails](./selector-lifecycle/SKILL.md#react-signal-consumption-guardrails). +- Output reuse and avoiding extra memoization follow + [Selector caching](./selectors/SKILL.md#selector-caching). +- Store-owned cadence and temporary trace options follow + [Store-first scheduling rule](./selector-scheduling/SKILL.md#store-first-scheduling-rule). - React component, selector lifecycle, selector scheduling, and migration work must route to the React leaves above as operational guidance for this app. diff --git a/skills/react/component-integration/SKILL.md b/skills/react/component-integration/SKILL.md index 3193cc2..75d6a63 100644 --- a/skills/react/component-integration/SKILL.md +++ b/skills/react/component-integration/SKILL.md @@ -3,14 +3,14 @@ name: react/component-integration description: >- ReactStore component integration guidance for React app/root wiring. Covers where to create/configure ReactStore, init/dispose ownership, app saga startup - with reactStore.runSaga(sagaFn), direct selector signal reads in JSX/TSX, - Babel transform/useSignals tracking, selector .useValue(...args) fallbacks for hook/plain-value boundaries, and - Store-first dispatch and React lifecycle ownership. + with reactStore.runSaga(sagaFn), Store-first dispatch, and React lifecycle + ownership. Routes selector consumption to the selector-lifecycle owner. type: sub-skill requires: - react - react/store - react/selector-lifecycle + - core/core-policy sources: - "@augmentcode/themis/react-store" - ../signals/SKILL.md @@ -19,6 +19,8 @@ sources: triggers: - React component integration - ReactStore component wiring + - bootstrap ReactStore + - React reducer registry - TSX Store dispatch - React signal component - selector .useValue component @@ -31,9 +33,11 @@ components, start app sagas after initialization, and dispatch through that same configured store instance from components and handlers. This is React Store family guidance for JSX/TSX components, custom hooks, and -the app bootstrap boundary. +the app bootstrap boundary. First-time installation and family selection start +at `../../setup/SKILL.md`; migration sequencing starts at +[Adoption checkpoint](../migration/setup/SKILL.md#adoption-checkpoint). -## 1. Create and configure the app `ReactStore` +## Create and configure ReactStore Create the store in an app-owned module, not inside a component render or custom hook. Pass app-owned reducers in the constructor map, optional middleware as the @@ -58,7 +62,7 @@ Key rules: - Do not add package-owned `@internal_` reducers or internal sagas. - Do not create a new `ReactStore` per component, route, hook call, or render. -## 2. Initialize before React renders selector users +## Initialize before React renders selector users Call `reactStore.init(initialState?)` once at the app bootstrap/root ownership boundary before rendering components that call direct signal selectors or @@ -93,7 +97,7 @@ Pass preloaded state to `reactStore.init(preloadedState)` when the app needs hydration. Initialize before selector reads because direct selector calls need the active Store-owned state stream and throw before `init()` and after `dispose()`. -## 3. Dispose at the same owner boundary +## Dispose at the same owner boundary The owner that calls `reactStore.init()` owns teardown. In browser apps this is usually the bootstrap file or test harness; in embedded/micro-frontend apps it may be the host's mount/unmount adapter. @@ -115,7 +119,7 @@ Do not hide `init()` in a child component `useEffect` if descendants render selectors immediately; effects run after render, too late for direct signal selectors or `.useValue(...args)` fallbacks that need the initialized store. -## 4. Start app sagas with `reactStore.runSaga(sagaFn)` +## Start app sagas explicitly `reactStore.init()` starts package-owned runtime work but does not auto-start app sagas. Start each app saga explicitly after initialization and keep the returned @@ -137,20 +141,13 @@ export function disposeAppRuntime() { `reactStore.runSaga(sagaFn)` throws if `init()` has not been called or the saga name is reserved for package internals. Do not start `@internal_sagaManager` directly. -## 5. Read state in components with direct selector signals +## Component reads and dispatch -React components and custom hooks should prefer direct selector calls and pass/read -the returned `ReadonlySignal` where the Preact React signal integration -supports it. Use `.useValue(...args)` only for third-party components, existing -boundaries, or hook contracts that require a plain value and are impractical to -adapt. - -When a component reads `signal.value`, make sure the file is covered by the -`@preact/signals-react` Babel transform or call `useSignals()` from -`@preact/signals-react/runtime` in the reading component/custom hook. Passing a -`ReadonlySignal` to a signal-aware child, or rendering a signal directly in a -JSX text position, is valid when intentional; do not treat the signal object as a -plain value for props, conditions, array operations, or serialization. +Once the app runtime is initialized, prefer signal-aware component reads and +dispatch through the same configured store. Choose the read API using +[Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map) and apply +[React signal consumption guardrails](../selector-lifecycle/SKILL.md#react-signal-consumption-guardrails). +The `.value` reads below require the tracking described in that owner. ```tsx import { reactStore } from "../store/react-store"; @@ -170,44 +167,17 @@ export function TodoRow({ id }: { id: string }) { } ``` -Component rules: - -- Prefer direct selector calls for signal-aware components and custom hooks. -- Ensure `.value` reads are tracked by the Babel transform or `useSignals()`. -- Use `.useValue(...args)` only in React components or custom hooks that truly need a - plain value. -- Import or otherwise receive the configured `ReactStore` instance and dispatch - with `reactStore.dispatch(action)` in event handlers. -- Use `.select(reactStore.state, ...args)` for one-shot reads inside handlers, - callbacks, tests, or pure composition. -- Do not use `.useValue(...args)` as the default just to avoid adapting a consumer to - accept a Preact React signal object. -- Do not create `useEffect` or custom hooks that carry business logic or side effects - (API calls, persistence, timers, subscriptions, event listeners, async workflows); - dispatch an action and handle the work in a saga instead. DOM-local effects (focus, - scroll, measurement) remain allowed. See `../../core/core-policy/SKILL.md` §3 and - `../migration/side-effects/SKILL.md`. - -## 6. Event-handler one-shot reads - -Handlers should not call `.useValue(...args)` and should not create direct signals just -to read once. Use `.select(state, ...args)` with the initialized app store state -already in scope, then dispatch through the configured store. +Import or receive the configured `ReactStore` instance; event handlers dispatch +with `reactStore.dispatch(action)`. When a handler also needs a snapshot, follow +[Handler and test one-shot reads](../selector-lifecycle/SKILL.md#handler-and-test-one-shot-reads) +rather than creating a render subscription. -```tsx -function DeleteTodoButton({ id }: { id: string }) { - function onDelete() { - const todo = selectTodoById.select(reactStore.state, id); - if (todo && !todo.completed) { - reactStore.dispatch(deleteTodo(id)); - } - } - - return ; -} -``` +For React business effects versus DOM-local hooks, apply +[Setup — core rules](../../core/core-policy/SKILL.md#setup--core-rules). +Use [React side-effect migration](../migration/side-effects/SKILL.md) when moving +an existing effect; startup stays at the app owner described above. -## 7. Common mistakes +## Common mistakes ### Initializing in an effect after children render @@ -237,23 +207,15 @@ Create/configure the store once in an app module or explicit mount adapter. ### Treating direct selector signals as plain values -```tsx -// ❌ WRONG: todos is a ReadonlySignal, not Todo[]. -function TodoCount() { - const todos = selectTodos(); - return {todos.length}; -} -``` - -Read `todos.value.length` in a tracked component, pass the signal to a -signal-aware child, or use `.useValue(...args)` only at a documented plain-value -fallback boundary. +Wiring the runtime does not turn selector signals into plain values. Apply +[Pitfalls](../selector-lifecycle/SKILL.md#pitfalls) for wrong-shape reads, +tracking requirements, and necessary plain-value fallback boundaries. -## 8. See also +## See also -- `react/store/SKILL.md` — `ReactStore` import, lifecycle, and Store runtime behavior. -- `react/signals/SKILL.md` — Preact Signals `.value`, tracking, direct JSX signal +- `../store/SKILL.md` — `ReactStore` import, lifecycle, and Store runtime behavior. +- `../signals/SKILL.md` — Preact Signals `.value`, tracking, direct JSX signal rendering, and component-local signal hooks. -- `react/selector-lifecycle/SKILL.md` — selector call modes across components,handlers, tests, composition, explicit binding, and sagas. -- `react/selectors/SKILL.md` — selector authoring for Preact React signals. +- `../selector-lifecycle/SKILL.md` — selector call modes across components, handlers, tests, composition, explicit binding, and sagas. +- `../selectors/SKILL.md` — selector authoring for Preact React signals. - `../../setup/SKILL.md` — first-time Store-family selection and setup. \ No newline at end of file diff --git a/skills/react/migration/SKILL.md b/skills/react/migration/SKILL.md index 32638e1..f0733f4 100644 --- a/skills/react/migration/SKILL.md +++ b/skills/react/migration/SKILL.md @@ -7,6 +7,7 @@ description: >- type: lifecycle requires: - react + - core/core-policy sources: - ./assessment/SKILL.md - ./setup/SKILL.md @@ -30,29 +31,23 @@ stores, component `useMemo` derivations, or `useEffect` side effects. ## React migration policy -1. **ReactStore owns shared, persisted, or async-driven state.** Anything read or - written by multiple components, synced to storage/server/IPC, or coordinated - with async flows moves to actions, reducers, selectors, and sagas. -2. **Component-local ephemeral UI state stays local.** Hover, focus, transient - form drafts, uncontrolled input details, scroll position, and single-component - toggles can remain in React component state. -3. **Selectors own shared derivations.** Move duplicated `useMemo`, derived custom - hook return values, and context selector logic into `ReactStore.createSelector`. -4. **Sagas own shared side effects.** Move persistent subscriptions, fetches, - debounces, timers, storage sync, and cross-feature effects out of components. - -Migrate one state owner/slice at a time. Keep the app on the React Store family: -`ReactStore`, direct Preact React selector signals as the preferred React consumer -path, `.useValue(...args)` only for necessary hook/plain-value fallback paths, and -`.select(state, ...args)` for composition and tests, and `.effect(...args)` for -sagas. +Apply [When to use Redux vs component-local state](../../core/core-policy/SKILL.md#when-to-use-redux-vs-component-local-state) +to each React owner, and [Setup — core rules](../../core/core-policy/SKILL.md#setup--core-rules) +to derived-state and effect ownership. These are the canonical placement rules; +this index owns migration sequencing, not a separate state policy. Record the +React-specific evidence with [Decision framework](./assessment/SKILL.md#decision-framework). + +Migrate one state owner/slice at a time, keeping the app on `ReactStore`. +Signal-first reads and necessary plain-value fallbacks follow +[Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map), including handler, +test, composition, and saga boundaries. ## React migration leaf routes | Route | Use when | | --- | --- | | `./assessment/SKILL.md` | Inventory shared React local state, context/hooks, external stores, derivations, effects, and consumers. | -| `./setup/SKILL.md` | Create and initialize the app-owned `ReactStore`, register reducers, and start app sagas. | +| `./setup/SKILL.md` | Confirm migration readiness against canonical installation and React runtime owners; not greenfield bootstrap instructions. | | `./writable-stores/SKILL.md` | Move shared mutable React state into actions, reducers, and serializable slice state. | | `./derived-stores/SKILL.md` | Move shared derivations to `ReactStore` selectors and test them with `.select`. | | `./side-effects/SKILL.md` | Move shared, persistent, or async effects to sagas. | @@ -61,20 +56,19 @@ sagas. ## Recommended order -1. `assessment` — inventory React state owners, derivations, effects, and +1. `./assessment/SKILL.md` — inventory React state owners, derivations, effects, and consumers; classify shared vs component-local. -2. `setup` — choose `ReactStore`, create the app store, initialize/dispose at the - app owner, and start app sagas with `reactStore.runSaga(sagaFn)`. -3. `writable-stores` — move shared mutable React state to serializable slice +2. `./setup/SKILL.md` — complete the [Adoption checkpoint](./setup/SKILL.md#adoption-checkpoint). + For first-time installation/family choice, start at + [Store-family decision gate](../../setup/SKILL.md#store-family-decision-gate); + for runtime mechanics use [Create and configure ReactStore](../component-integration/SKILL.md#create-and-configure-reactstore). +3. `./writable-stores/SKILL.md` — move shared mutable React state to serializable slice state, actions, and pure reducers. -4. `derived-stores` — move shared derivations to selectors, compose with - `.select(state, ...args)`, consume in components with direct selector signals - first, and unit-test with `.select`. -5. `side-effects` — move shared or async effects to sagas. -6. `component-migration` — replace component/context/custom-hook reads with - direct selector signals where possible, `.useValue(...args)` only where plain values - are necessary, and Store-first dispatch. -7. `cleanup` — remove old providers/hooks/state owner modules after verification. +4. `./derived-stores/SKILL.md` — move shared derivations to selectors and test them. +5. `./side-effects/SKILL.md` — migrate effects selected by core policy. +6. `./component-migration/SKILL.md` — replace consumers using the canonical + [Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map) and Store-first dispatch. +7. `./cleanup/SKILL.md` — remove old providers/hooks/state owner modules after verification. ## Quick reference @@ -82,12 +76,11 @@ sagas. | --- | --- | | Shared `useState` / `useReducer` state | Slice initial state + actions + reducer | | Context provider that stores business state | `ReactStore` reducer map + selectors/actions | -| Custom hook exposing shared mutable state | Direct selector signals + action dispatch helpers; `.useValue(...args)` only for plain-value hook contracts | +| Custom hook exposing shared mutable state | [Custom hook migration](./component-migration/SKILL.md#custom-hook-migration) + action dispatch | | External mutable store subscription | Reducer state + saga/channel integration as needed | | Repeated `useMemo`/derived hook value | `reactStore.createSelector(...)` | -| `useEffect` fetch/timer/storage sync | Saga with `takeEvery`/`takeLatest`, `call`, `put`, `delay` | -| Component render read | `selectFoo(...args)` signal first; `selectFoo.useValue(...args)` only for necessary plain values | -| Handler/test/composition read | `selectFoo.select(reactStore.state, ...args)` | +| `useEffect` business fetch/timer/storage sync | [Conversion recipes](./side-effects/SKILL.md#conversion-recipes) after policy classification | +| Render/handler/test/composition read | [Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map) | ## Orchestration example @@ -123,10 +116,8 @@ const nextSteps: ReactMigrationStep[] = cartAssessment.verdict === "reactstore" - React examples import `ReactStore` from `@augmentcode/themis/react-store` and keep selectors Store-bound. -- Components/custom hooks consume migrated shared state with direct selector signals - first; `.useValue(...args)` appears only for documented plain-value fallbacks. -- Selectors compose and tests assert with `.select(state, ...args)`. -- Sagas read migrated state with `.effect(...args)` and start via - `reactStore.runSaga(sagaFn)` after `init()`. +- Consumer migration passes [Verification cues](../selector-lifecycle/SKILL.md#verification-cues). +- Runtime readiness passes [Verification cues](./setup/SKILL.md#verification-cues), + including saga startup at the React app owner. - React migration instructions keep state ownership, selector consumption, and side-effect ownership explicit at each step. \ No newline at end of file diff --git a/skills/react/migration/assessment/SKILL.md b/skills/react/migration/assessment/SKILL.md index e657962..83e5785 100644 --- a/skills/react/migration/assessment/SKILL.md +++ b/skills/react/migration/assessment/SKILL.md @@ -9,6 +9,7 @@ type: sub-skill requires: - react - react/migration + - core/core-policy triggers: - audit React state - classify React state @@ -16,7 +17,7 @@ triggers: --- # React migration assessment -Before editing, inventory current React state ownership and decide what moves to`ReactStore` versus what stays local. +Before editing, inventory current React state ownership and decide what moves to `ReactStore` versus what stays local. ## Identify React state owners @@ -31,19 +32,13 @@ Search for state and effect patterns in React files: ## Decision framework -### Move to ReactStore - -- State read or written by multiple components or routes. -- State that persists across navigation, reloads, or app sessions. -- State involved in async operations, debouncing, timers, IPC, or server sync. -- State that drives business logic, permissions, feature flags, or cross-featurecoordination. -- Derivations duplicated across components/hooks. - -### Keep in React component state - -- Single-component hover/focus/open state. -- Uncommitted form drafts that do not leave one component. -- DOM measurement, uncontrolled input details, scroll position, and animationstate that only matters while one component is mounted. +Classify each inventoried React owner using +[When to use Redux vs component-local state](../../../core/core-policy/SKILL.md#when-to-use-redux-vs-component-local-state). +For effects and shared derivations, apply +[Setup — core rules](../../../core/core-policy/SKILL.md#setup--core-rules). +This leaf owns React-pattern inventory and evidence, not a separate placement +policy. Record the matched policy criterion and all consumers, including +services/non-component code; do not infer ownership from the hook name alone. ## Assessment output @@ -60,6 +55,7 @@ type ReactStateInventoryRecord = { derivedValues: string[]; sideEffects: string[]; consumers: string[]; + policyReason: string; verdict: ReactMigrationVerdict; nextSkills: string[]; }; @@ -71,6 +67,7 @@ export const cartInventory: ReactStateInventoryRecord = { derivedValues: ["itemCount", "subtotal"], sideEffects: ["localStorage sync"], consumers: ["CartSummary.tsx", "HeaderCartButton.tsx"], + policyReason: "Shared business state with persisted storage synchronization", verdict: "reactstore", nextSkills: ["setup", "writable-stores", "derived-stores", "side-effects", "component-migration"], }; @@ -90,8 +87,8 @@ export const hoverInventory = { ## Downstream routing -- Mutable shared state → `react/migration/writable-stores`. -- Shared derivations → `react/migration/derived-stores`. -- Shared async/persistent effects → `react/migration/side-effects`. -- JSX/TSX consumers → `react/migration/component-migration`. -- Old owners/import paths → `react/migration/cleanup`. \ No newline at end of file +- Mutable shared state → `../writable-stores/SKILL.md`. +- Shared derivations → `../derived-stores/SKILL.md`. +- Shared async/persistent effects → `../side-effects/SKILL.md`. +- JSX/TSX consumers → `../component-migration/SKILL.md`. +- Old owners/import paths → `../cleanup/SKILL.md`. \ No newline at end of file diff --git a/skills/react/migration/component-migration/SKILL.md b/skills/react/migration/component-migration/SKILL.md index e78b827..943427f 100644 --- a/skills/react/migration/component-migration/SKILL.md +++ b/skills/react/migration/component-migration/SKILL.md @@ -2,12 +2,12 @@ name: react/migration/component-migration description: >- Migrate React JSX/TSX components and custom hooks to ReactStore selectors and - Store-first dispatch. Prefer direct selector signals in components/hooks, use - selector .useValue(...args) only for necessary hook/plain-value fallback reads, - .select(state, ...args) in handlers/tests. + Store-first dispatch. Owns before/after consumer mapping and rollout order; + defers call-mode decisions to React selector lifecycle. type: sub-skill requires: - react/component-integration + - react/selector-lifecycle - react/migration triggers: - migrate React component @@ -17,12 +17,12 @@ triggers: # React component migration Replace old React state/context/custom-hook reads with `ReactStore` selectors, -read in components and custom hooks via direct selector signals where possible, -use `.useValue(...args)` only when a plain value is necessary, and dispatch through the -configured `ReactStore` instance. +prefer signal-aware consumers, and dispatch through the configured store. -React migration steps use component/custom-hook boundaries and the explicit -selector call modes described below. +Choose render, hook, handler, and test entry points using +[Call-mode map](../../selector-lifecycle/SKILL.md#call-mode-map). Apply +[React signal consumption guardrails](../../selector-lifecycle/SKILL.md#react-signal-consumption-guardrails) +to the `.value` reads in the migrated example, including React tracking. ## Before: context/custom hook consumption @@ -55,58 +55,31 @@ export function CartButton({ id }: { id: string }) { ## Custom hook migration -Prefer returning signals from migrated custom hooks when callers can accept them. -Use `.useValue(...args)` only for legacy hook contracts that must return plain values. - -```tsx -import { selectCartTotal, selectIsCartSaving } from "../store/cart/cart-selectors"; - -export function useCartSummary() { - const total = selectCartTotal(); - const isSaving = selectIsCartSaving(); - return { total, isSaving }; -} -``` +Record which existing hook contracts can become signal-aware and which must +keep plain values. Follow [Call-mode map](../../selector-lifecycle/SKILL.md#call-mode-map) +and [Fallback hook/plain-value read](../../selector-lifecycle/SKILL.md#fallback-hookplain-value-read) +instead of maintaining a second hook-consumption recipe here. ## Handler one-shot reads -Handlers should not call `.useValue(...args)` and should not create direct signals just -to read once. Use `.select(reactStore.state, ...args)`. - -```tsx -import { reactStore } from "../store/react-store"; -import { checkoutRequested } from "../store/cart/cart-slice"; -import { selectCanCheckout } from "../store/cart/cart-selectors"; - -export function CheckoutButton() { - function onCheckout() { - if (selectCanCheckout.select(reactStore.state)) { - reactStore.dispatch(checkoutRequested()); - } - } - return ; -} -``` +Move old context snapshots to the configured store without introducing a render +subscription. Use [Handler and test one-shot reads](../../selector-lifecycle/SKILL.md#handler-and-test-one-shot-reads) +for the implementation and hook-boundary restrictions. ## Rollout order per component -1. Replace old state/context/custom-hook imports with the new slice actions,selectors, and configured `reactStore` instance. -2. Replace render-time reads with direct selector signals; use `.useValue(...args)` only - for necessary plain-value boundaries. +1. Replace old state/context/custom-hook imports with the new slice actions, selectors, and configured `reactStore` instance. +2. Map each read boundary using [Call-mode map](../../selector-lifecycle/SKILL.md#call-mode-map). 3. Replace writes with `reactStore.dispatch(actionCreator(...))`. -4. Replace handler/test one-shot reads with `.select(reactStore.state, ...args)`. -5. Keep single-component ephemeral UI state in React component state. +4. Verify both render consumers and one-shot handlers against the lifecycle examples above. +5. Preserve local-state decisions from [Decision framework](../assessment/SKILL.md#decision-framework). 6. Remove obsolete providers/hooks only after all consumers migrate. ## Bad: calling `.useValue` in an event handler -```tsx -// BAD: .useValue belongs in React components/custom hooks during render, not handlers. -function onSubmit() { - const canCheckout = selectCanCheckout.useValue(); - if (canCheckout) reactStore.dispatch(checkoutRequested()); -} -``` +Moving a custom-hook read into an event handler can violate React hook rules. +Check [Pitfalls](../../selector-lifecycle/SKILL.md#pitfalls) before replacing the +old consumer; do not carry its render-only call mode into callbacks. ## Bad: duplicate old and new owners diff --git a/skills/react/migration/derived-stores/SKILL.md b/skills/react/migration/derived-stores/SKILL.md index df7d45f..6184962 100644 --- a/skills/react/migration/derived-stores/SKILL.md +++ b/skills/react/migration/derived-stores/SKILL.md @@ -15,10 +15,9 @@ triggers: --- # React derived state migration -Shared React derivations move to `reactStore.createSelector(...)`. Compose -selectors with `.select(state, ...args)`, consume migrated values in components -or custom hooks with direct selector signals first, use `.useValue(...args)` only for -necessary plain-value fallback reads, and test pure selector logic with `.select`. +Shared React derivations move to `reactStore.createSelector(...)`. This leaf +maps old calculations to selector definitions; choose consumption and test APIs +using [Call-mode map](../../selector-lifecycle/SKILL.md#call-mode-map). React sources include duplicated `useMemo`, derived custom-hook return values, context selector helpers, and render-time calculations reused across components. @@ -55,6 +54,10 @@ export const selectCartTotal = reactStore.createSelector((state) => { ## Component and test consumption +The migrated read below is signal-aware. Apply +[React signal consumption guardrails](../../selector-lifecycle/SKILL.md#react-signal-consumption-guardrails) +for `.value` tracking and any necessary plain-value fallback boundary. + ```tsx import { selectCartTotal } from "../store/cart/cart-selectors"; @@ -86,11 +89,9 @@ export const selectTodoTitle = reactStore.createSelector((state, id: string) => - One selector per shared derivation; keep selectors narrow and pure. - Compose upstream selectors with `.select(state, ...args)` inside selector bodies. -- Components and custom hooks prefer direct signal calls; use `.useValue(...args)` only - when a plain-value boundary is necessary and a signal-aware rewrite is - impractical. -- Handlers, tests, and selector unit tests use `.select(state, ...args)`. -- Sagas use `yield* selectFoo.effect(...args)`. +- Verify migrated consumer boundaries using + [Verification cues](../../selector-lifecycle/SKILL.md#verification-cues), not a + migration-specific call-mode policy. - Do not store selector outputs in reducers; reducers own base state only. ## Bad: direct signal form inside selector composition @@ -104,6 +105,6 @@ export const selectBadTotal = reactStore.createSelector(() => { ## Cross-references -- `../../selectors/SKILL.md` — selector creation and call forms. +- `../../selectors/SKILL.md` — selector authoring and caching. - `../../selector-lifecycle/SKILL.md` — direct signal, `.useValue`, `.select`, `.effect`, and `.withStore` choices. - `../component-migration/SKILL.md` — component consumption after migration. \ No newline at end of file diff --git a/skills/react/migration/setup/SKILL.md b/skills/react/migration/setup/SKILL.md index 4e59b10..8bcbdc2 100644 --- a/skills/react/migration/setup/SKILL.md +++ b/skills/react/migration/setup/SKILL.md @@ -1,9 +1,9 @@ --- name: react/migration/setup description: >- - Pre-migration ReactStore setup. Use the canonical setup skill plus ReactStore - routing to create the configured store, initialize/dispose it at the React app - owner, register app reducers, and start app sagas. + ReactStore adoption checkpoint before migrating existing React state owners. + Confirms installation, the selected family, and the app lifecycle owner by + following setup and React component-integration, without repeating bootstrap. type: sub-skill requires: - react @@ -11,99 +11,44 @@ requires: - react/migration triggers: - ReactStore migration setup - - bootstrap ReactStore - - React reducer registry + - prepare React state migration + - React migration readiness --- # React migration setup -Complete this once before migrating individual React state owners. Start at the canonical root setup skill (`../../../setup/SKILL.md`), choose the React Store family, and keep the app on `ReactStore` for this code path. +Use this checkpoint only when adopting `ReactStore` into an existing app. For a +new app or an unresolved family choice, start with +[Store-family decision gate](../../../setup/SKILL.md#store-family-decision-gate) +and [Installation workflow](../../../setup/SKILL.md#installation-workflow). +Runtime bootstrap mechanics belong to `../../component-integration/SKILL.md`. -## 1. Import the public ReactStore runtime +## Adoption checkpoint -Use the npm package directly. Do not copy package source files into the app. +Before migrating the first state owner, record the existing or newly configured +app runtime and the evidence for each checkpoint: -```ts -import { ReactStore } from "@augmentcode/themis/react-store"; -import type { StoreState } from "@augmentcode/themis/types"; -``` +| Migration checkpoint | Canonical implementation | +| --- | --- | +| Package installed, public imports used rather than copied source | [Installation workflow](../../../setup/SKILL.md#installation-workflow) and [Correct import and class choice](../../store/SKILL.md#correct-import-and-class-choice) | +| One app-owned store and reducer map, with no package-internal registrations | [Create and configure ReactStore](../../component-integration/SKILL.md#create-and-configure-reactstore) | +| Migrated selector users cannot render before initialization | [Initialize before React renders selector users](../../component-integration/SKILL.md#initialize-before-react-renders-selector-users) | +| The same bootstrap, test, or mount adapter owns cleanup, not a child effect | [Dispose at the same owner boundary](../../component-integration/SKILL.md#dispose-at-the-same-owner-boundary) | +| Migrated sagas start explicitly after initialization and have a cancellation owner | [Start app sagas explicitly](../../component-integration/SKILL.md#start-app-sagas-explicitly) | -## 2. Create an app-owned ReactStore module +Reuse an existing configured store rather than introducing a second runtime. If +no slice has migrated yet, its initial app-owned reducer map can be empty; add +reducers as their owners migrate using the linked configuration procedure. -Start with the migrated reducer map you already have, or an empty app-owned map when preparing the runtime before the first slice. +## Add slices incrementally -```ts -// src/store/react-store.ts -import { ReactStore } from "@augmentcode/themis/react-store"; -import type { StoreState } from "@augmentcode/themis/types"; - -export const reactStore = new ReactStore({}); -export type AppState = StoreState; -``` - -As slices migrate, add app-owned reducers to the constructor map. Do not manually register package-owned `@internal_` reducers or internal sagas. - -## 3. Initialize before rendering selector users - -Call `reactStore.init(initialState?)` at the React bootstrap, test harness, or -micro-frontend mount boundary before components call direct selector signals or -`.useValue(...args)` fallbacks. - -```tsx -// src/main.tsx -import React from "react"; -import { createRoot } from "react-dom/client"; -import { App } from "./App"; -import { reactStore } from "./store/react-store"; - -const root = createRoot(document.getElementById("root")!); -const disposeStore = reactStore.init(); - -root.render( - - - -); - -export function disposeApp() { - root.unmount(); - disposeStore(); -} -``` - -Do not hide `init()` in a child component `useEffect`; effects run after the -first render and can be too late for direct selector signal calls or -`.useValue(...args)` fallbacks. - -## 4. Start app sagas explicitly after init - -`reactStore.init()` starts package-owned runtime work, not app sagas. Start each migrated app saga with `reactStore.runSaga(sagaFn)` after initialization. - -```ts -import { reactStore } from "./store/react-store"; -import { cartSaga } from "./store/cart/sagas/cart-saga"; - -const disposeStore = reactStore.init(); -const cancelCartSaga = reactStore.runSaga(cartSaga); - -export function disposeRuntime() { - cancelCartSaga(); - disposeStore(); -} -``` - -## 5. Add slices incrementally - -Per migrated slice, create app files such as: - -- `src/store/{slice}/{slice}-slice.ts` for initial state, actions, reducer. -- `src/store/{slice}/{slice}-selectors.ts` for `reactStore.createSelector(...)`. -- `src/store/{slice}/sagas/{slice}-saga.ts` for async/shared side effects. - -Then add the reducer to the `ReactStore` constructor map and start the saga from the same runtime owner that initialized the store. +Migrate one inventoried owner at a time. Follow +[Setup — slice directory layout](../../../core/file-structure/SKILL.md#setup--slice-directory-layout) +for slice types, actions/reducers, selectors, sagas, and tests rather than copying +a second layout here. Register the migrated reducer and start its saga through +the same runtime owner recorded in the checkpoint above. ## Verification cues -- `ReactStore` comes from `@augmentcode/themis/react-store`. -- Store initialization happens before React renders selector users. -- App sagas start with `reactStore.runSaga(sagaFn)` after `init()`. -- Setup instructions keep initialization and saga ownership at the React app boundary. \ No newline at end of file +- Record the configured store module, bootstrap/mount owner, teardown path, and + migrated reducer/saga registrations; verify each against its canonical section above. +- Continue to `../writable-stores/SKILL.md` only after this checkpoint is satisfied. \ No newline at end of file diff --git a/skills/react/migration/side-effects/SKILL.md b/skills/react/migration/side-effects/SKILL.md index c871b1f..4625e18 100644 --- a/skills/react/migration/side-effects/SKILL.md +++ b/skills/react/migration/side-effects/SKILL.md @@ -6,6 +6,8 @@ description: >- type: sub-skill requires: - core/sagas + - core/core-policy + - react/component-integration - react/migration triggers: - migrate React useEffect @@ -14,7 +16,10 @@ triggers: --- # React side-effect migration -Shared, persistent, or async React side effects move to sagas. DOM-only effects that exist solely to manage one component's mounted DOM can remain local. +Classify existing React effects with +[When to use Redux vs component-local state](../../../core/core-policy/SKILL.md#when-to-use-redux-vs-component-local-state) +and [Setup — core rules](../../../core/core-policy/SKILL.md#setup--core-rules) +before migrating business work to sagas. Keep permitted DOM-local effects local. React sources include `useEffect` fetches, subscriptions, timers, debounces, storage sync, IPC/websocket listeners, and custom hooks that hide async work. @@ -65,18 +70,13 @@ export function* usersSaga() { ## Start the saga from ReactStore setup -```ts -import { reactStore } from "../react-store"; -import { usersSaga } from "./users-saga"; - -const disposeStore = reactStore.init(); -const cancelUsersSaga = reactStore.runSaga(usersSaga); - -export function disposeRuntime() { - cancelUsersSaga(); - disposeStore(); -} -``` +Attach each migrated app saga to the existing React bootstrap/root owner, not +to a replacement business-effect hook. Follow +[Start app sagas explicitly](../../component-integration/SKILL.md#start-app-sagas-explicitly) +for post-init startup and cancellation, and +[Dispose at the same owner boundary](../../component-integration/SKILL.md#dispose-at-the-same-owner-boundary) +for teardown. Core [Application saga startup](../../../core/sagas/SKILL.md#application-saga-startup) +supplies the framework-neutral contract; React integration owns its lifecycle placement. ## Conversion recipes @@ -95,6 +95,7 @@ export function disposeRuntime() { - Use `takeLatest` for stale-response-prone fetch/search flows. - Use `takeEvery` when every action must be processed. - Use selector `.effect(...args)` in sagas when the saga needs current derived state. + Follow [Saga reads](../../selector-lifecycle/SKILL.md#saga-reads) for call-mode boundaries. - Do not keep both a migrated `useEffect` and a saga for the same trigger. - Do not use selector `.useValue(...args)` or direct React signals from saga code. @@ -109,6 +110,6 @@ React.useEffect(() => { ## Cross-references -- `../../../core/sagas/SKILL.md` — saga patterns and Store-first startup. +- `../../../core/sagas/SKILL.md` — framework-neutral saga patterns. - `../../../core/selector-channels/SKILL.md` — reacting to selector value changes from sagas. -- `../../selectors/SKILL.md` — `.effect(...args)` and non-React saga reads. \ No newline at end of file +- `../../selector-lifecycle/SKILL.md` — saga selector reads without React hooks/signals. \ No newline at end of file diff --git a/skills/react/migration/writable-stores/SKILL.md b/skills/react/migration/writable-stores/SKILL.md index 56c4288..6c4419d 100644 --- a/skills/react/migration/writable-stores/SKILL.md +++ b/skills/react/migration/writable-stores/SKILL.md @@ -8,6 +8,8 @@ type: sub-skill requires: - core/actions - core/reducers + - core/core-policy + - core/state-serialization - react/migration triggers: - migrate React local state @@ -16,7 +18,10 @@ triggers: --- # React mutable state migration -Shared mutable React state maps to serializable slice state, action creators, and pure reducers. Keep component-local ephemeral UI state in React. +Shared mutable React state maps to slice state, actions, and reducers only after +classification with [When to use Redux vs component-local state](../../../core/core-policy/SKILL.md#when-to-use-redux-vs-component-local-state). +Keep the assessment's local-state verdicts; this leaf owns the React migration +mapping, not the core state/action/reducer contracts. React source patterns include `useState`, `useReducer`, context provider state, custom hook state, and external mutable stores. @@ -39,30 +44,34 @@ export function CounterProvider({ children }: { children: React.ReactNode }) { ## After: slice state, actions, reducer -```ts -import { createAction } from "@augmentcode/themis/utils/store/create-action"; -import { createReducer } from "@augmentcode/themis/utils/store/create-reducer"; - -type CounterState = { count: number; username: string }; -const initialState: CounterState = { count: 0, username: "" }; - -export const setCount = createAction<[value: number]>("counter/setCount"); -export const increment = createAction("counter/increment"); -export const setUsername = createAction<[value: string]>("counter/setUsername"); - -export const counterReducer = createReducer(initialState) - .with(setCount, (state, { payload: [count] }) => count === state.count ? state : { ...state, count }) - .with(increment, (state) => ({ ...state, count: state.count + 1 })) - .with(setUsername, (state, { payload: [username] }) => ({ ...state, username })); -``` - -## Rules - -- Move shared, persisted, async-driven, or business state into slice state. -- Keep reducers pure: no `fetch`, `localStorage`, timers, clocks, random IDs, or mutation. -- Keep state serializable: no `Date`, `Map`, `Set`, class instances, functions, promises, or DOM objects. -- Compute new state in reducers; React components dispatch action creators. -- Preserve reference equality on no-op updates when practical to avoid needless selector invalidation. +| React source in the example | Migrated owner | +| --- | --- | +| Provider `count` and `username` state | Canonical fields in the counter slice | +| `setCount` / `setUsername` setters | Named actions carrying the new primitive value | +| Functional `increment` setter | Event action handled by the counter reducer | +| Context consumers | Store-bound selectors plus configured Store dispatch | + +Implement the mapped actions using [Examples](../../../core/actions/SKILL.md#examples) +(no-payload events and tuple payloads), and implement the reducer using +[Examples](../../../core/reducers/SKILL.md#examples) (handler chaining and +same-reference no-ops). Do not create migration-local copies of those APIs. +Then migrate JSX/TSX consumers with +[After: direct selector signal plus Store dispatch](../component-migration/SKILL.md#after-direct-selector-signal-plus-store-dispatch). + +## Core implementation contracts + +- State placement is decided by [When to use Redux vs component-local state](../../../core/core-policy/SKILL.md#when-to-use-redux-vs-component-local-state). +- Pure immutable transitions and no-op identity follow [Do](../../../core/reducers/SKILL.md#do) + and [Don't](../../../core/reducers/SKILL.md#dont); components dispatch rather than owning the transition. +- Every migrated field, including nested values and objects previously held by + React providers, must meet [Do](../../../core/state-serialization/SKILL.md#do) + and the complete [Don't](../../../core/state-serialization/SKILL.md#dont) + serialization exclusions. Do not substitute a shorter migration-specific list. +- Add/update serialization tests for initial state and changed reducer paths + according to [Verification cues](../../../core/state-serialization/SKILL.md#verification-cues) + and [JSON round-trip regression test](../../../core/state-serialization/SKILL.md#json-round-trip-regression-test). + Verify reducer transitions and no-op identity with + [Verification cues](../../../core/reducers/SKILL.md#verification-cues). ## Component-local state remains local @@ -77,15 +86,9 @@ export function ProductCard() { ## Bad: reducer side effect -```ts -// BAD: reducers must not persist or read external systems. -export function badSaveSettings(state: { theme: string }, action: { payload: [string] }) { - localStorage.setItem("theme", action.payload[0]); - return { ...state, theme: action.payload[0] }; -} -``` - -Move the persistence into `react/migration/side-effects` saga guidance. +Do not carry a provider's persistence into its replacement reducer. The forbidden +operations remain in [Don't](../../../core/reducers/SKILL.md#dont); migrate that +work separately via [Conversion recipes](../side-effects/SKILL.md#conversion-recipes). ## Cross-references diff --git a/skills/react/selector-lifecycle/SKILL.md b/skills/react/selector-lifecycle/SKILL.md index d42c11b..c4b71d3 100644 --- a/skills/react/selector-lifecycle/SKILL.md +++ b/skills/react/selector-lifecycle/SKILL.md @@ -16,6 +16,10 @@ sources: - ../selectors/SKILL.md triggers: - React selector lifecycle + - selector .useValue + - selector .withStore + - selector .select + - selector .effect - selector .useValue lifecycle - direct signal read - useSignals selector @@ -30,7 +34,10 @@ consumer integration path when callers can pass, read, or render signals. Use `.useValue(...args)` only when a hook/plain value is necessary and adapting the consumer to accept signals is impractical. -Use this lifecycle for apps that chose the React Store family. +Use this lifecycle for apps that chose the React Store family. This is the +canonical owner of the React selector call-mode matrix and consumer boundaries; +selector definitions and argument evaluation belong to +[Selector argument API](../selectors/SKILL.md#selector-argument-api). ## Call-mode map @@ -50,6 +57,8 @@ Use this lifecycle for apps that chose the React Store family. signal integration. - Ensure React component `.value` reads are tracked by the Preact Signals Babel transform or by an explicit `useSignals()` fallback in the reading component. + Configuration and transform limitations are in + [React tracking requirement](../signals/SKILL.md#react-tracking-requirement). - Use `.useValue(...args)` in React components or custom hooks only when a plain value or hook-shaped API is required and a signal-aware rewrite is impractical. - Use `.select(reactStore.state, ...args)` in handlers and tests when a plain @@ -70,8 +79,9 @@ Use this lifecycle for apps that chose the React Store family. is not a replacement for `.select(state, ...args)` in handlers/tests/sagas. - `.select(state, ...args)` and `.effect(...args)` take plain selector arguments, not signal wrappers. -- Selector-channel helpers use the Redux store from saga context; they do not - subscribe to direct `ReadonlySignal` selector outputs. +- Selector-channel helpers use the Redux store's `getState()` / `subscribe()` + from saga context with plain stable argument tuples; they do not subscribe to + direct `ReadonlySignal` selector outputs. ## Don't @@ -83,7 +93,7 @@ Use this lifecycle for apps that chose the React Store family. ## Examples -### 1. React component read with direct signals +### React component read with direct signals ```tsx import { selectTodoById } from "../store/todos/todos-selectors"; @@ -101,7 +111,7 @@ export function TodoTitle({ id }: { id: string }) { } ``` -### 2. Fallback hook/plain-value read with `.useValue(...args)` +### Fallback hook/plain-value read ```tsx export function useCanEditTodo(id: string) { @@ -114,7 +124,7 @@ export function useCanEditTodo(id: string) { Use this fallback only when the hook contract must return a plain boolean and rewriting callers to accept the underlying signals would be too invasive. -### 3. Direct signal call for signal-aware code +### Direct signal call for signal-aware code ```ts const todoSignal = selectTodoById("todo-1"); @@ -124,7 +134,7 @@ console.log(todoSignal.value?.title); Use the direct form when the caller can accept a `ReadonlySignal`; for pure selector composition, prefer `.select(state, ...args)`. -### 4. Handler/test one-shot read with `.select(state, ...args)` +### Handler and test one-shot reads ```tsx import { reactStore } from "../store/react-store"; @@ -136,7 +146,7 @@ function onDelete(id: string) { } ``` -### 5. Compose selectors with `.select(state, ...args)` +### Compose selectors ```ts export const selectVisibleTodos = reactStore.createSelector((state) => { @@ -146,7 +156,7 @@ export const selectVisibleTodos = reactStore.createSelector((state) => { }); ``` -### 6. Saga read with `.effect(...args)` +### Saga reads ```ts import { put } from "typed-redux-saga"; @@ -160,7 +170,7 @@ export function* saveCurrentTodoWorker() { Do not pass the selector object itself to saga `select`; use `.effect(...args)` or `.select(state, ...args)` intentionally. -### 7. Explicit binding with `.withStore(...)` +### Explicit Store binding ```ts import type { ReactStore } from "@augmentcode/themis/react-store"; @@ -206,8 +216,8 @@ for `ReactStore`, that result is a Preact React `ReadonlySignal`. ## See also -- `react/component-integration/SKILL.md` — app bootstrap, Store lifecycle, saga +- `../component-integration/SKILL.md` — app bootstrap, Store lifecycle, saga startup, component dispatch, and handler examples. -- `react/selectors/SKILL.md` — authoring ReactStore selectors. -- `react/store/SKILL.md` — `ReactStore` import and initialization rules. +- `../selectors/SKILL.md` — authoring ReactStore selectors. +- `../store/SKILL.md` — `ReactStore` import and initialization rules. - `@augmentcode/themis/docs/SELECTORS.md` — human reference for selector call forms. \ No newline at end of file diff --git a/skills/react/selector-scheduling/SKILL.md b/skills/react/selector-scheduling/SKILL.md index 995bb5a..a7de1a1 100644 --- a/skills/react/selector-scheduling/SKILL.md +++ b/skills/react/selector-scheduling/SKILL.md @@ -31,12 +31,12 @@ configured `ReactStore` instance and tune selector coalescing only through ## Store-first scheduling rule +Scheduling applies to signal-backed render consumers. Choose the consumer API +using [Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map); this leaf +owns cadence, not component/hook/handler/saga boundary rules. + - Create production selectors with the configured `ReactStore` instance: `reactStore.createSelector(...)`. -- Direct selector calls return Preact React `ReadonlySignal` values and are the - preferred React consumer integration path when callers can accept signals. -- Use `selectFoo.useValue(...args)` in React components and custom hooks only when a - hook/plain value is necessary and a signal-aware rewrite is impractical. - Direct `ReadonlySignal` outputs are cached for the same ReactStore instance + selector + args, and direct signal outputs plus `.useValue(...args)` subscribe to the owning `ReactStore`'s Store-owned cadence, capped by `throttledSelectorFrequency`. @@ -47,17 +47,16 @@ configured `ReactStore` instance and tune selector coalescing only through supported. - Selector trace output is disabled by default; pass `{ traceSelectors: true }` in the same final options object only for temporary diagnostics. -- Use `.select(reactStore.state, ...args)` for one-shot handlers/tests and - `.effect(...args)` for sagas; those paths are not React render subscriptions. +- Snapshot and saga reads do not use React render scheduling; follow + [React signal consumption guardrails](../selector-lifecycle/SKILL.md#react-signal-consumption-guardrails). ## Do not - Do not import selector scheduler helpers from package internals or source-shaped paths. - Do not wrap selector callbacks, selector signals, or `.useValue(...args)` results with ad hoc `memoize`, `cache`, debounce, `setTimeout`, `requestAnimationFrame`, streams, or proxy state just to reduce React renders. -- Do not manually subscribe to selector outputs from React components; pass/read the - selector signal directly, or use `.useValue(...args)` only at necessary plain-value - boundaries. +- Do not manually subscribe to selector outputs from React components; use the + consumer integration in [Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map). - Do not rely on selector outputs as audit/event streams; they represent the latest derived state and may coalesce intermediate writes. - Do not replace this React signal scheduling model with unrelated selector @@ -65,7 +64,7 @@ configured `ReactStore` instance and tune selector coalescing only through ## Examples -### 1. Configure coalescing at the ReactStore owner +### Configure coalescing at the ReactStore owner ```ts import { ReactStore } from "@augmentcode/themis/react-store"; @@ -78,7 +77,7 @@ export const reactStore = new ReactStore( ); ``` -### 2. Define selectors through the configured Store +### Define selectors through the configured Store ```ts import { reactStore } from "./react-store"; @@ -95,68 +94,13 @@ export const selectPointerLabel = reactStore.createSelector((state) => { }); ``` -### 3. Direct signal output is already scheduled - -```ts -import { selectPointerLabel } from "./pointer-selectors"; - -const pointerLabelSignal = selectPointerLabel(); - -export function readPointerLabelNow() { - return pointerLabelSignal.value; -} -``` - -Use the direct form for React consumers that can accept a Preact React -`ReadonlySignal`; this is the preferred component integration path. - -### 4. Components and hooks prefer direct signals - -```tsx -import { selectPointer, selectPointerLabel } from "./pointer-selectors"; - -export function PointerBadge() { - const pointer = selectPointer(); - const label = selectPointerLabel(); - return {label.value}; -} -``` - -Use `.useValue(...args)` here only if a third-party component or hook API requires -plain values and cannot reasonably accept the signals. +### Consumer integration -### 5. Event handlers use `.select(state)` for one-shot reads - -```tsx -import { reactStore } from "./react-store"; -import { copyPointer } from "./pointer-slice"; -import { selectPointer } from "./pointer-selectors"; - -export function handleCopyPointer() { - const pointer = selectPointer.select(reactStore.state); - reactStore.dispatch(copyPointer(pointer)); -} -``` - -### 6. Saga code uses `.effect()` rather than React scheduling - -```ts -import { call, put, takeLatest } from "typed-redux-saga"; -import { pointerMoved, pointerPersisted } from "./pointer-slice"; -import { selectPointer } from "./pointer-selectors"; - -declare const api: { savePointer(pointer: { x: number; y: number }): Promise }; - -export function* pointerSaga() { - yield* takeLatest(pointerMoved, function* persistPointer() { - const pointer = yield* selectPointer.effect(); - yield* call(api.savePointer, pointer); - yield* put(pointerPersisted()); - }); -} -``` +Direct signal outputs are already scheduled. Component/hook, handler/test, and +saga examples belong to [Examples](../selector-lifecycle/SKILL.md#examples), +including the distinction between render subscriptions and one-shot reads. -### 7. ❌ Bad: manual debounce wrapper around selector output +### ❌ Bad: manual debounce wrapper around selector output ```tsx // BAD: this adds stale local state on top of Store-owned selector coalescing. @@ -174,7 +118,7 @@ export function useManuallyDebouncedPointer() { } ``` -### 8. ❌ Bad: treating selector outputs as event logs +### ❌ Bad: treating selector outputs as event logs ```tsx // BAD: selector values are latest-state projections and can coalesce writes. @@ -190,7 +134,7 @@ export function PointerAuditPanel({ audit }: { audit: Array<{ x: number; y: numb } ``` -### 9. Prefer actions or sagas when every event matters +### Prefer actions or sagas when every event matters ```ts import { call, takeEvery } from "typed-redux-saga"; @@ -205,21 +149,14 @@ export function* pointerAuditSaga() { } ``` -## Cases covered +## Verification cues -| Case | Examples | -| --- | --- | -| ReactStore-owned scheduler configuration | 1 | -| Store-bound selector definitions and composition | 2 | -| Direct `ReadonlySignal` outputs | 3 | -| Component/custom-hook direct signal reads, with `.useValue(...args)` fallback | 4 | -| Handler/test one-shot reads | 5 | -| Saga read mode that bypasses React render scheduling | 6 | -| Manual debounce/wrapper misuse | 7 | -| Audit/event-log misuse and replacement | 8, 9 | +- Cadence is configured at the Store, not with consumer wrappers. +- Selector outputs are latest-state projections, not event logs. +- Consumer examples follow [Verification cues](../selector-lifecycle/SKILL.md#verification-cues). ## See also -- `react/selectors` — building `ReactStore` selectors. -- `react/selector-lifecycle` — choosing direct signal, `.useValue`, `.select`, `.effect`, and `.withStore` call modes. +- `../selectors/SKILL.md` — building `ReactStore` selectors. +- `../selector-lifecycle/SKILL.md` — choosing direct signal, `.useValue`, `.select`, `.effect`, and `.withStore` call modes. - `@augmentcode/themis/docs/SELECTORS.md` — selector memoization and lifecycle rules. \ No newline at end of file diff --git a/skills/react/selectors/SKILL.md b/skills/react/selectors/SKILL.md index bb20086..656b8ca 100644 --- a/skills/react/selectors/SKILL.md +++ b/skills/react/selectors/SKILL.md @@ -1,13 +1,9 @@ --- name: react/selectors description: >- - Author ReactStore selectors whose direct calls return Preact React - ReadonlySignal values and are the preferred React consumer integration path. - Covers signal selector arguments, .value tracking via Babel transform or - useSignals(), Store-bound creation, .useValue(...args) as a fallback for - hook/plain-value boundaries, .withStore(reactStore), pure .select(state) - composition/testing, and saga-only .effect() usage without - importing React selector internals. + Author Store-bound ReactStore selectors with pure callbacks, cached + ReadonlySignal results, and stable scalar or signal arguments. Selector + lifecycle owns consumer call-mode choices and React tracking boundaries. type: sub-skill requires: - react @@ -21,24 +17,18 @@ sources: triggers: - React selector - direct selector signal - - selector .useValue - - selector .withStore - - selector .select - - selector .effect - signal selector - ReadonlySignal selector - ReactStore selector --- -# React selectors — signal-first call model +# React selectors — authoring and caching Use this skill when `store.createSelector(...)` belongs to a `ReactStore` and direct selector calls should produce Preact React `ReadonlySignal` results. -Direct selector calls are the preferred React consumer integration path when -callers can pass, read, or render signals through the Preact React signal -integration. Components that read `.value` must be tracked by the Preact Signals -Babel transform or an explicit `useSignals()` fallback. Use `.useValue(...args)` -only when a hook/plain value is required and adapting the consumer to accept a -signal is impractical. +This leaf owns selector definitions and caching, not consumer lifecycle. For +signal-first consumption and plain-value fallback boundaries, follow +[Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map) and +[React signal consumption guardrails](../selector-lifecycle/SKILL.md#react-signal-consumption-guardrails). ## Authoring rules @@ -52,36 +42,19 @@ signal is impractical. identity is stable and intentional. - Do not import from `themis` React selector internal deep paths. -```tsx -import { ReactStore } from "@augmentcode/themis/react-store"; -import type { ReadonlySignal } from "@preact/signals-react"; +```ts +import { reactStore } from "./react-store"; -export const reactStore = new ReactStore({ todos: todosReducer }); export const selectTodo = reactStore.createSelector((state, id: string) => { return state.todos.collection.map[id]; }); - -type Todo = { title: string } | undefined; - -function TodoTitle({ todo }: { todo: ReadonlySignal }) { - return {todo.value?.title}; -} - -function TodoRow({ id }: { id: string }) { - const todo = selectTodo(id); - return ; -} ``` -## Call forms +## Selector argument API -| Context | Use | Result | -| --- | --- | --- | -| Preferred React/signal-aware consumer | `selectFoo(...argsOrSignals)` | `ReadonlySignal` | -| Hook/plain-value fallback | `selectFoo.useValue(...argsOrSignals)` | Plain value `R` | -| Explicit ReactStore binding | `selectFoo.withStore(reactStore)(...argsOrSignals)` | `ReadonlySignal` | -| Tests/handlers/composition | `selectFoo.select(state, ...args)` | Plain value `R` | -| Sagas | `yield* selectFoo.effect(...args)` | typed-redux-saga select effect | +Choose the consumer entry point using +[Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map); the argument API +below describes how signal-aware entry points evaluate their inputs. Selector arguments may be plain values or Preact React `ReadonlySignal` values for direct calls, `.useValue(...args)`, and `.withStore(...)(...args)`. Signal @@ -90,27 +63,20 @@ derived selector signal updates when either Store state or signal arguments change. `.select(state, ...args)` and `.effect(...args)` are plain synchronous or saga paths; pass plain argument values there instead of signal wrappers. -Prefer direct signal outputs for React consumers that can accept signals; they -and `.useValue(...args)` are throttled by the owning `ReactStore`'s -`throttledSelectorFrequency` option, defaulting to `64` FPS. `.useValue(...args)` -remains valid only for third-party APIs, legacy component boundaries, or custom -hooks that must return a plain value. Selector trace output is disabled by -default; pass `{ traceSelectors: true }` in the final Store options object only -for temporary diagnostics. `.effect(...args)` is saga-only; it is not a React -hook, signal subscription, or throttled render path. - -Selector-channel helpers that consume `.select`/`.effect`-compatible selectors -run in sagas and support `ReactStore` selectors through the same shared selector - read shape used by ReactStore selectors. Pass plain stable selector arguments as - the helper args tuple; selector-channel effects subscribe through the Redux store - object's `getState()` / `subscribe()` context path, not through React signals. +Signal emissions use the owning Store cadence; configuration and temporary +trace options belong to [Store-first scheduling rule](../selector-scheduling/SKILL.md#store-first-scheduling-rule). +Saga and selector-channel reads do not subscribe to React signals; follow +[React signal consumption guardrails](../selector-lifecycle/SKILL.md#react-signal-consumption-guardrails) +for that boundary. ## Selector caching - Store-created selectors have internal selector-result caching/memoization. - Direct React `ReadonlySignal` outputs are cached per ReactStore instance + selector + arguments; repeated `selectFoo(args)` calls for the same store reuse the same `ReadonlySignal`. - Do not wrap selector callbacks, direct signal calls, or `.useValue(...args)` calls in extra `memoize`, `cache`, manual cache maps, debounce, or throttle layers solely for performance. -- Prefer the same Store-bound selector + same arguments over props drilling when the receiving consumer can reasonably call the selector in valid React/signal context; otherwise use `.select`, `.effect`, `.withStore`, or `.useValue` as the boundary requires. +- Prefer the same Store-bound selector + same arguments over props drilling when + the receiving consumer can call it in a valid context; choose that context's + entry point via [Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map). ## Stable selector arguments @@ -128,32 +94,13 @@ run in sagas and support `ReactStore` selectors through the same shared selector `(state, { id, includeDone })`; destructure an object arg only when callers pass a documented stable reference. -## Call-mode examples +## Authoring examples -### 1. React component direct signal use +Consumer examples for direct signals, hook fallbacks, handlers, explicit Store +bindings, and sagas live in [Examples](../selector-lifecycle/SKILL.md#examples). +These examples focus on argument tracking and pure selector definitions. -```tsx -import type { ReadonlySignal } from "@preact/signals-react"; -import { selectTodo } from "./todos-selectors"; - -type Todo = { title: string; completed: boolean } | undefined; - -function TodoLabel({ todo }: { todo: ReadonlySignal }) { - return {todo.value?.title ?? "Untitled"}; -} - -export function TodoRow({ id }: { id: string }) { - const todo = selectTodo(id); - return ; -} -``` - -The `.value` read must be covered by the Preact Signals Babel transform or by an -explicit `useSignals()` call in the reading component. Passing the -`ReadonlySignal` through props is preferred over converting it to a plain -value when the child can be signal-aware. - -### 2. Signal arguments update derived selectors +### Signal arguments update derived selectors ```tsx import { useSignal } from "@preact/signals-react"; @@ -169,19 +116,7 @@ export function FilteredTodos() { The selector receives a signal argument and internally tracks `filter.value`. Consumers still receive a `ReadonlySignal` result. -### 3. Plain-value fallback with `.useValue(...args)` - -```tsx -export function LegacyTodoBadge({ id }: { id: string }) { - const todo = selectTodo.useValue(id); - return ; -} -``` - -Use this only when `LegacyBadge` or the surrounding hook contract requires a -plain value and cannot reasonably accept `ReadonlySignal`. - -### 4. Pure composition and tests with `.select(state, ...args)` +### Pure composition and tests ```ts export const selectOpenTodoTitles = reactStore.createSelector((state) => { @@ -193,59 +128,14 @@ export const selectOpenTodoTitles = reactStore.createSelector((state) => { expect(selectOpenTodoTitles.select(mockState)).toEqual(["Write docs"]); ``` -`.select(...)` is pure and synchronous. Use it inside selector callbacks, tests, -and one-shot handlers when an explicit state snapshot is already available. - -### 5. Explicit alternate binding with `.withStore(reactStore)` - -```ts -const selectPreviewTodo = selectTodo.withStore(previewReactStore); -const previewTodo = selectPreviewTodo("todo-1"); -``` - -For React selectors, `.withStore(...)` accepts another `ReactStore` and returns a -direct-call binding whose calls produce `ReadonlySignal`. - -### 6. Saga read with `.effect(...args)` - -```ts -import { call } from "typed-redux-saga"; -import { selectTodo } from "./todos-selectors"; - -export function* persistTodo(todoId: string) { - const todo = yield* selectTodo.effect(todoId); - if (todo) yield* call(api.saveTodo, todo); -} -``` - -`.effect(...args)` is saga-only. It does not create a React signal or call React -hooks. - -### 7. ❌ Bad: treating a signal as a plain value - -```tsx -export function TodoTitle({ id }: { id: string }) { - const todo = selectTodo(id); - return {todo.title}; -} -``` - -React selector direct calls return Preact React signals, not plain values. Use -`todo.value`, pass/render the signal intentionally, or choose -`.useValue(...args)` only at a real plain-value fallback boundary. +Keep composition synchronous against the supplied state snapshot. Consumer +wrong-shape and hook-boundary mistakes are covered in +[Pitfalls](../selector-lifecycle/SKILL.md#pitfalls). ## Don't -- Do not call `.useValue(...args)` outside React components or custom hooks. -- Do not choose `.useValue(...args)` as the default React render path when a component - or helper can be adapted to accept a `ReadonlySignal`. -- Do not treat a direct selector result as a plain array/object/string/boolean; - read `.value` in a tracked component, pass/render the signal intentionally, or - use `.useValue(...args)` at a documented fallback boundary. -- Do not use selector lifecycle rules outside the ReactStore call modes described - above. -- Do not call another selector's direct signal form inside a selector callback; use - `.select(state)` to keep composition pure and synchronous. +- Follow [React signal consumption guardrails](../selector-lifecycle/SKILL.md#react-signal-consumption-guardrails) + for signal/plain-value and hook boundaries; this leaf does not redefine them. - Do not add manual memoization or throttling wrappers around selector calls, direct signals, or `.useValue(...)`; configure selector coalescing through the owning `ReactStore` options instead. - Do not props-drill derived values solely to avoid selector calls when the consumer can call the same Store-bound selector with the same args in a valid React/signal context. - Do not use standalone React selector utilities as public package imports. @@ -255,17 +145,12 @@ React selector direct calls return Preact React signals, not plain values. Use ## Verification cues -- React component examples prefer direct selector calls and pass/read - `ReadonlySignal` values when signal integration supports it, with `.value` - reads covered by the Babel transform or explicit `useSignals()` fallback. -- `.useValue(...args)` examples are framed as hook/plain-value fallback reads, not the - default consumer path. +- Check consumer examples against [Verification cues](../selector-lifecycle/SKILL.md#verification-cues). +- Definitions are Store-bound, pure, and use stable arguments without extra caches. - Unit tests for pure selector logic use `.select(mockState, ...args)`. -- Sagas use `.effect(...args)` and never treat direct selector signals as saga - subscriptions. ## See also -- `react/store/SKILL.md` — Store class and import choice. +- `../store/SKILL.md` — Store class and import choice. - `@augmentcode/themis/docs/SELECTORS.md` — human reference and examples for all call forms. -- `core/state-integrity/SKILL.md` — canonical derived-value ownership. \ No newline at end of file +- `../../core/state-integrity/SKILL.md` — canonical derived-value ownership. \ No newline at end of file diff --git a/skills/react/signals/SKILL.md b/skills/react/signals/SKILL.md index 8da3628..aeb8f46 100644 --- a/skills/react/signals/SKILL.md +++ b/skills/react/signals/SKILL.md @@ -30,8 +30,9 @@ triggers: # React/Preact signals — tracking and consumption Use this skill for React apps that chose `ReactStore` and Preact Signals. It -owns general signal consumption guidance; `../selectors/SKILL.md` owns -ReactStore selector call modes that produce or consume those signals. +owns general signal tracking and component-local signals; +[Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map) owns ReactStore +selector consumption choices, while `../selectors/SKILL.md` owns authoring. This skill covers React signal consumption only. Keep selector outputs and component-local signals within the React tracking mechanisms described below. @@ -100,36 +101,21 @@ Rules: ## ReactStore selector signal consumption -```tsx -import type { ReadonlySignal } from "@preact/signals-react"; -import { selectTodoById } from "../store/todos/todos-selectors"; - -type Todo = { title: string } | undefined; - -function TodoTitleText({ todo }: { todo: ReadonlySignal }) { - return {todo.value?.title ?? "Untitled"}; -} - -export function TodoTitle({ id }: { id: string }) { - const todo = selectTodoById(id); - return ; -} -``` +ReactStore selectors produce read-only signals that use the tracking mechanisms +above. For component/prop examples and necessary plain-value fallbacks, follow +[React signal consumption guardrails](../selector-lifecycle/SKILL.md#react-signal-consumption-guardrails) +and [Examples](../selector-lifecycle/SKILL.md#examples); this skill does not +define a second selector call-mode policy. -The direct call is preferred because `selectTodoById(id)` returns a -`ReadonlySignal`. The `.value` read must be tracked by the Babel transform -or explicit `useSignals()` in the reading component. - -### Direct JSX signal rendering +## Direct JSX signal rendering ```tsx -import { computed } from "@preact/signals-react"; -import { selectTodoById } from "../store/todos/todos-selectors"; +import { useComputed, useSignal } from "@preact/signals-react"; -export function TodoTitle({ id }: { id: string }) { - const todo = selectTodoById(id); - const title = computed(() => todo.value?.title ?? "Untitled"); - return <>{title}; +export function DraftLength() { + const draft = useSignal(""); + const label = useComputed(() => `${draft.value.length} characters`); + return <> draft.value = event.currentTarget.value} />{label}; } ``` @@ -138,18 +124,8 @@ a signal as text. For props, conditions, array/object operations, or values sent to non-signal-aware APIs, read `.value` in a tracked component or use a documented plain-value fallback. -### Plain-value fallback boundary - -```tsx -export function LegacyTodoTitle({ id }: { id: string }) { - const todo = selectTodoById.useValue(id); - return ; -} -``` - -Use `.useValue(...args)` only in React components/custom hooks when a third-party -component, legacy API, or hook contract must receive a plain `R`. Do not make it -the default render path just to avoid passing `ReadonlySignal`. +Selector-specific fallback choices are in +[Fallback hook/plain-value read](../selector-lifecycle/SKILL.md#fallback-hookplain-value-read). ## Do / don't @@ -165,9 +141,8 @@ Do: Don't: -- Do not treat a direct selector result as a plain array/object/string/boolean; - use `.value`, direct JSX signal rendering, or `.useValue(...args)` at a real - fallback boundary. +- For selector results, apply the signal/plain-value boundaries in + [Pitfalls](../selector-lifecycle/SKILL.md#pitfalls). - Do not destructure, map, compare, or serialize a signal object as if it were the selected value. - Do not replace ReactStore selectors with module-level shared signals for app @@ -177,8 +152,7 @@ Don't: - Component examples that read `.value` mention Babel transform or explicit `useSignals()` tracking. -- Direct selector calls are documented as returning `ReadonlySignal` and are - preferred for signal-aware React consumers. -- `.useValue(...args)` examples are clearly fallback boundaries. +- Selector-specific examples follow + [Verification cues](../selector-lifecycle/SKILL.md#verification-cues). - React signal guidance remains scoped to ReactStore selector outputs and component-local signal state. diff --git a/skills/react/store/SKILL.md b/skills/react/store/SKILL.md index c011420..86519ca 100644 --- a/skills/react/store/SKILL.md +++ b/skills/react/store/SKILL.md @@ -3,7 +3,8 @@ name: react/store description: >- ReactStore import, initialization, disposal, and Store-runtime guidance for the React signal Store variant. Use for @augmentcode/themis/react-store, - inherited runSaga/dispatch/state behavior, and React signal selector call modes. + inherited runSaga/dispatch/state behavior, with app wiring and selector call + modes delegated to their React owners. type: sub-skill requires: - react @@ -15,7 +16,7 @@ sources: triggers: - ReactStore - react-store import - - Store state lifecycle + - ReactStore state lifecycle - signal Store --- # ReactStore import and lifecycle @@ -38,6 +39,12 @@ const dispose = reactStore.init(); ## Lifecycle rules +This is a runtime API summary. Bootstrap, init/dispose ownership, and app saga +startup procedures belong to +[Create and configure ReactStore](../component-integration/SKILL.md#create-and-configure-reactstore) +and its lifecycle sections. Consumer call-mode decisions belong to +[Call-mode map](../selector-lifecycle/SKILL.md#call-mode-map). + - Construct `ReactStore` with app-owned reducers and optional middleware, then call `reactStore.init(initialState?)` before invoking direct selector calls or `.useValue(...args)`. @@ -47,7 +54,8 @@ const dispose = reactStore.init(); module-level shared Preact signals. - `reactStore.dispatch`, `reactStore.state`, `reactStore.runSaga(sagaFn)`, and `reactStore.dispose()` follow the shared Store runtime behavior documented in - core Store guidance. + `@augmentcode/themis/docs/ARCHITECTURE.md`; saga runtime mechanics are in + [Store saga lifecycle](../../core/saga-manager/SKILL.md#store-saga-lifecycle). - Do not manually register package-owned `@internal_` reducers or internal sagas. ## Verification cues From 17dda5c90a29e4e0c9d12b4903c17ea97ea3d3d6 Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 14:18:42 -0700 Subject: [PATCH 09/13] Consolidate root setup and Streaming skill ownership Agent-Id: agent-0d1bca18-6d13-479a-937a-a4e6395d1c0b --- skills/SKILL.md | 34 +- skills/setup/SKILL.md | 440 +++---------------- skills/streaming/SKILL.md | 26 +- skills/streaming/selector-lifecycle/SKILL.md | 36 +- skills/streaming/selectors/SKILL.md | 32 +- skills/streaming/store/SKILL.md | 68 +-- 6 files changed, 162 insertions(+), 474 deletions(-) diff --git a/skills/SKILL.md b/skills/SKILL.md index 43680ce..65d8829 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -32,13 +32,15 @@ triggers: - Redux saga - Redux store pruning - selector lifecycle + - Store state lifecycle + - selector before init - Node store --- # themis skill router Use this repository root skill first when choosing package guidance. Its job is routing only: load `./setup/SKILL.md` for first-time app setup, load the family that matches the environment and touched code path, then load the leaf skills named by that family. Do not treat this file as a replacement index for `./setup/`, `./core/`, `./svelte/`, `./react/`, or `./streaming/`. -> This package uses a CUSTOM Redux setup — not Redux Toolkit (RTK). Do not use `createSlice`, `configureStore`, `createAsyncThunk`, or any RTK API. +For the package's custom Redux API and architecture constraints, read `./core/core-policy/SKILL.md` — **Setup — core rules**. ## App-level Store family rule @@ -48,17 +50,19 @@ Use this repository root skill first when choosing package guidance. Its job is ## Selector output cache routing -- Cached direct selector outputs are not Svelte-only. Route Svelte readable cache guidance to `./svelte/selectors/SKILL.md` and `./svelte/selector-scheduling/SKILL.md`. -- Route React `ReadonlySignal` cache guidance to `./react/selectors/SKILL.md` and `./react/selector-scheduling/SKILL.md`; direct signal calls are preferred where valid. -- Route Streaming/Kefir `Observable` cache guidance to `./streaming/selectors/SKILL.md` and `./streaming/selector-lifecycle/SKILL.md`; prefer same selector+args over manual stream passing where valid. +- After selecting the family, route Svelte readable cache guidance to `./svelte/selectors/SKILL.md` — **Selector caching** and `./svelte/selector-scheduling/SKILL.md`. +- Route React `ReadonlySignal` cache guidance to `./react/selectors/SKILL.md` — **Selector caching** and `./react/selector-scheduling/SKILL.md`; preferred consumer call modes belong to `./react/selector-lifecycle/SKILL.md` — **Call-mode map**. +- Route Streaming/Kefir `Observable` cache guidance to `./streaming/selectors/SKILL.md` — **Selector caching** and `./streaming/selector-lifecycle/SKILL.md` — **Lifecycle map**. -## Universal architecture rule — effects live in sagas, not components +## Universal architecture routing -This rule applies to EVERY family below (Svelte, React, Streaming, Core). Components render and dispatch only. **Do NOT create new custom hooks, React `useEffect`, or Svelte `$effect` that contain business logic or side effects** — API calls, persistence/localStorage, timers, subscriptions, event listeners, IPC/websocket, or async workflows. Those belong in sagas. Dispatch an action from the component and handle the work in a saga. +For every family, route Redux ownership and component-versus-saga side-effect decisions to `./core/core-policy/SKILL.md` — **Setup — core rules** and **When to use Redux vs component-local state**. That skill owns the policy, including the DOM-local exception; this router does not redefine it. -The ONLY permitted component effects are DOM-local: focus, scroll, measurement, and third-party widget lifecycle that cannot live elsewhere. Anything else is a violation. +For existing effects, classify them with that policy before loading the selected family's migration leaf: `./react/migration/side-effects/SKILL.md` or `./svelte/migration/side-effects/SKILL.md`. Do not load both for the same app. -For the canonical statement of this rule and for migrating any existing effects into sagas, see `./core/core-policy/SKILL.md` §3, `./react/migration/side-effects/SKILL.md`, and `./svelte/migration/side-effects/SKILL.md`. +## Generic lifecycle routing + +Unqualified requests such as `selector lifecycle`, `Store state lifecycle`, or `selector before init` enter here, not a concrete family leaf. First identify the target app/package/code path using **Routing decision order**. If the path itself is unknown, ask for it; once classified, absence of concrete UI evidence defaults to Streaming. Load only the selected family's Store and selector-lifecycle leaves through `./svelte/SKILL.md`, `./react/SKILL.md`, or `./streaming/SKILL.md`. A generic lifecycle phrase alone is not evidence for any UI family. ## Routing decision order @@ -84,15 +88,15 @@ For the canonical statement of this rule and for migrating any existing effects ## Consumer skill install routing -When a consuming app asks how to install packaged AI skills, use the same evidence as the routing decision above and recommend the smallest matching bundle: +When a consuming app asks for packaged AI skills, use the same evidence as **Routing decision order** to select the smallest matching bundle: -- React evidence → `npx themis install-skills:react` (root router plus `setup`, `core`, and `react`). -- Svelte/SvelteKit evidence → `npx themis install-skills:svelte` (root router plus `setup`, `core`, and `svelte`). -- Streaming, Node/server/worker/CLI/test/no-UI, observable evidence, or no concrete UI evidence → `npx themis install-skills:streaming` (root router plus `setup`, `core`, and `streaming`). -- Shared Redux/redux-saga guidance only → `npx themis install-skills:core` (root router plus `setup` and `core`). -- Use `npx themis install-skills` or `npx themis install-skills:all` only when every package skill family is intentionally needed. +- React evidence → React bundle. +- Svelte/SvelteKit evidence → Svelte bundle. +- Streaming/no-UI or observable evidence → Streaming bundle. +- Shared Redux/redux-saga guidance only → Core bundle. +- Choose all families only when every family is intentionally needed across separate apps or paths. -Package installation itself never copies skills automatically. Explicit installs copy to `.agents/skills/themis/`, create or reuse `.claude/skills/themis` as its compatibility link, and preserve collisions with a warning. Repeating a command refreshes package-owned files from its manifest; `npx themis cleanup-skills` removes owned files and links before uninstall. Read the canonical workflow in [@augmentcode/themis/docs/INSTALLATION.md](@augmentcode/themis/docs/INSTALLATION.md) for destination, refresh, verification, cleanup, and maintainer details. +Continue at `./setup/SKILL.md` — **Installation workflow**. The sole operational owner is `@augmentcode/themis/docs/INSTALLATION.md` — **Consumer CLI and bundle selection** and **Verify, refresh, cleanup, and uninstall**; it specifies commands, bundle contents, destinations, compatibility links, collision/refresh behavior, and cleanup ordering. ## Evidence to record in handoff diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index f2cae0a..b6f90cd 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -2,9 +2,9 @@ name: setup description: >- Canonical first-time themis setup entry. Start here for greenfield app - setup, choose exactly one concrete Store family before creating files, and adapt - imports, selector call modes, lifecycle, saga startup, and verification to that - family. + setup, choose exactly one concrete Store family before creating files, then + follow the neutral checklist and explicit core/family implementation references. + Installation commands remain in the canonical installation documentation. triggers: - init redux - setup store @@ -48,401 +48,63 @@ Make this decision before creating `store.ts`, selectors, root lifecycle wiring, **Mutual exclusivity rule:** one app/package/code path must not mix concrete Store families. Do not import more than one of `Store`, `ReactStore`, and `StreamingStore` into the same setup path, do not mix Svelte readable `$selector` patterns with React `.useValue(...)` or Kefir observables, and do not reuse lifecycle examples across families except as explicit contrast notes. -## Branch setup adapters +## Installation workflow -Use the shared slice/action/reducer/saga examples below for all families, but adapt the Store-specific seams with this matrix before writing files: +Choose a bundle using `../SKILL.md` — **Consumer skill install routing**. Follow `@augmentcode/themis/docs/INSTALLATION.md`, the sole owner of installation operations: -| Setup seam | Svelte Store | React Store | Streaming Store | -| --- | --- | --- | --- | -| Store import and instance | import { Store } from '@augmentcode/themis/svelte-store'; export const store = new Store({ counter: counterReducer }) | import { ReactStore } from '@augmentcode/themis/react-store'; export const reactStore = new ReactStore({ counter: counterReducer }) | import { StreamingStore } from '@augmentcode/themis/streaming-store'; export const streamStore = new StreamingStore({ counter: counterReducer }) | -| State bridge expectation | store.init() creates the Store-owned state stream; direct selector calls return Svelte readables and window.svelteRedux.reduxContext exposes state/dispatch for devtool inspection | reactStore.init() creates the Store-owned state stream; direct selector calls return Preact React signals | streamStore.init() creates the Store-owned Kefir state stream; direct selector calls return Kefir observables | -| Selector direct call mode | selectFoo() returns a Svelte readable and belongs at Svelte component init; templates use $foo | selectFoo() returns a ReadonlySignal and is the preferred React consumer path; use selectFoo.useValue(...args) only for necessary plain-value boundaries | selectFoo() returns a Kefir Observable; consumers own observation/subscription teardown | -| Non-render selector reads | .select(state, ...args) for tests, handlers, and selector composition; yield* selectFoo.effect(...args) in sagas | Same .select(...) and saga .effect(...); .useValue(...) is not a saga/test helper | Same .select(...) and saga .effect(...); observable calls are not Svelte readables or React hooks | -| Initialization owner | Svelte root layout calls const dispose = store.init(); onDestroy(dispose) before children use selectors | App bootstrap initializes reactStore before React selector reads; dispose when the root/test owner unmounts or exits | Process/server/test owner initializes streamStore before observing selector streams; dispose when that owner exits | -| App saga startup | store.init() starts package internals only; call store.runSaga(counterSaga) after init, often from Svelte onMount | reactStore.init() starts package internals only; call reactStore.runSaga(counterSaga) after init and retain/call the cancel function in the bootstrap owner | streamStore.init() starts package internals only; call streamStore.runSaga(counterSaga) after init and cancel it before/with store disposal | -| Optional Store options | Pass selector scheduling or diagnostics as the third constructor argument: new Store(reducers, middleware, { throttledSelectorFrequency, sagaMonitor: true, traceSelectors: true }) | Same third-argument options shape on ReactStore; omit sagaMonitor/traceSelectors to keep diagnostics disabled | Same third-argument options shape on StreamingStore; omit sagaMonitor/traceSelectors to keep diagnostics disabled | -| Verification | Svelte component renders $count, dispatch buttons work, optional window.svelteRedux inspection after initDevTool() | React component/custom hook prefers selectCount() as a signal and reads .value where supported; .useValue() appears only for necessary plain-value fallback reads and no Svelte $ syntax appears | Kefir observer receives selector values after init, stream subscriptions are stopped, no Svelte/React render lifecycle appears | - -### Svelte Store branch minimum setup - -Choose this branch only for a Svelte/SvelteKit app path with concrete Svelte evidence. Do not add `ReactStore`, `StreamingStore`, React `.useValue(...)`, Preact signals, Kefir/observable selector, React setup, or streaming lifecycle patterns to the same app. - -```ts -import { Store } from '@augmentcode/themis/svelte-store'; -import type { StoreState } from '@augmentcode/themis/types'; - -export const store = new Store({ counter: counterReducer }); -export type AppState = StoreState; -``` - -```svelte - -``` - -### React Store branch minimum setup - -Choose this branch only for a React UI app path with concrete React evidence. Do not copy Svelte readable component-init or StreamingStore/Kefir examples into the same app. - -```ts -import { ReactStore } from '@augmentcode/themis/react-store'; -import type { StoreState } from '@augmentcode/themis/types'; - -export const reactStore = new ReactStore({ counter: counterReducer }); -export type AppState = StoreState; -``` - -```tsx -const dispose = reactStore.init(); -const cancelCounterSaga = reactStore.runSaga(counterSaga); - -function Counter() { - const count = selectCount(); - return ; -} - -// root/test teardown owner: -cancelCounterSaga(); -dispose(); -``` - -### Streaming Store branch minimum setup - -Choose this branch by default for Node/server/worker/CLI/test/no-UI paths, or whenever the app wants Kefir/observable selector calls. Do not copy Svelte component lifecycle or React `.useValue(...)` examples into the same app. - -```ts -import { StreamingStore } from '@augmentcode/themis/streaming-store'; -import type { StoreState } from '@augmentcode/themis/types'; - -export const streamStore = new StreamingStore({ counter: counterReducer }); -export type AppState = StoreState; -``` - -```ts -const dispose = streamStore.init(); -const cancelCounterSaga = streamStore.runSaga(counterSaga); -const countSubscription = selectCount().observe((count) => { - console.log(`[Counter] Current count: ${count}`); -}); - -// process/test teardown owner: -countSubscription.unsubscribe(); -cancelCounterSaga(); -dispose(); -``` - -## Svelte Store branch working example - -The detailed walkthrough below is the Svelte/SvelteKit branch. For React or Streaming apps, keep the shared slice/reducer/saga structure but swap every Store-specific seam using the branch setup adapters above. - -## Step 1 — Install Dependencies - -```bash -npm install @augmentcode/themis redux redux-saga typed-redux-saga fast-equals -``` - -Svelte 5 is also a required peer dependency and is assumed to already be installed in your Svelte project. - -For testing sagas (optional, recommended): - -```bash -npm add -D redux-saga-test-plan -``` - -Package installation does not copy AI skills automatically. In a consumer app, choose the smallest explicit bundle from the decision gate above and use the installed CLI: - -```bash -npx themis install-skills:react -npx themis install-skills:svelte -npx themis install-skills:streaming -npx themis install-skills:core -npx themis install-skills -npx themis help -``` - -The selected files are copied to canonical `.agents/skills/themis/`; a `.claude/skills/themis` compatibility link is created or reused. Repeating the command refreshes package-owned files; collisions and user-authored files are preserved. For the canonical destination, verification, cleanup-before-uninstall, and separate source-checkout maintainer workflow, read [@augmentcode/themis/docs/INSTALLATION.md](@augmentcode/themis/docs/INSTALLATION.md): - -```bash -npm exec -- themis install-skills:svelte -npx themis cleanup-skills -``` - -Then uninstall packages that are no longer needed: - -```bash -npm uninstall @augmentcode/themis -npm uninstall redux redux-saga typed-redux-saga fast-equals # only if your app no longer uses them -``` - -Do not replace consumer commands with the repository's maintainer validation scripts; those are documented separately in `@augmentcode/themis/docs/INSTALLATION.md`. - -## Step 2 — Create the Store and Register App Sagas - -Create `src/lib/store/store.ts`: - -```typescript -/** - * Store Instance - * - * Create a Store with app-owned reducers and register app sagas here. - * Each reducer-map key becomes an app state domain name (e.g., state.counter). - * Export AppState from StoreState after constructing the store. - */ -import { Store } from '@augmentcode/themis/svelte-store'; -import type { StoreState } from '@augmentcode/themis/types'; - -// Import your slice reducers and sagas here: -// import { counterReducer } from './slices/counter/counter-slice'; -// import { counterSaga } from './slices/counter/sagas/counter-saga'; - -export const store = new Store({ - // counter: counterReducer, -}); -export type AppState = StoreState; -``` - -The `Store` class accepts app reducer maps in the constructor. Start app sagas explicitly with `store.runSaga(sagaFn)` after `store.init()` — no separate saga registry files are needed. Constructor reducer maps preserve `StoreState` without an explicit `: Store` annotation. Optional selector scheduling and diagnostics configuration belongs in the third constructor argument, for example `new Store(reducers, undefined, { throttledSelectorFrequency: 64, sagaMonitor: true, traceSelectors: true })`; omit `sagaMonitor` and `traceSelectors` (or pass `false`) to keep both diagnostics disabled, and do not replace Store-owned saga middleware for monitoring. Package-owned slices are mounted by default under reserved `@internal_` domains such as `@internal_storeUtility`; those domains can appear in the inferred type, but application code should not register reducers with that prefix or depend on those internal state shapes directly. - -This setup chooses `Store` readable selector behavior for the app. Do not switch examples or app wiring to `StreamingStore` or Kefir selectors here. - -## Step 3 — Wire the Store into Root Layout - -Edit your root layout (e.g., `src/routes/+layout.svelte`): - -```svelte - - -{@render children()} -``` - -Call `store.init()` during root component initialization and register the returned disposer with `onDestroy`. Under the hood, `store.init()`: +- **Consumer package installation** — package/runtime dependencies, the selected family's peers, and the optional saga-test helper. +- **Explicit skill installation** and **Consumer CLI and bundle selection** — explicit commands, bundle contents, destinations, compatibility links, collision handling, and refresh behavior. +- **Verify, refresh, cleanup, and uninstall** — verification and cleanup-before-uninstall ordering, including preservation of unrelated files/dependencies. +- **Maintainer source-checkout validation** — the separate repository workflow, not a substitute for consumer installation. -- Combines all registered reducers into the root reducer -- Applies the base store middleware chain and saga middleware -- Starts the package saga manager orchestrator -- Creates the Store-owned selector state resources used by Svelte selectors -- Returns a cleanup function that delegates to `store.dispose()` (registered with `onDestroy`) +## Neutral setup checklist -`store.init()` starts the internal saga manager but does **not** start app sagas. Each app saga must be started explicitly by function — call `store.runSaga(counterSaga)` from `onMount` in the layout for mount-scoped cleanup, or keep `const cancel = store.runSaga(counterSaga)` for imperative control. Store derives the manager name from the saga function. Do not call it with `@internal_sagaManager`. For whole-store teardown outside the root-layout `onDestroy(dispose)` pattern, call `store.dispose()` to tear down the initialized Store runtime and stop saga tasks owned by that Store. +Complete this sequence using the linked owners rather than adapting an example from another family: -## Step 4 — Create Your First Slice (Verification) +1. Record the family and target path from **Store-family decision gate**, then follow **Installation workflow**. +2. Design one small canonical slice using **Shared implementation references** below: define types, actions, and reducer before registering it. +3. Construct the chosen Store and wire its initialization/disposal at the app's lifetime boundary using **Family implementation references**. Preserve inferred app state and package-owned internal boundaries from that Store skill. +4. Define Store-bound selectors and consume them using only the chosen family's call-mode and lifecycle skills. Use its composition/test and saga read forms where direct reactive calls do not apply. +5. Register the reducer and start app sagas through the selected family's bootstrap owner. Follow `../core/sagas/SKILL.md` — **Application saga startup** and `../core/saga-manager/SKILL.md` — **Store saga lifecycle** for startup and cancellation; Store initialization is not app-saga registration. +6. Verify an action changes the expected slice, the chosen selector consumer sees the update, the saga handles its trigger, and teardown releases subscriptions/tasks and the Store. Complete **Verification and handoff**. -### 4a. Define the slice +## Family implementation references -Create `src/lib/store/slices/counter/counter-slice.ts`: +Read only the selected row. These leaves own the concrete examples formerly embedded in this setup guide; there is no shared framework-specific working example to copy or translate. -```typescript -import { createAction } from '@augmentcode/themis/utils/store/create-action'; -import { createReducer } from '@augmentcode/themis/utils/store/create-reducer'; - -// --- State --- -export type CounterState = { - count: number; -}; - -const initialState: CounterState = { - count: 0, -}; - -// --- Actions --- -export const increment = createAction('counter/increment'); -export const decrement = createAction('counter/decrement'); -export const reset = createAction('counter/reset'); -export const incrementBy = createAction<[amount: number]>('counter/incrementBy'); - -// --- Reducer --- -export const counterReducer = createReducer(initialState) - .with(increment, (state) => ({ ...state, count: state.count + 1 })) - .with(decrement, (state) => ({ ...state, count: state.count - 1 })) - .with(reset, () => initialState) - .with(incrementBy, (state, action) => ({ - ...state, - count: state.count + action.payload[0], - })); -``` - -### 4b. Define selectors - -Create `src/lib/store/slices/counter/counter-selectors.ts`: - -```typescript -import { store } from '$lib/store/store'; - -/** Read the count value from state */ -export const selectCount = store.createSelector((state) => { - return state.counter.count; -}); - -/** Derived selector — checks if count is positive */ -export const selectIsPositive = store.createSelector((state) => { - return selectCount.select(state) > 0; -}); -``` - -### 4c. Define a saga - -Create `src/lib/store/slices/counter/sagas/counter-saga.ts`: - -```typescript -import { takeEvery, put, delay } from 'typed-redux-saga'; -import { createAction } from '@augmentcode/themis/utils/store/create-action'; -import { increment } from '../counter-slice'; -import { selectCount } from '../counter-selectors'; - -export const logCount = createAction('counter/logCount'); - -function* handleLogCount() { - const count = yield* selectCount.effect(); - console.log(`[Counter] Current count: ${count}`); -} - -export function* counterSaga() { - yield* takeEvery(logCount, handleLogCount); -} -``` - -### 4d. Add the reducer and start the saga - -Update `src/lib/store/store.ts`: - -```typescript -import { counterReducer } from './slices/counter/counter-slice'; -import { counterSaga } from './slices/counter/sagas/counter-saga'; - -export const store = new Store({ counter: counterReducer }); -``` - -`store.init()` does **not** run the saga. Start it explicitly by function in your root layout after `store.init()`: - -```svelte - - - -{@render children()} -``` - -For non-component code (services, tests, IPC handlers), use `store.runSaga(counterSaga)` instead — it returns a cancel function that stops the saga. When the whole Store lifetime ends, call `store.dispose()` or the disposer returned by `store.init()` to tear down the initialized Store runtime and stop Store-owned running saga tasks. - -### 4e. Use in a component - -Create or edit a page component (e.g., `src/routes/+page.svelte`): - -```svelte - - -

Count: {$count}

-

Is positive: {$isPositive}

- - - - - -``` - -## Step 5 — Verify the Chosen Family Setup - -1. **Run the app or target owner** (`npm run dev`, the server/worker/CLI entry, or the focused test harness) and confirm initialization happens before selector reads. -2. **Dispatch through the chosen Store instance** and confirm the counter changes. -3. **Start app sagas explicitly after init.** Click or invoke the `logCount` action and confirm `[Counter] Current count: 1` appears. -4. **Check family-specific selector behavior:** - - Svelte: component-init `selectCount()` returns a readable used as `$count`; optional browser inspection uses `window.svelteRedux.reduxContext.state` after `store.initDevTool()`. - - React: direct `selectCount()` returns a signal value for React components/custom hooks to read where supported; `selectCount.useValue()` is only a plain-value fallback, not a Svelte readable or Kefir observable. - - Streaming: `selectCount()` returns a Kefir observable after `streamStore.init()`; observers receive updates and unsubscribe during teardown. -5. **Search for family mixing before handoff.** The same app/package/code path should not import or demonstrate multiple concrete Store families. - -## Step 6 — Svelte Debugging Tools (`window.svelteRedux`) - -If you register devtools with `store.initDevTool()` after `store.init()`, the initialized Store instance is exposed on `window.svelteRedux`: - -| Console Command | Effect | -| --- | --- | -| window.svelteRedux.reduxContext | Access the initialized Store instance (state, dispatch) | - -### Inspecting state from the console: - -```javascript -// Get current state -window.svelteRedux.reduxContext.state - -// Dispatch an action manually -window.svelteRedux.reduxContext.dispatch({ type: 'counter/increment', payload: undefined }) - -// Read the current Store state snapshot -window.svelteRedux.reduxContext.state -``` - -## Quick Reference - -### Key Patterns - -| Pattern | Import From | Usage | +| Family | Construction and app lifetime | Selector authoring and consumption | | --- | --- | --- | -| Create action (no payload) | @augmentcode/themis/utils/store/create-action | createAction('slice/name') | -| Create action (with payload) | @augmentcode/themis/utils/store/create-action | createAction<[string, number]>('slice/name') | -| Create reducer | @augmentcode/themis/utils/store/create-reducer | createReducer(init).with(action, handler) | -| Create app selector | configured chosen Store instance | store.createSelector((state) => state.slice.field) | -| Selector in Svelte component | Svelte Store branch only | const val = selectFoo() → {$val} | -| Selector in React component/custom hook | React Store branch only | const val = selectFoo() → ReadonlySignal; use selectFoo.useValue() only when a plain value is necessary | -| Selector in streaming consumer | Streaming Store branch only | const val$ = selectFoo() → Kefir observable | -| Selector in event handler | imported/captured initialized Store instance | selectFoo.select(store.state) | -| Selector in saga | — | yield* selectFoo.effect() | -| Dispatch in component/consumer | configured chosen Store instance | store.dispatch(action) | -| Dispatch outside UI | existing initialized chosen Store instance | store.dispatch(action) | +| Svelte Store | `../svelte/store/SKILL.md` — **Correct import and class choice**, **Lifecycle rules**, **App saga lifetime**; `../svelte/component-integration/SKILL.md` — **Root layout wiring**, **Template and handler wiring** | `../svelte/selectors/SKILL.md` — **Choose the factory**, **Examples**; `../svelte/selector-lifecycle/SKILL.md` — **Call-mode map** | +| React Store | `../react/store/SKILL.md` — **Correct import and class choice**, **Lifecycle rules**; `../react/component-integration/SKILL.md` — **Create and configure ReactStore**, **Initialize before React renders selector users**, **Dispose at the same owner boundary**, **Start app sagas explicitly**, **Component reads and dispatch** | `../react/selectors/SKILL.md` — **Authoring rules**, **Selector caching**; `../react/selector-lifecycle/SKILL.md` — **Call-mode map** | +| Streaming Store | `../streaming/store/SKILL.md` — **Correct import and class choice**, **Lifecycle rules**, **Process bootstrap** | `../streaming/selectors/SKILL.md` — **Authoring rules**, **Call forms**, **Selector caching**; `../streaming/selector-lifecycle/SKILL.md` — **Lifecycle map**, **Consumer subscription ownership** | -### Slice File Conventions +## Shared implementation references -The user creates their own slice files under `src/lib/store/slices//` — these are your application code, not package source. Import builder APIs such as `createAction` and `createReducer` from their explicit utility leaf subpaths (`@augmentcode/themis/utils/store/create-action` and `@augmentcode/themis/utils/store/create-reducer`); import the configured app Store instance (`store`, `reactStore`, or `streamStore`) and use that instance for `createSelector(...)` and `dispatch(...)`. +Use these framework-neutral owners for the slice/action/reducer/saga portion of setup: -``` -src/lib/store/slices// -├── -slice.ts # State type, actions, reducer -├── -selectors.ts # Selectors -├── -types.ts # Shared types (optional, for cross-process use) -└── sagas/ - └── -saga.ts # Side effects -``` - -### Rules - -- **Reducers must be pure** — no side effects, no mutations, return new objects. -- **State must be serializable** — no `Date`, `Map`, `Set`, `RegExp`, `Promise`, `Function`. -- **Actions use **`sliceName/actionName` naming convention. -- **Use the chosen family call mode only**: Svelte selector readables at component init, React direct selector signals in components/custom hooks with `.useValue(...args)` only as a necessary plain-value fallback, or Streaming Kefir observables in non-UI/observable consumers. Dispatch through the configured Store instance. -- **Do not mix Store families** inside one app/package/code path. -- **Side effects go in sagas**, not components. -- **Use **`Collection` for entity storage, never plain arrays of objects. \ No newline at end of file +| Setup concern | Canonical instruction and examples | +| --- | --- | +| State/effect ownership and custom Redux APIs | `../core/core-policy/SKILL.md` — **Setup — core rules**, **When to use Redux vs component-local state** | +| App-owned slice files, separate type modules, reducer registration, and action namespaces | `../core/file-structure/SKILL.md` — **Setup — slice directory layout**, **Register a normal slice**, **Naming conventions** | +| Public builder imports and configured Store imports | `../core/import-boundaries/SKILL.md` — **Setup — the package export surface** | +| No-payload and tuple-payload actions | `../core/actions/SKILL.md` — **No-payload action for explicit events**, **Tuple payload action consumed by reducers** | +| Pure immutable chained reducers | `../core/reducers/SKILL.md` — **Do**, **Chain handlers on the reducer function** | +| Canonical facts, derived selectors, and entity Collections | `../core/state-integrity/SKILL.md` — **MUST / NEVER rules**; `../core/collections/SKILL.md` — **Shape and imports** | +| Serializable initial state and payloads | `../core/state-serialization/SKILL.md` — **Do** and **Don't** | +| Saga watchers, named selector effects, and app-saga cancellation | `../core/sagas/SKILL.md` — **Do**, **Examples**, **Application saga startup**; `../core/saga-manager/SKILL.md` — **Store saga lifecycle** | + +## Optional diagnostics and scheduling + +Configure diagnostics only for the selected Store instance; shared contracts do not require switching families. + +- Selector diagnostics and constructor options: `../core/selector-tracing/SKILL.md` — **Configure the Store** and **Scope and safety rules**. Shared streams and custom logging: `../core/redux-action-logging/SKILL.md` — **Store-owned logging streams** and **Logger factory lifecycle**. +- Saga monitoring and Store-owned middleware: `../core/sagas/SKILL.md` — **Do** and **Don't**. +- Selector scheduling, only for the chosen family: `../svelte/selector-scheduling/SKILL.md` — **Scheduling options**; `../react/selector-scheduling/SKILL.md` — **Store-first scheduling rule**; or `../streaming/selectors/SKILL.md` — **Call forms**. +- Optional browser Store inspection (when that hook is applicable): `../core/debugging/SKILL.md` — **Inspect state from the console** and **Setup — minimum working inspection**. This is not a prerequisite for non-browser setup. + +## Verification and handoff + +- Run the target app/process or focused test harness. Verify one end-to-end dispatch, selector update, and saga trigger using the selected family leaves' **Verification cues**; include the teardown path. +- Use `../core/testing/SKILL.md` — **Layer rules** for reducer, pure-selector, and saga checks, and **Saga test setup cues** for the optional test helper. +- Follow `../core/state-integrity/SKILL.md` — **Automated architecture gate workflow** for the repository/app gate appropriate to the target. +- Search for multiple concrete Store imports and mismatched selector consumption in the same app/package/code path. Record the routing evidence, canonical skills read, tests run, and lifecycle owner. Separate apps may select different families; a single app may not. \ No newline at end of file diff --git a/skills/streaming/SKILL.md b/skills/streaming/SKILL.md index fa567e7..b2f581b 100644 --- a/skills/streaming/SKILL.md +++ b/skills/streaming/SKILL.md @@ -53,28 +53,20 @@ Use this root for Streaming-specific `themis` work and as the default package ro | Route | Use when | | --- | --- | -| ./store/SKILL.md | Choosing/importing StreamingStore, initialization/disposal, and inherited Store runtime behavior. | +| ./store/SKILL.md | Choosing/importing StreamingStore and owning construction, process bootstrap, initialization, and whole-Store disposal. | | ./selectors/SKILL.md | Authoring Store-bound selectors whose direct calls return cached Kefir Observable values, including observable selector arguments, .withStore, .select, and .effect. | -| ./selector-lifecycle/SKILL.md | Deciding when streaming selectors may be invoked/observed and how init()/dispose() affect stream state. | +| ./selector-lifecycle/SKILL.md | Direct-call validity, explicit Store binding, and consumer subscription timing/teardown; not Store construction or runtime disposal implementation. | First-time app setup starts at the canonical root setup skill: `../setup/SKILL.md`. -## Routing rules +## Operational guidance owners -- Use `StreamingStore` only from `@augmentcode/themis/streaming-store`. -- Treat this app/code path as Streaming-only; do not add alternate Store lifecycle setup to the same app. -- Create production app-local streaming selectors through the configured `StreamingStore` instance: `streamStore.createSelector(...)`. -- Direct selector calls return Kefir observables; use them through the consuming app's observable subscription pattern. -- Direct Kefir `Observable` outputs are cached for the same StreamingStore instance + selector + args; prefer the same selector + args over manual stream passing where valid. -- Streaming selector emissions use the configured Store `throttledSelectorFrequency` policy, defaulting to 64 FPS; rapid Store or observable argument bursts coalesce to the latest pending selector value. -- Selector trace output is disabled by default; pass `{ traceSelectors: true }` in the final StreamingStore options object only for temporary diagnostics. -- `StreamingStore.traceStreams` is the same frozen, read-only Kefir stream collection exposed by every Store family: `selectorDetail`, `selectorSummary`, `selectorCadence`, `sagaMonitor`, `runtimeError`, and `reduxAction`. Observe these public streams; never import or publish through internal emitters. -- With `logReduxActions: true`, pure middleware publishes one immutable `reduxAction` event after successful `next(action)`; StoreRuntime's default logger renders the legacy grouped action/state output. The event retains action and state references, so redact sensitive values before sharing logs. -- The built-in console logger is attached by default. Pass a typed `loggerFactory` in the final options object to receive all six streams and optionally return a disposer; custom factories replace, rather than augment, default console logging. -- Set `traceSelectors: { summaryEnabled: true, summaryIntervalMs: ... }` to allocate and publish selector summaries. `summaryEnabled` is the sole summary switch; detailed selector flags remain independent. -- `.select(state, ...args)` stays the pure selector path for tests/composition. -- `.effect(...args)` stays the typed-redux-saga path for saga reads. -- Core saga guidance, selector-channel helpers, `waitFor`, reducers, actions, and state policy remain in `../core/`. +- For the public class import and process lifetime, read `./store/SKILL.md` — **Correct import and class choice**, **Lifecycle rules**, and **Process bootstrap**. +- For Store-bound creation, observable arguments, direct/pure/saga call forms, scheduling, and output reuse, read `./selectors/SKILL.md` — **Authoring rules**, **Call forms**, **Selector caching**, and **Stable selector arguments**. This router does not duplicate the selector contract. +- For invocation validity and observer cleanup, read `./selector-lifecycle/SKILL.md` — **Lifecycle map** and **Consumer subscription ownership**. +- Streaming diagnostics use the shared contracts: `../core/selector-tracing/SKILL.md` — **Configure the Store**, **Aggregate summaries**, and **Store-family symmetry and lifecycle**. These own trace defaults/options, summary allocation, and selector-resource disposal. +- For the public stream inventory/types, custom logger replacement and cleanup, Redux middleware event ordering, immutable payloads, rendering, and privacy, read `../core/redux-action-logging/SKILL.md` — **Store-owned logging streams**, **Logger factory lifecycle**, **Read one action's group**, and **Keep logging opt-in and temporary**. +- For Redux/saga behavior rather than Kefir consumption, use `../core/SKILL.md` — **Core leaf routes**; selector-driven saga subscriptions specifically belong to `../core/selector-channels/SKILL.md` — **Do** and **Implementation cues**. ## Verification cues diff --git a/skills/streaming/selector-lifecycle/SKILL.md b/skills/streaming/selector-lifecycle/SKILL.md index eb4ffa4..30e6218 100644 --- a/skills/streaming/selector-lifecycle/SKILL.md +++ b/skills/streaming/selector-lifecycle/SKILL.md @@ -3,8 +3,8 @@ name: streaming/selector-lifecycle description: >- Lifecycle guidance for StreamingStore selectors: selectors may be defined from the configured StreamingStore, but direct observable calls require init(); - dispose() tears down the Store-owned stream state. Use this for - stream observation timing and withStore(streamStore) bindings. + direct calls are invalid after disposal. Owns consumer subscription timing and + withStore(streamStore) bindings, not Store construction or runtime teardown. type: sub-skill requires: - streaming @@ -16,7 +16,7 @@ sources: triggers: - stream selector lifecycle - observe selector stream - - selector before init + - streaming selector before init - streaming selector teardown - withStore stream --- @@ -26,6 +26,8 @@ Use this skill to decide when a Streaming selector can be invoked, observed, or Use it only for apps that chose the Streaming Store family. Do not combine these Streaming lifecycle/setup rules with alternate Store or selector lifecycle/setup patterns in the same app. +For construction, initialization, and whole-Store shutdown, read `../store/SKILL.md` — **Lifecycle rules** and **Process bootstrap**. This leaf owns when consumers may call and observe selectors, not how the Store runtime is initialized or disposed. + ## Lifecycle map | Phase/context | Correct action | Why | @@ -36,19 +38,33 @@ Use it only for apps that chose the Streaming Store family. Do not combine these | Tests/composition | Use selectFoo.select(state, ...args) | Pure synchronous selector path, no Store lifecycle needed. | | Sagas | Use yield* selectFoo.effect(...args) | Keeps named selector ownership and typed-redux-saga style. | | Explicit StreamingStore binding | Use selectFoo.withStore(streamStore)(...args) | Binds to another initialized StreamingStore. | -| Teardown | Stop consumers and call streamStore.dispose() when the owner ends | dispose() clears Store-owned stream state and shared runtime resources. | +| Consumer teardown | Unsubscribe each consumer when its owner ends, before whole-Store shutdown | Consumer ownership is separate from the Store lifetime in ../store/SKILL.md — **Lifecycle rules**. | ## Operational guardrails - Direct selector calls intentionally throw before `init()` and after `dispose()`; do not hide that error with fallback empty streams. - A direct selector call returns a Kefir observable. Manage observation/teardown using the consuming app's Kefir subscription pattern. -- Same StreamingStore + selector + args direct calls reuse the cached Kefir Observable, but only call direct observable mode after `init()` or through a valid `.withStore(...)` binding. +- Output reuse does not extend the valid invocation window; see `../selectors/SKILL.md` — **Selector caching** for the identity/cache contract. - `.withStore(...)` accepts another initialized StreamingStore; use it for tests/integration adapters that own their own initialized Store state. -- `.select(...)` and `.effect(...)` are not streaming subscriptions. They are the pure read and saga read escape hatches for selector composition and saga reads. -- Selector-channel helpers can consume StreamingStore selectors in sagas through - the shared `.select`/`.effect` read shape. Pass plain args to the helper args - tuple; do not treat direct Kefir Observable selector outputs as saga - subscriptions. +- For non-subscription read forms, use `../selectors/SKILL.md` — **Call forms**. Selector-driven saga subscriptions instead follow `../../core/selector-channels/SKILL.md` — **Do** and **Implementation cues**, including the plain-argument tuple and saga-context state contract. + +## Consumer subscription ownership + +The process bootstrap in `../store/SKILL.md` — **Process bootstrap** initializes +the Store before starting consumers. Each Kefir consumer retains its own +subscription and stops it before that Store owner shuts down. For example, an +app-owned `startConsumers` function can return the observer's cleanup: + +```ts +function startConsumers() { + const subscription = selectTodoCount().observe(handleTodoCount); + return () => subscription.unsubscribe(); +} +``` + +Use the same ownership pattern for direct public diagnostic-stream observations; +the logger's own lifecycle remains in `../../core/redux-action-logging/SKILL.md` — +**Logger factory lifecycle** and **Store-owned logging streams**. Do not import internal emitters to subscribe. ## Verification cues diff --git a/skills/streaming/selectors/SKILL.md b/skills/streaming/selectors/SKILL.md index 61676a0..bc053b4 100644 --- a/skills/streaming/selectors/SKILL.md +++ b/skills/streaming/selectors/SKILL.md @@ -34,8 +34,8 @@ lifecycle/setup guidance here. - Create selectors through the configured `StreamingStore` instance. - Keep this app on the Streaming Store family and keep alternate frontend patterns isolated to separate app/code paths. -- Keep selector callbacks pure and derived-only; reducers must not store selector - outputs. +- For pure derived callbacks and canonical state ownership, follow + `../../core/state-integrity/SKILL.md` — **MUST / NEVER rules**. - Compose selectors with `.select(state, ...args)` inside another selector. - Keep generic selector helper modules Store-parameterized: accept a configured store and call `store.createSelector(...)` at the integration boundary. @@ -53,10 +53,12 @@ export const streamStore = new StreamingStore({ todos: todosReducer }); export const selectTodoCount = streamStore.createSelector((state) => { return state.todos.collection.ids.length; }); - -const todoCount$ = selectTodoCount(); ``` +This module defines selectors without reading stream state. Before invoking +them, follow `../selector-lifecycle/SKILL.md` — **Lifecycle map**; for observation +and cleanup examples use **Consumer subscription ownership** in that same skill. + ## Call forms | Context | Use | Result | @@ -72,15 +74,15 @@ Streaming selector outputs are throttled by the owning Store's `throttledSelectorFrequency` option, defaulting to `64` FPS. The first selector value remains prompt; subsequent rapid Store updates or observable argument updates within a throttle interval are omitted/coalesced, and the latest pending -value emits at the scheduled moment. Selector trace output is disabled by -default; pass `{ traceSelectors: true }` in the final Store options object only -for temporary diagnostics. +value emits at the scheduled moment. For opt-in diagnostics and constructor +options, read `../../core/selector-tracing/SKILL.md` — **Scope and safety rules** +and **Configure the Store** rather than adding a Streaming-specific trace policy. -Selector-channel helpers that consume `.select`/`.effect`-compatible selectors -run in sagas and support `StreamingStore` selectors through the shared selector -read shape. Pass plain stable selector arguments as the helper args tuple; -selector-channel effects read Redux state from saga context and do not subscribe -to direct Kefir `Observable` selector outputs. +Selector-channel helpers use the shared selector read shape, not direct Kefir +outputs. Their saga-context subscription behavior and plain stable argument +tuples are owned by `../../core/selector-channels/SKILL.md` — **Do** and +**Implementation cues**; one-shot waits belong to `../../core/wait-for/SKILL.md` +— **Do**. Saga read conventions belong to `../../core/sagas/SKILL.md` — **Do**. ## Selector caching @@ -139,6 +141,6 @@ export const selectTodo = streamStore.createSelector((state, id: string) => { ## See also -- `streaming/store/SKILL.md` — Store class and import choice. -- `streaming/selector-lifecycle/SKILL.md` — invocation and teardown timing. -- `core/state-integrity/SKILL.md` — canonical derived-value ownership. +- `../store/SKILL.md` — **Correct import and class choice**. +- `../selector-lifecycle/SKILL.md` — **Lifecycle map** for invocation and teardown timing. +- `../../core/state-integrity/SKILL.md` — **MUST / NEVER rules** for derived-value ownership. diff --git a/skills/streaming/store/SKILL.md b/skills/streaming/store/SKILL.md index c9bd485..6e8325b 100644 --- a/skills/streaming/store/SKILL.md +++ b/skills/streaming/store/SKILL.md @@ -3,7 +3,8 @@ name: streaming/store description: >- StreamingStore import, initialization, disposal, and Store-runtime guidance for the Kefir/observable Store variant. Use for @augmentcode/themis/streaming-store, - inherited runSaga/dispatch/state behavior, and observable selector lifecycle. + process bootstrap and whole-Store ownership. Selector call validity and consumer + subscriptions belong to streaming/selector-lifecycle. type: sub-skill requires: - streaming @@ -15,7 +16,7 @@ sources: triggers: - StreamingStore - streaming-store import - - Store state lifecycle + - Streaming Store state lifecycle - Kefir Store - observable Store --- @@ -32,37 +33,47 @@ This is Streaming Store family guidance. For the same app/package/code path, do ```ts import { StreamingStore } from "@augmentcode/themis/streaming-store"; +import type { StoreState } from "@augmentcode/themis/types"; export const streamStore = new StreamingStore({ todos: todosReducer }); -const dispose = streamStore.init(); +export type AppState = StoreState; ``` ## Lifecycle rules -- Construct `StreamingStore` with app-owned reducers and optional middleware, then call `streamStore.init(initialState?)` before invoking streaming selector calls. -- Streaming selector calls return Kefir `Observable` outputs backed by the Store-owned Kefir state stream after initialization and throw before `init()` or after `dispose()`. -- `streamStore.dispatch`, `streamStore.state`, `streamStore.runSaga(sagaFn)`, and `streamStore.dispose()` follow the shared Store runtime behavior documented in core Store guidance. -- Do not manually register package-owned `@internal_` reducers or internal sagas. +- The process/server/worker/test owner constructs the `StreamingStore` with app-owned reducers and optional middleware/options, calls `streamStore.init(initialState?)`, and retains the returned disposer for that owner's shutdown. +- Initialization creates the Redux runtime and its Store-owned Kefir state source. The returned disposer delegates to `streamStore.dispose()` for whole-Store teardown. Selector invocation errors and observation timing are owned by `../selector-lifecycle/SKILL.md` — **Lifecycle map** and **Operational guardrails**. +- Preserve constructor inference with `StoreState` rather than widening the configured instance. For reducer registration, state shape, and reserved package-owned domains, read `../../core/file-structure/SKILL.md` — **Register a normal slice** and `../../core/saga-manager/SKILL.md` — **Store saga lifecycle**. +- Use the initialized instance for `streamStore.state` and `streamStore.dispatch`; action/async-dispatch semantics belong to `../../core/actions/SKILL.md` — **Await dispatch when the caller needs the result**. App-saga placement belongs to `../../core/sagas/SKILL.md` — **Application saga startup**; startup, cancellation, and internal-manager boundaries belong to `../../core/saga-manager/SKILL.md` — **Store saga lifecycle**. + +## Process bootstrap + +Keep initialization and shutdown at one non-UI owner boundary. Application-specific saga and observer logic stay in their own modules; this example assumes `todosSaga` and `startConsumers` are app-owned functions. + +```ts +const dispose = streamStore.init(); +const cancelTodosSaga = streamStore.runSaga(todosSaga); +const stopConsumers = startConsumers(); +// When the process/test owner ends: +stopConsumers(); +cancelTodosSaga(); +dispose(); +``` + +For the Kefir subscription implementation behind `startConsumers`, read `../selector-lifecycle/SKILL.md` — **Consumer subscription ownership**. Store initialization does not start app sagas; follow `../../core/saga-manager/SKILL.md` — **Store saga lifecycle** for their explicit registration. ## Logging and tracing streams -`StreamingStore.traceStreams` is a frozen, read-only collection of Kefir -observables shared symmetrically with `Store` and `ReactStore`: `selectorDetail`, -`selectorSummary`, `selectorCadence`, `sagaMonitor`, `runtimeError`, and -`reduxAction`. Import -`StoreTraceStreams` and `StoreLoggerFactory` from -`@augmentcode/themis/types` when annotating a custom logger. The default logger -subscribes to these streams and writes the established console prefixes. Redux -action middleware is a pure event producer; StoreRuntime owns the default -legend/group rendering. A custom `loggerFactory` replaces it and may return a -disposer. - -Use `summaryEnabled: true` in the flat `traceSelectors` options object to opt -into selector summary allocation. `summaryIntervalMs` controls publication -cadence; detailed trace flags do not allocate summaries. Dispose direct Kefir -subscriptions before `streamStore.dispose()`. Store disposal stops the logger, -summary interval, selector cadence resources, and other Store-owned tracing -resources; a later successful `init()` reattaches the configured logger. +StreamingStore uses the cross-family diagnostics contract, not a separate Kefir +logging API. Read `../../core/selector-tracing/SKILL.md` — **Configure the Store**, +**Aggregate summaries**, and **Store-family symmetry and lifecycle** for tracing +options, summary allocation, and selector-resource cleanup. +Read `../../core/redux-action-logging/SKILL.md` — **Store-owned logging streams**, +**Logger factory lifecycle**, **Read one action's group**, and **Keep logging +opt-in and temporary** for the read-only stream inventory/types, logger +replacement/disposer and reinitialization, middleware events, and rendering. Direct Kefir consumers +follow `../selector-lifecycle/SKILL.md` — **Consumer subscription ownership**; +they do not take over Store-owned logger or timer cleanup. ## Streaming family boundary @@ -73,10 +84,11 @@ resources; a later successful `init()` reattaches the configured logger. - Imports use `@augmentcode/themis/streaming-store` for `StreamingStore`. - Examples and docs do not describe `Store` as a streaming API. -- Streaming selector usage is initialized before observation, or tests explicitly assert the pre-init error path. +- Process/test shutdown invokes the Store disposer and the app-owned saga cancel functions. +- Selector usage follows `../selector-lifecycle/SKILL.md` — **Verification cues**, including error-path tests when applicable. ## See also -- `streaming/selectors/SKILL.md` — Kefir selector return model. -- `streaming/selector-lifecycle/SKILL.md` — safe invocation/teardown timing. -- `core/import-boundaries/SKILL.md` — public package import surface. \ No newline at end of file +- `../selectors/SKILL.md` — **Call forms** for the Kefir selector return model. +- `../selector-lifecycle/SKILL.md` — **Lifecycle map** for invocation/teardown timing. +- `../../core/import-boundaries/SKILL.md` — **Setup — the package export surface**. \ No newline at end of file From 641bfef8d6ec501dab2ae8e2e6f6657f925e4800 Mon Sep 17 00:00:00 2001 From: Dmitriy Kharchenko Date: Fri, 18 Sep 2026 14:19:47 -0700 Subject: [PATCH 10/13] docs(skills): consolidate Svelte ownership and migration guidance Agent-Id: agent-96f2be33-9fa4-4945-9709-dcd1e0c6d3d6 --- skills/svelte/SKILL.md | 42 ++-- skills/svelte/component-integration/SKILL.md | 224 ++++-------------- skills/svelte/migration/SKILL.md | 220 ++++------------- skills/svelte/migration/assessment/SKILL.md | 62 ++--- skills/svelte/migration/cleanup/SKILL.md | 31 ++- .../migration/component-migration/SKILL.md | 49 ++-- .../svelte/migration/derived-stores/SKILL.md | 36 +-- skills/svelte/migration/setup/SKILL.md | 167 ++++--------- skills/svelte/migration/side-effects/SKILL.md | 26 +- .../svelte/migration/writable-stores/SKILL.md | 151 +++--------- skills/svelte/selector-lifecycle/SKILL.md | 43 ++-- skills/svelte/selector-scheduling/SKILL.md | 128 ++-------- skills/svelte/selectors/SKILL.md | 64 ++--- skills/svelte/store/SKILL.md | 29 ++- 14 files changed, 382 insertions(+), 890 deletions(-) diff --git a/skills/svelte/SKILL.md b/skills/svelte/SKILL.md index dfaa9b8..faae703 100644 --- a/skills/svelte/SKILL.md +++ b/skills/svelte/SKILL.md @@ -16,9 +16,9 @@ triggers: - Svelte readable - Svelte selector readable - Store component wiring - - selector lifecycle + - Svelte selector lifecycle - Svelte store migration - - component integration + - Svelte component integration --- # Svelte-readable routing index @@ -78,12 +78,12 @@ Before editing code or docs under this skill: ## Always-on policy -- Redux owns shared/domain state; Svelte stores (`*.store.svelte.ts`) are deprecated. +- Redux owns shared/domain state; shared Svelte stores (`*.store.svelte.ts`) are deprecated. Ephemeral instance-local UI state remains local per `../core/core-policy/SKILL.md` → **Setup — core rules**. - A Svelte app uses `Store` and Svelte-readable selectors for its Store-backed component reads. - Redux state is canonical only: no derived fields, duplicated entity copies, parallel arrays/maps for the same records, or reducer-maintained selector outputs. -- Components render and dispatch; reducers update state; sagas own side effects. +- Components render and dispatch; reducers update state; sagas own domain side effects. DOM-local focus, scroll, measurements, and widget lifecycle stay in components per `../core/core-policy/SKILL.md` → **Setup — core rules**. - State must stay serializable; normalized object collections use `Collection`. - Actions, selectors, and sagas each have one canonical owner/implementation; run the state/action/selector/saga preflight searches before adding another. @@ -161,28 +161,14 @@ const componentRouting = { }; ``` -### Store-first dispatch and state reads +### Route Store-first dispatch and state reads -```ts -import { Store } from "@augmentcode/themis/svelte-store"; -import { renameTodo } from "./slices/todos/todos-actions"; -import { todosReducer } from "./slices/todos/todos-slice"; -import { selectTodo } from "./slices/todos/todos-selectors"; -import { todosSaga } from "./slices/todos/sagas/todos-saga"; - -const store = new Store({ todos: todosReducer }); -const dispose = store.init(); -const cancelTodosSaga = store.runSaga(todosSaga); - -store.dispatch(renameTodo("todo-1", "Ship docs")); -const selectedTodo = selectTodo.select(store.state, "todo-1"); - -cancelTodosSaga(); -dispose(); -selectedTodo?.id satisfies string | undefined; -``` - -`Store` is the canonical Svelte-readable class from `@augmentcode/themis/svelte-store`. +Use `./store/SKILL.md` → **Correct import and class choice**, **Lifecycle rules**, +and **App saga lifetime** for construction, initialization, and cleanup. Use +`./selector-lifecycle/SKILL.md` → **Call-mode map** for direct state reads and +`./component-integration/SKILL.md` → **Template and handler wiring** for dispatch +through the configured Store. This index routes those procedures instead of +maintaining a second lifecycle implementation. ### Verification handoff evidence payload @@ -227,7 +213,7 @@ const incompleteRouting = { | --- | --- | --- | | `../core/actions/SKILL.md` | Creating custom `createAction` or `createAsyncAction` actions. | `../core/actions/SKILL.md` | | `../core/reducers/SKILL.md` | Building immutable chained reducers and no-op reference equality behavior. | `../core/reducers/SKILL.md` | -| `./selectors/SKILL.md` | Creating Store-bound selectors, cached Svelte readable direct outputs, collection utility reads, `.select`, or `.effect` usage. | `./selectors/SKILL.md` | +| `./selectors/SKILL.md` | Authoring/composing Store-bound selectors, collection reads, cache contracts, and stable arguments. | `./selectors/SKILL.md` | ### Selector system @@ -235,7 +221,7 @@ const incompleteRouting = { | --- | --- | --- | | `./selector-lifecycle/SKILL.md` | Choosing component-init, handler, or saga selector call modes; using Store-first dispatch. | `./selector-lifecycle/SKILL.md` | | `../core/selector-channels/SKILL.md` | Reacting to selector value changes from sagas or creating selector-backed channels. | `../core/selector-channels/SKILL.md` | -| `./selector-scheduling/SKILL.md` | Recognizing cached readable output reuse and selector emission scheduling as internal details; do not import removed throttled-readable helpers. | `./selector-scheduling/SKILL.md` | +| `./selector-scheduling/SKILL.md` | Tuning FPS/coalescing and preventing extra scheduler layers or event-log assumptions. | `./selector-scheduling/SKILL.md` | | `../core/wait-for/SKILL.md` | Suspending sagas until selector predicates pass or time out. | `../core/wait-for/SKILL.md` | ### Sagas and side effects @@ -260,7 +246,7 @@ const incompleteRouting = { | Route | Use when | Path | | --- | --- | --- | | `./store/SKILL.md` | Choosing/importing `Store`, initialization/disposal, `useInitStore`/`useRunSaga` helpers, and shared Store runtime behavior. | `./store/SKILL.md` | -| `./component-integration/SKILL.md` | Wiring Store initialization, component reads, Store dispatch, and template reactivity. | `./component-integration/SKILL.md` | +| `./component-integration/SKILL.md` | Applying Store and selector lifecycle contracts to root layouts, templates, and handlers. | `./component-integration/SKILL.md` | ### Testing, debugging, and verification diff --git a/skills/svelte/component-integration/SKILL.md b/skills/svelte/component-integration/SKILL.md index f4eb7f1..caa5c98 100644 --- a/skills/svelte/component-integration/SKILL.md +++ b/skills/svelte/component-integration/SKILL.md @@ -1,25 +1,23 @@ --- name: svelte/component-integration description: >- - Wire the Store class into a Svelte 5 app. Create a Store instance with - constructor reducer maps, configure optional middleware, call - store.init() + onDestroy in the root layout, dispatch through the configured - Store instance, and use selectFoo() at component init with $selectorResult$ - in templates. Start app sagas through store.runSaga(sagaFn). Public API: - @augmentcode/themis/svelte-store; related guidance: ../SKILL.md §7. + Wire a configured Store into Svelte root layouts, templates, and handlers. + Apply svelte/store lifecycle contracts and svelte/selector-lifecycle call + modes; this leaf owns component wiring, not Store API or selector policy. type: sub-skill library: themis requires: - svelte + - svelte/store - svelte/selector-lifecycle sources: - "@augmentcode/themis/svelte-store" - - ../SKILL.md + - ../store/SKILL.md + - ../selector-lifecycle/SKILL.md triggers: - - store init layout - - Store dispatch - - component wiring - - $selector$ template + - Svelte layout wiring + - Svelte component wiring + - Svelte $selector$ template --- # Component Integration — Store-first setup and dispatch @@ -28,54 +26,15 @@ triggers: This is Svelte Store family guidance for Svelte component initialization, readable selector binding, and Store dispatch. -## 1. `Store` class +## Store contract handoff -From `@augmentcode/themis/svelte-store`: +Before wiring a layout, read `../store/SKILL.md` → **Correct import and class choice**, +**Lifecycle rules**, **App saga lifetime**, and **Svelte component lifecycle helpers**. +That owner covers constructor maps, state inference, middleware ordering, internal +domains, init/dispose, devtools registration, and saga cancellation. Selector +factory contracts live in `../selectors/SKILL.md` → **Choose the factory**. -```typescript -export class Store< - TStateMap extends StoreStateMap = {}, - TReducers extends StoreReducersInput = StoreReducersInput, -> { - constructor(reducersMap?: TReducers, middleware?: StoreMiddleware | StoreMiddleware[]); - addMiddleware(middleware: StoreMiddleware | StoreMiddleware[]): void; - getReducers(): StoreReducersMap; - get state(): StoreState; - get dispatch(): Store["dispatch"]; - createSelector( - selectorFunc: StoreSelectorCallback> - ): StoreSelector>; - - // Initialize Store-owned Redux/readable state, bind the saga manager orchestrator, - // and return a disposer equivalent to store.dispose(). Does NOT start app sagas. - // If a store context already exists, returns a noop. - init(initialState?: PreloadedStoreState): () => void; - - // Explicitly register the initialized Store on the devtools hook. - initDevTool(): () => void; - - // Tear down the initialized Store runtime and stop Store-owned saga tasks. - // Safe to call before init(); equivalent to the init() returned disposer. - dispose(): void; - - // Start a saga function. Returns a cancel function that stops it. - // Throws if init() has not been called or the derived name is reserved. - runSaga(saga: Saga): () => void; -} -``` - -Key rules: - -- Pass app-owned reducers in the constructor map and start app-owned sagas with `store.runSaga(sagaFn)` after `store.init()`. -- Use `Store` from `@augmentcode/themis/svelte-store` for this app. -- For typed state, infer `StoreState` from the configured Store instance. Constructor reducer maps preserve reducer-state inference without an explicit `: Store` annotation. -- Use `store.createSelector(...)` for app-local selectors that should infer that configured store's `StoreState`; generic/shared selector helpers should accept a configured Store instead of importing standalone selector creation utilities. -- Register only app-owned reducers in constructor maps. `Store` manages package-owned internals under reserved `@internal_` names: reducers such as `@internal_storeUtility` are package-managed, and the internal saga manager starts during `Store` initialization. Internal reducer domains can appear in `StoreState`. Consumers should not add `@internal_` reducers/sagas or depend on internal state paths directly. -- Custom middlewares are **prepended** before the base store middleware chain. -- `init()` builds the Redux store/readable state and starts the package-owned manager. App sagas are **not** started automatically — start each one explicitly via `store.runSaga(sagaFn)`, usually from `onMount` in a component/layout. It derives the manager name from the saga function and rejects direct `@internal_sagaManager` usage. -- `initDevTool()` is a separate, explicit devtools registration step after `init()` when inspection hooks are needed; `dispose()` cleans up that registration along with Store-owned tasks. - -## 2. Setup — root layout bootstrap +## Root layout wiring Create a single `Store` instance with app-owned reducers at module scope: @@ -94,53 +53,24 @@ Bootstrap in `+layout.svelte` by initializing the configured Store instance and ```svelte {@render children()} ``` -Pass `store.init(initialState)` when the app needs preloaded state. `store.init()` should run during root component initialization so the Store-owned runtime is ready before children use selectors or dispatch through the Store; `onDestroy(dispose)` handles teardown. The returned disposer calls `store.dispose()`, so the existing `const dispose = store.init(); onDestroy(dispose);` pattern remains valid and preferred in Svelte roots. Use direct `store.dispose()` only when non-component code or tests own the whole Store lifetime. - -## 3. Starting app sagas — `store.runSaga` - -`store.init()` starts the package-owned saga manager but does **not** auto-start app sagas. Every app saga must be started explicitly by function. Consumers should not start `@internal_sagaManager` directly. `store.runSaga(sagaFn)` derives a manager name from the saga function. - -### Mount-scoped — `onMount(() => store.runSaga(sagaFn))` - -Call from `onMount` to tie the saga's lifetime to that mount (most app-wide sagas run in the root layout, next to `store.init()`): - -```svelte - -``` - -Svelte calls the returned cancel function when the component unmounts. - -### Imperative — `store.runSaga(sagaFn)` - -For non-component code (services, tests, IPC handlers), start a saga directly and keep the returned cancel function: - -```ts -const cancel = store.runSaga(editorSaga); -// later: -cancel(); -``` - -`store.runSaga(sagaFn)` throws if `init()` has not been called or the derived saga name is reserved for package internals. +Initialize before children render, pass preloaded state to `init(initialState)` if +needed, and keep teardown next to initialization. The saga above is mount-scoped, +not once-per-Store: see `../store/SKILL.md` → **App saga lifetime** for remount, +imperative cancellation, and whole-Store teardown semantics. -Full Store teardown is separate from per-saga cancellation: `store.dispose()` and the disposer returned by `store.init()` tear down the initialized Store runtime and stop saga tasks owned by that Store. Continue to use the cancel function returned by `store.runSaga(sagaFn)` for normal mount-scoped or operation-scoped saga cleanup. - -## 4. Using state in components +## Template and handler wiring At the top of a component script block (component init), create selector readables and import the configured Store for event-handler dispatch/state reads: @@ -172,46 +102,19 @@ At the top of a component script block (component init), create selector readabl {/if} ``` -**Component rules:** - -- ✅ `selectFoo()` at top-level script (component init) -- ✅ `store.dispatch(action)` in handlers through the imported initialized Store instance -- ✅ Use `$selectorResult$` in templates for reactive updates -- ✅ Use `selectFoo.select(store.state)` in handlers with an existing initialized `Store` instance imported/captured outside the handler -- ❌ NEVER call `selectFoo()` inside handlers / callbacks / async functions — it uses `getContext()` which is only valid at init -- ❌ NEVER create wrapper hooks around `dispatch` — dispatch action creators directly so tests and action traces stay explicit - -For non-component code that already imports the initialized app `Store` instance, use its getters directly: - -```ts -import { store } from "$lib/store"; - -export function submitFromShortcut(id: string) { - const item = selectItem.select(store.state, id); - if (item) store.dispatch(submitItem(id)); -} -``` +Capture readables in the script and render `$selectorResult$` in the template; +handlers dispatch through the configured Store. For the complete component, +callback/async, test, saga, composition, and explicit-binding matrix, use +`../selector-lifecycle/SKILL.md` → **Call-mode map** and **Don't**. That owner also +covers one-shot service reads and the prohibition on standalone dispatch helpers. -Do not import removed standalone dispatch helpers. The configured `Store` instance is the public per-store dispatch entry point. +## Wiring pitfalls -The three selector call modes (`selectFoo()`, `.select(state)`, `yield* selectFoo.effect()`) are covered in detail in `svelte/selector-lifecycle/SKILL.md`. +### Missing cleanup or duplicate initialization -## 5. Common Mistakes - -### Calling `store.init()` without cleanup - -**Mechanism:** `store.init()` returns a disposer backed by `store.dispose()`; skipping `onDestroy(dispose)` leaks Store-owned saga tasks and runtime subscriptions on hot reload and in tests. - -```typescript -// ❌ WRONG -store.init(); - -// ✅ CORRECT -const dispose = store.init(); -onDestroy(dispose); -``` - -*Source: `../SKILL.md §7`.* +Keep one root owner and its cleanup together. Missing cleanup leaks runtime +subscriptions/tasks; adding child initialization is not a fix for missing state. +Use `../store/SKILL.md` → **Lifecycle rules** for disposal and noop-init behavior. ### Creating a wrapper hook around `dispatch` @@ -228,58 +131,19 @@ export function useAddItem() { store.dispatch(addItem(i)); ``` -*Source: `../SKILL.md §7, §17`.* +*Canonical import/dispatch boundaries: `../../core/import-boundaries/SKILL.md` → **Core Patterns**.* ### Reading state with `selector()` in a template -**Mechanism:** templates bind Svelte readables via `$readable$` syntax. Calling `selectFoo()` in the template body creates a fresh readable every render and loses memoization (and throws outside init). - -```svelte - -

{selectCount()}

- - - -

{$count$}

-``` - -*Source: `../SKILL.md §7`.* - -### Importing standalone package dispatch helpers - -**Mechanism:** per-store dispatch belongs to the configured `Store` instance. Do not import package-level dispatch helpers from public entrypoints. - -```svelte - - -``` - -*Source: `../SKILL.md §2, §7`.* - -### Double-initializing the store - -**Mechanism:** `store.init()` returns a noop disposer when an initialized Store runtime already exists — so calling it a second time does nothing. Agents sometimes "fix" missing state by adding a second `store.init()` call in a child layout; the fix is silent and the child layout's middlewares/sagas never register. - -```svelte - - - - - - - - -``` - -*Public API: `@augmentcode/themis/svelte-store` (`Store.init` early-return on existing context).* +This violates the component-init/context contract, not a cache-miss guarantee. +Identical Store + selector + arguments reuse the readable; see +`../selectors/SKILL.md` → **Selector caching** and +`../selector-lifecycle/SKILL.md` → **Pitfalls**. Render the captured `$count$`, +not `selectCount()` in markup. -## 6. See also +## See also -- `svelte/selector-lifecycle` — the three selector call modes (`selectFoo()` / `.select(state)` / `.effect()`). -- `core/file-structure` — slice layout and registration order. +- `../store/SKILL.md` — Store API and lifecycle contracts. +- `../selector-lifecycle/SKILL.md` — selector call-site rules. +- `../../core/file-structure/SKILL.md` — slice layout and registration order. - `../../setup/SKILL.md` — first-time greenfield setup. diff --git a/skills/svelte/migration/SKILL.md b/skills/svelte/migration/SKILL.md index 11c281e..fd51ec6 100644 --- a/skills/svelte/migration/SKILL.md +++ b/skills/svelte/migration/SKILL.md @@ -12,15 +12,16 @@ description: >- type: lifecycle requires: - svelte + - core/core-policy triggers: - - migrate stores - - convert to redux + - migrate Svelte stores + - convert Svelte stores to redux - replace svelte stores - - migration + - Svelte migration - migrate writable - migrate derived - svelte store to redux - - store migration plan + - Svelte store migration plan --- # Migrate Svelte Stores → Redux + Saga @@ -40,181 +41,54 @@ Before editing code or docs under this skill: ## Migration Policy -Migrating from Svelte stores to `themis` is driven by two rules: - -1. **Redux owns shared, persisted, or async-driven state.** Anything read or written by more than one component, anything synced to localStorage / the server / IPC, and anything involved in async flows (API calls, timers, debouncing) moves into a slice. -2. **Component-local ephemeral state stays local.** Hover, focus, open/closed toggles, scroll position, and single-form field values do not need a slice. **When in doubt → move to Redux.** - -Migrate one store at a time, simplest and most isolated first. Each slice lands in its own branch and PR; never batch multiple store migrations in one commit. Keep the old `.store.svelte.ts` file in git history until the new slice passes tests and manual UI verification — then delete it and run `grep` to confirm zero residual references. Do not leave the old path as a one-line re-export, proxy, or delegate-only wrapper; remove it, inline it, or document it as a compatibility shim with a sunset/removal condition. - -Reducers must stay pure (no `fetch`, no `localStorage`, no `Date.now()`, no mutation) and state must stay structured-cloneable (no `Date` / `Map` / `Set` / `RegExp` / class instances / functions / `Promise`). Side effects — subscriptions, `$effect`, timers, IPC, fetches — go into sagas. Every `selectFoo()` readable call is a component-init API: capture it at the top of the `