Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.3.0 - 2026-09-18

- Observed ignored async-action rejections internally while preserving the original promise and rejection for explicit awaiters.
- Returned the original typed async-action promise from Store, ReactStore, and StreamingStore dispatch, enabling `await store.dispatch(asyncAction(...))` without changing ordinary action dispatch behavior.
- Added the provenance-aware `redundant-async-action-catch` ESLint rule to flag redundant catches on proven Themis async-action promises.
- Updated reducer, saga, and action-skill documentation with preferred dispatch-await error handling and per-instance async-action completion guidance.
- Consolidated core, React, Svelte, setup, and Streaming skill ownership and routing, corrected conflicting lifecycle and migration guidance, and preserved framework isolation.
- Repaired skill references with canonical relative links and stable named anchors, replacing stale numbered citations and normalizing same-document links.

## 0.2.9 - 2026-09-17

- Fixed selector-channel re-entrant updates so synchronous worker dispatches advance the previous payload baseline before emission, preventing duplicate or stale transitions.
Expand Down
21 changes: 20 additions & 1 deletion docs/REDUCERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions docs/SAGAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,16 +427,16 @@ Wrap individual workers in try/catch:
function* handleFetchItems(action: ReturnType<typeof fetchItems>) {
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() {
Expand All @@ -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:
Expand Down
32 changes: 28 additions & 4 deletions eslint-plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions eslint-plugins/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
4 changes: 4 additions & 0 deletions eslint-plugins/plugins/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,6 +78,7 @@ export {
reactForbiddenComponentImportPlugin,
reactPreferDirectSelectorPlugin,
reducerSideEffectPlugin,
redundantAsyncActionCatchPlugin,
removedMiddlewareSourcePlugin,
sagaLocalSelectorPlugin,
sagaWatcherActionTypePlugin,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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);
93 changes: 93 additions & 0 deletions eslint-plugins/store/redundant-async-action-catch/plugin.mjs
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@augmentcode/themis",
"version": "0.2.9",
"version": "0.3.0",
"publishConfig": {
"access": "public"
},
Expand Down Expand Up @@ -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"
Expand Down
Loading