From 1962779163813240ff0b5e5edb8c661678dafb1c Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sat, 10 Oct 2026 00:25:54 +0000 Subject: [PATCH 1/3] fix(xstate): enter only the restored states on a reentering self-history transition History resolution moves out of stateUtils.ts into src/historyRecall.ts, a pure module that decides between a recorded and a default recall, records history on exit, and tells whether a transition restores its own source. isAtomicStateNode and isDescendant move into the leaf src/stateNodePredicates.ts so both modules share them without an import cycle. The per-parent history default map and its default-transition loop are gone: nothing filled the map with anything but the parent's own initial transition. Root cause of the fix: the microstep resolved a history target's ancestors from the pre-exit history value, but its descendants from the record the exit had just written. A transition with reenter: true to its source's own history, with no record yet, therefore ran entry actions for the ancestors of the default target (b for a default of b.b2; the parallel state, its regions and their initials for a parallel default) although only the recorded states were entered. getEffectiveTargetStates now takes the history value to read: the transition domain still reads the snapshot's, the entry set reads the post-exit one. Both new modules join the mutate set, the lint script and tsconfig.tsgo.json, and XS1's enrolled list names them. --- .changeset/history-reenter-ghost-entry.md | 9 + packages/AGENTS.md | 8 +- packages/xstate/package.json | 2 +- packages/xstate/src/historyRecall.ts | 90 ++ packages/xstate/src/mapState.ts | 2 +- packages/xstate/src/stateNodePredicates.ts | 29 + packages/xstate/src/stateUtils.ts | 215 +-- packages/xstate/src/transition.ts | 3 +- packages/xstate/stryker.config.ts | 2 + packages/xstate/test/history.test.ts | 1636 -------------------- packages/xstate/tsconfig.tsgo.json | 10 +- 11 files changed, 231 insertions(+), 1775 deletions(-) create mode 100644 .changeset/history-reenter-ghost-entry.md create mode 100644 packages/xstate/src/historyRecall.ts create mode 100644 packages/xstate/src/stateNodePredicates.ts delete mode 100644 packages/xstate/test/history.test.ts diff --git a/.changeset/history-reenter-ghost-entry.md b/.changeset/history-reenter-ghost-entry.md new file mode 100644 index 0000000..cd55f1e --- /dev/null +++ b/.changeset/history-reenter-ghost-entry.md @@ -0,0 +1,9 @@ +--- +"@systemfsoftware/xstate": patch +--- + +A transition with `reenter: true` that targets its source's own history state no longer runs entry actions for states it does not enter. + +- Before, when the history state had no record yet, the ancestors of its default target ran their `entry` actions even though the restored states were the recorded ones. For a default of `b.b2` that was `b`; for a default inside a parallel state it was the parallel state, its regions and their initial states. +- The machine now exits the source, re-enters it, and enters exactly the restored states, so it emits only those states' entry actions. +- The resulting state value and persisted history value are the same as before. diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 323e157..9eb8f75 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -4,7 +4,7 @@ Root `AGENTS.md` governs; these are the facts no default gate reports. Forked from statelyai/xstate, branch `next` at `2146ae26ebfc7e6a624b3a1f237f9e6ddc30b9f5` (MIT). Members are owned (`REPO-O1`); upstream is a reference, never a merge source. Plan: systemfsoftware's `docs/plans/2026-10-05-2317-feat-xstate-v6-fork-plan.md`. Not forked (no consumer in this program): the Solid, Svelte, Vue, Angular and Preact bindings, `@xstate/codemod`, `@xstate/scxml`; core's `bin/xstate.js` only ran `@xstate/codemod` through `npx`, so it is not shipped. -| ID | Rule | Gate | -| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **XS1** | A member gains `stryker.config.ts`, `oxlint.config.ts` (extending the root `oxlint.shared.ts`) and its `mutation` and `lint` scripts with its first capability PR, and its debt-ledger entry goes in that PR: `scripts/stryker-plan-gate.ts` (`exemption`) reads any `XS1` entry for a member as leave to schedule no mutants. Until its last capability PR, a member whose `src` tsconfig lacks `@effect/language-service` points `lint:tsgo` at `tsconfig.tsgo.json`, which turns the plugin on for the enrolled `src` files only. Each capability PR adds the `src` files it covers to `mutate`, to the `lint` script and to `tsconfig.tsgo.json`, with its test files, and fixes their diagnostics without disable comments. The last capability PR turns the plugin on for the whole `src` tsconfig and deletes `tsconfig.tsgo.json`. Remaining debt: in `@systemfsoftware/xstate`, every `src` file outside `mutate` is unmutated, unlinted and unchecked by the Effect plugin until its capability PR (enrolled: `src/assert.ts`, `src/stateMatching.ts`, `src/mapState.ts`, `src/fsm.ts`, `src/graph/`; the `lint` script and `tsconfig.tsgo.json` name the whole folder, and `mutate` names its seven files with runtime logic, leaving out `types.ts` and `index.ts`). | `review` — wrong: a PR mutates `src/assert.ts` and leaves it out of the `lint` script or `tsconfig.tsgo.json`, or keeps a ledger entry for an enrolled member; right: `mutate`, the `lint` script, `tsconfig.tsgo.json` and this row's enrolled list all name `src/assert.ts`, and `lint:tsgo` prints a nonzero `Checked N files` | -| **XS2** | The persisted snapshot wire format is upstream v6's (actor refs `{ xstate$type: 'actorRef', id }`, children by logical address, timers with `startedAt`); it changes only through a versioned machine migration. | `review` — wrong: an encoder emits `{ _tag: 'ActorRef', id }`; right: the encoder writes `{ xstate$type: 'actorRef', id }` and the tagged class exists only after decode | +| ID | Rule | Gate | +| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **XS1** | A member gains `stryker.config.ts`, `oxlint.config.ts` (extending the root `oxlint.shared.ts`) and its `mutation` and `lint` scripts with its first capability PR, and its debt-ledger entry goes in that PR: `scripts/stryker-plan-gate.ts` (`exemption`) reads any `XS1` entry for a member as leave to schedule no mutants. Until its last capability PR, a member whose `src` tsconfig lacks `@effect/language-service` points `lint:tsgo` at `tsconfig.tsgo.json`, which turns the plugin on for the enrolled `src` files only. Each capability PR adds the `src` files it covers to `mutate`, to the `lint` script and to `tsconfig.tsgo.json`, with its test files, and fixes their diagnostics without disable comments. The last capability PR turns the plugin on for the whole `src` tsconfig and deletes `tsconfig.tsgo.json`. Remaining debt: in `@systemfsoftware/xstate`, every `src` file outside `mutate` is unmutated, unlinted and unchecked by the Effect plugin until its capability PR (enrolled: `src/assert.ts`, `src/stateMatching.ts`, `src/mapState.ts`, `src/fsm.ts`, `src/graph/`, `src/historyRecall.ts`, `src/stateNodePredicates.ts`; the `lint` script and `tsconfig.tsgo.json` name the whole folder, and `mutate` names its seven files with runtime logic, leaving out `types.ts` and `index.ts`). | `review` — wrong: a PR mutates `src/assert.ts` and leaves it out of the `lint` script or `tsconfig.tsgo.json`, or keeps a ledger entry for an enrolled member; right: `mutate`, the `lint` script, `tsconfig.tsgo.json` and this row's enrolled list all name `src/assert.ts`, and `lint:tsgo` prints a nonzero `Checked N files` | +| **XS2** | The persisted snapshot wire format is upstream v6's (actor refs `{ xstate$type: 'actorRef', id }`, children by logical address, timers with `startedAt`); it changes only through a versioned machine migration. | `review` — wrong: an encoder emits `{ _tag: 'ActorRef', id }`; right: the encoder writes `{ xstate$type: 'actorRef', id }` and the tagged class exists only after decode | diff --git a/packages/xstate/package.json b/packages/xstate/package.json index 715aaae..b0ae7c2 100644 --- a/packages/xstate/package.json +++ b/packages/xstate/package.json @@ -25,7 +25,7 @@ "attw": "attw --pack .", "api:check": "api-extractor run && api-extractor run --config api-extractor.actors.json && api-extractor run --config api-extractor.durable.json && api-extractor run --config api-extractor.fsm.json && api-extractor run --config api-extractor.graph.json && api-extractor run --config api-extractor.validation.json", "api:update": "api-extractor run --local && api-extractor run --local --config api-extractor.actors.json && api-extractor run --local --config api-extractor.durable.json && api-extractor run --local --config api-extractor.fsm.json && api-extractor run --local --config api-extractor.graph.json && api-extractor run --local --config api-extractor.validation.json", - "lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint src/assert.ts src/stateMatching.ts src/mapState.ts src/fsm.ts src/graph tests/event-assertions.conformance.test.ts tests/state-matching.conformance.test.ts tests/map-state.conformance.test.ts tests/fsm.conformance.test.ts tests/graph.conformance.test.ts tests/__fixtures__ --format=${f:-default}", + "lint": "f=${OXLINT_FORMAT:-${AGENT:+agent}}; oxlint src/assert.ts src/stateMatching.ts src/mapState.ts src/fsm.ts src/graph src/historyRecall.ts src/stateNodePredicates.ts tests/event-assertions.conformance.test.ts tests/state-matching.conformance.test.ts tests/map-state.conformance.test.ts tests/fsm.conformance.test.ts tests/graph.conformance.test.ts tests/history.conformance.test.ts tests/__fixtures__ --format=${f:-default}", "mutation": "stryker run" }, "devDependencies": { diff --git a/packages/xstate/src/historyRecall.ts b/packages/xstate/src/historyRecall.ts new file mode 100644 index 0000000..d24d551 --- /dev/null +++ b/packages/xstate/src/historyRecall.ts @@ -0,0 +1,90 @@ +import { isAtomicStateNode, isDescendant } from './stateNodePredicates.js' +import type { AnyStateNode, AnyTransitionDefinition, HistoryValue } from './types.js' + +export type HistoryStateNode = AnyStateNode & { type: 'history' } + +export interface HistoryRecallRecorded { + readonly type: 'recorded' + readonly nodes: readonly AnyStateNode[] +} + +export interface HistoryRecallDefault { + readonly type: 'default' + readonly transition: AnyTransitionDefinition +} + +export type HistoryRecall = HistoryRecallRecorded | HistoryRecallDefault + +export function isHistoryNode( + stateNode: AnyStateNode, +): stateNode is HistoryStateNode { + return stateNode.type === 'history' +} + +const defaultHistoryTransition = ( + source: HistoryStateNode, + target: readonly AnyStateNode[], +): AnyTransitionDefinition => ({ + target, + source, + reenter: false, + eventType: '', +}) + +export interface RecallHistoryArgs { + readonly historyNode: HistoryStateNode + readonly historyValue: HistoryValue + readonly defaultTargets: () => readonly AnyStateNode[] +} + +export function recallHistory(args: RecallHistoryArgs): HistoryRecall { + const recorded = args.historyValue[args.historyNode.id] + + return recorded === undefined + ? { + type: 'default', + transition: defaultHistoryTransition( + args.historyNode, + args.defaultTargets(), + ), + } + : { type: 'recorded', nodes: recorded } +} + +export interface RecordHistoryArgs { + readonly historyNode: HistoryStateNode + readonly exitingNode: AnyStateNode + readonly currentStateNodes: readonly AnyStateNode[] +} + +export function recordHistoryNodes( + args: RecordHistoryArgs, +): Array { + const { historyNode, exitingNode, currentStateNodes } = args + + if (historyNode.history === 'deep') { + return currentStateNodes.filter( + (stateNode) => + isAtomicStateNode(stateNode) && + isDescendant({ childStateNode: stateNode, parentStateNode: exitingNode }), + ) + } + + return currentStateNodes.filter( + (stateNode) => stateNode.parent === exitingNode, + ) +} + +export interface RestoresSourceViaHistoryArgs { + readonly targets: readonly AnyStateNode[] + readonly effectiveTargetStates: readonly AnyStateNode[] + readonly source: AnyStateNode +} + +export function restoresSourceViaHistory( + args: RestoresSourceViaHistoryArgs, +): boolean { + return args.targets.some(isHistoryNode) + ? args.effectiveTargetStates.some((stateNode) => stateNode === args.source) + : false +} diff --git a/packages/xstate/src/mapState.ts b/packages/xstate/src/mapState.ts index fe20891..3f91f1f 100644 --- a/packages/xstate/src/mapState.ts +++ b/packages/xstate/src/mapState.ts @@ -1,5 +1,5 @@ import type { MachineSnapshot } from './State.js' -import { isAtomicStateNode } from './stateUtils.js' +import { isAtomicStateNode } from './stateNodePredicates.js' import type { AnyMachineSnapshot, AnyStateNode, StateSchema } from './types.js' type StateSchemaFromSnapshot = TSnapshot extends MachineSnapshot< diff --git a/packages/xstate/src/stateNodePredicates.ts b/packages/xstate/src/stateNodePredicates.ts new file mode 100644 index 0000000..ade2059 --- /dev/null +++ b/packages/xstate/src/stateNodePredicates.ts @@ -0,0 +1,29 @@ +import type { AnyStateNode } from './types.js' + +const IS_ATOMIC_TYPE: Record = { + atomic: true, + choice: true, + compound: false, + final: true, + history: false, + parallel: false, +} + +export function isAtomicStateNode(stateNode: AnyStateNode): boolean { + return IS_ATOMIC_TYPE[stateNode.type] +} + +export interface DescendantArgs { + readonly childStateNode: AnyStateNode + readonly parentStateNode: AnyStateNode +} + +const isOrHasAncestor = (stateNode: AnyStateNode, ancestor: AnyStateNode): boolean => + stateNode === ancestor || hasAncestor(stateNode.parent, ancestor) + +const hasAncestor = (stateNode: AnyStateNode | undefined, ancestor: AnyStateNode): boolean => + stateNode === undefined ? false : isOrHasAncestor(stateNode, ancestor) + +export function isDescendant(args: DescendantArgs): boolean { + return hasAncestor(args.childStateNode.parent, args.parentStateNode) +} diff --git a/packages/xstate/src/stateUtils.ts b/packages/xstate/src/stateUtils.ts index 6d0de57..55e1487 100644 --- a/packages/xstate/src/stateUtils.ts +++ b/packages/xstate/src/stateUtils.ts @@ -12,11 +12,13 @@ import { createTimeoutEvent, createTimeoutEventId, } from './eventUtils.js' +import { isHistoryNode, recallHistory, recordHistoryNodes, restoresSourceViaHistory } from './historyRecall.js' import { isInertActorScope } from './inertActorScope.js' import type { MachineSnapshot } from './State.js' import { cloneMachineSnapshot } from './State.js' import { toStatePath } from './stateMatching.js' import type { StateNode } from './StateNode.js' +import { isAtomicStateNode, isDescendant } from './stateNodePredicates.js' import { transitionEffectSignal, transitionEffectTargets } from './system.js' import { assertChildIdFree, @@ -102,14 +104,6 @@ function getStateInput(snapshot: AnyMachineSnapshot, stateNodeId: string) { return snapshot._stateInputs?.[stateNodeId] } -export function isAtomicStateNode(stateNode: AnyStateNode) { - return ( - stateNode.type === 'atomic' || - stateNode.type === 'final' || - stateNode.type === 'choice' - ) -} - function getChildren(stateNode: AnyStateNode): Array { return Object.values(stateNode.states).filter((sn) => sn.type !== 'history') } @@ -786,36 +780,13 @@ function resolveTarget( }) } -function resolveHistoryDefaultTransition( +function resolveHistoryDefaultTargets( stateNode: AnyStateNode & { type: 'history' }, -): AnyTransitionDefinition { +): Array { const normalizedTarget = normalizeTarget(stateNode.config.target) - if (!normalizedTarget) { - if (stateNode.parent!.type === 'parallel') { - return { - target: [stateNode.parent!], - source: stateNode, - reenter: false, - eventType: '' as any, - } - } - return stateNode.parent!.initial as AnyTransitionDefinition - } - const target = normalizedTarget.map((t) => typeof t === 'string' ? getStateNodeByPath(stateNode.parent!, t) : t) + const target = normalizedTarget!.map((t) => typeof t === 'string' ? getStateNodeByPath(stateNode.parent!, t) : t) assertLegalTargetSet(stateNode, target) - return { - target, - source: stateNode, - reenter: false, - eventType: '' as any, - to: (stateNode.config as any)._historyDefaultTransition, - } -} - -function isHistoryNode( - stateNode: AnyStateNode, -): stateNode is AnyStateNode & { type: 'history' } { - return stateNode.type === 'history' + return target } function getInitialStateNodes(stateNode: AnyStateNode) { @@ -1047,24 +1018,12 @@ export function transitionNode< return allInnerTransitions } -function isDescendant( - childStateNode: AnyStateNode, - parentStateNode: AnyStateNode, -): boolean { - let marker = childStateNode - while (marker.parent && marker.parent !== parentStateNode) { - marker = marker.parent - } - - return marker.parent === parentStateNode -} - function hasDescendantState( stateNodes: Set, parentStateNode: AnyStateNode, ): boolean { for (const stateNode of stateNodes) { - if (isDescendant(stateNode, parentStateNode)) { + if (isDescendant({ childStateNode: stateNode, parentStateNode })) { return true } } @@ -1118,7 +1077,7 @@ function removeConflictingTransitions( const transitionsToRemove = new Set() for (const t2 of filteredTransitions) { if (hasIntersection(getExitSet(t1), getExitSet(t2))) { - if (isDescendant(t1.source, t2.source)) { + if (isDescendant({ childStateNode: t1.source, parentStateNode: t2.source })) { transitionsToRemove.add(t2) } else { t1Preempted = true @@ -1177,10 +1136,9 @@ function createTransitionResultResolver( function getEffectiveTargetStates( transition: Pick, - snapshot: AnyMachineSnapshot, + historyValue: HistoryValue, resolveTransition: TransitionResultResolver, ): Array { - const historyValue = snapshot.historyValue const { targets } = resolveTransition(transition) if (!targets) { return [] @@ -1190,16 +1148,20 @@ function getEffectiveTargetStates( for (const targetNode of targets) { if (isHistoryNode(targetNode)) { - const historyNodes = historyValue[targetNode.id] - if (historyNodes) { - for (const node of historyNodes) { + const recall = recallHistory({ + historyNode: targetNode, + historyValue, + defaultTargets: () => resolveHistoryDefaultTargets(targetNode), + }) + if (recall.type === 'recorded') { + for (const node of recall.nodes) { targetSet.add(node) } } else { for ( const node of getEffectiveTargetStates( - resolveHistoryDefaultTransition(targetNode), - snapshot, + recall.transition, + historyValue, resolveTransition, ) ) { @@ -1227,7 +1189,9 @@ function narrowParallelDomain( while (narrowed.type === 'parallel') { const region = getChildren(narrowed).find((child) => targetStates.every( - (target) => target === child || isDescendant(target, child), + (target) => + target === child || + isDescendant({ childStateNode: target, parentStateNode: child }), ) ) if (!region) { @@ -1245,7 +1209,7 @@ function getTransitionDomain( ): AnyStateNode | undefined { const targetStates = getEffectiveTargetStates( transition, - snapshot, + snapshot.historyValue, resolveTransition, ) @@ -1255,7 +1219,7 @@ function getTransitionDomain( if ( transition._transitionDomain === 'internal' && transition.source.type === 'compound' && - targetStates.every((target) => isDescendant(target, transition.source)) + targetStates.every((target) => isDescendant({ childStateNode: target, parentStateNode: transition.source })) ) { return transition.source } @@ -1267,7 +1231,7 @@ function getTransitionDomain( for (const ancestor of getProperAncestors(head, undefined)) { if ( ancestor.type === 'compound' && - tail.every((stateNode) => isDescendant(stateNode, ancestor)) + tail.every((stateNode) => isDescendant({ childStateNode: stateNode, parentStateNode: ancestor })) ) { return ancestor } @@ -1281,13 +1245,18 @@ function getTransitionDomain( // non-reentering self-target: the enter set restores the stored // configuration from outside the source, so the exit set must match or the // source's invoked actors are re-created without being stopped. - const restoresSourceViaHistory = targets?.some(isHistoryNode) && - targetStates.some((target) => target === transition.source) + const restoresSource = restoresSourceViaHistory({ + targets: targets ?? [], + effectiveTargetStates: targetStates, + source: transition.source, + }) if ( - !restoresSourceViaHistory && + !restoresSource && targetStates.every( - (target) => target === transition.source || isDescendant(target, transition.source), + (target) => + target === transition.source || + isDescendant({ childStateNode: target, parentStateNode: transition.source }), ) ) { // Targets are contained within the source. A reentering transition @@ -1304,7 +1273,7 @@ function getTransitionDomain( } // Find the least common ancestor (LCA) of the source and effective targets. for (const ancestor of getProperAncestors(head, undefined)) { - if (tail.every((sn) => isDescendant(sn, ancestor))) { + if (tail.every((sn) => isDescendant({ childStateNode: sn, parentStateNode: ancestor }))) { // A cross-region transition (source in one parallel region, targets in // another) only exits the region containing its targets; the source // region and other sibling regions stay put unless it reenters. @@ -1342,7 +1311,7 @@ function computeExitSet( } for (const stateNode of stateNodeSet) { - if (isDescendant(stateNode, domain!)) { + if (isDescendant({ childStateNode: stateNode, parentStateNode: domain! })) { statesToExit.add(stateNode) } } @@ -1388,7 +1357,9 @@ export function initialMicrostep( target: initialStateNodes.filter( (stateNode) => !initialStateNodes.some( - (other) => other !== stateNode && isDescendant(other, stateNode), + (other) => + other !== stateNode && + isDescendant({ childStateNode: other, parentStateNode: stateNode }), ), ), source: root, @@ -1585,16 +1556,16 @@ function microstep( // From SCXML algorithm: https://www.w3.org/TR/scxml/#exitStates for (const exitStateNode of statesToExit) { for (const historyNode of Object.values(exitStateNode.states)) { - if (historyNode.type !== 'history') { + if (!isHistoryNode(historyNode)) { continue } - const predicate = historyNode.history === 'deep' - ? (sn: AnyStateNode) => isAtomicStateNode(sn) && isDescendant(sn, exitStateNode) - : (sn: AnyStateNode) => sn.parent === exitStateNode - changedHistory ??= { ...historyValue } - changedHistory[historyNode.id] = currentStateNodes.filter(predicate) + changedHistory[historyNode.id] = recordHistoryNodes({ + historyNode, + exitingNode: exitStateNode, + currentStateNodes, + }) } } @@ -1731,16 +1702,12 @@ function microstep( // in other words, those are states for which initial actions should be executed // when we target `#deep_child` initial actions of its ancestors shouldn't be executed const statesForDefaultEntry = new Set() - const historyDefaultsByParent = new Map< - AnyStateNode, - AnyTransitionDefinition[] - >() const addAncestorStatesToEnter = ( ancestors: AnyStateNode[], reentrancyDomain: AnyStateNode | undefined, ) => { for (const anc of ancestors) { - if (!reentrancyDomain || isDescendant(anc, reentrancyDomain)) { + if (!reentrancyDomain || isDescendant({ childStateNode: anc, parentStateNode: reentrancyDomain })) { statesToEnter.add(anc) } if (anc.type === 'parallel') { @@ -1756,38 +1723,29 @@ function microstep( const addDescendantStatesToEnter = (stateNode: AnyStateNode) => { if (isHistoryNode(stateNode)) { - const historyStateNodes = historyValue[stateNode.id] - if (historyStateNodes) { - for (const s of historyStateNodes) { + const recall = recallHistory({ + historyNode: stateNode, + historyValue, + defaultTargets: () => resolveHistoryDefaultTargets(stateNode), + }) + if (recall.type === 'recorded') { + for (const s of recall.nodes) { statesToEnter.add(s) addDescendantStatesToEnter(s) } - for (const s of historyStateNodes) { + for (const s of recall.nodes) { addAncestorStatesToEnter( getProperAncestors(s, stateNode.parent), undefined, ) } } else { - const historyDefaultTransition = resolveHistoryDefaultTransition(stateNode) - const historyParent = stateNode.parent! - statesForDefaultEntry.add(historyParent) - const defaults = historyDefaultsByParent.get(historyParent) ?? [] - defaults.push(historyDefaultTransition) - historyDefaultsByParent.set(historyParent, defaults) - const { targets } = getCurrentTransitionResult( - historyDefaultTransition, - ) + statesForDefaultEntry.add(stateNode.parent!) + const { targets } = getCurrentTransitionResult(recall.transition) for (const s of targets ?? []) { statesToEnter.add(s) - - if (historyDefaultTransition === stateNode.parent?.initial) { - statesForDefaultEntry.add(stateNode.parent) - } - addDescendantStatesToEnter(s) } - for (const s of targets ?? []) { addAncestorStatesToEnter( getProperAncestors(s, stateNode.parent), @@ -1860,7 +1818,7 @@ function microstep( } const targetStates = getEffectiveTargetStates( transition, - currentSnapshot, + historyValue, getCurrentTransitionResult, ) for (const s of targetStates) { @@ -2013,40 +1971,35 @@ function microstep( nextState.context = context } - if (statesForDefaultEntry.has(stateNodeToEnter)) { - const defaultTransitions = [ - stateNodeToEnter.initial, - ...(historyDefaultsByParent.get(stateNodeToEnter) ?? []), - ].filter(Boolean) - for (const defaultTransition of defaultTransitions) { - const { - actions: initialActions, - context: initialContext, - input: initialInput, - internalEvents: initialInternalEvents, - } = getTransitionResult( - defaultTransition, - nextState, - event, - actorScope, + const defaultTransition = stateNodeToEnter.initial + if (defaultTransition && statesForDefaultEntry.has(stateNodeToEnter)) { + const { + actions: initialActions, + context: initialContext, + input: initialInput, + internalEvents: initialInternalEvents, + } = getTransitionResult( + defaultTransition, + nextState, + event, + actorScope, + ) + if (initialActions) { + actions.push(...initialActions) + } + if (initialInternalEvents?.length) { + internalQueue.push(...initialInternalEvents) + } + if (initialContext !== undefined) { + nextState.context = mergeContextPatch( + nextState.context, + initialContext, ) - if (initialActions) { - actions.push(...initialActions) - } - if (initialInternalEvents?.length) { - internalQueue.push(...initialInternalEvents) - } - if (initialContext !== undefined) { - nextState.context = mergeContextPatch( - nextState.context, - initialContext, - ) - } - if (initialInput && defaultTransition.target) { - for (const targetNode of defaultTransition.target) { - stateInputMap[targetNode.id] = initialInput - stateInputsChanged = true - } + } + if (initialInput && defaultTransition.target) { + for (const targetNode of defaultTransition.target) { + stateInputMap[targetNode.id] = initialInput + stateInputsChanged = true } } } diff --git a/packages/xstate/src/transition.ts b/packages/xstate/src/transition.ts index e5f666e..a9507ab 100644 --- a/packages/xstate/src/transition.ts +++ b/packages/xstate/src/transition.ts @@ -1,6 +1,7 @@ import { createInitEvent } from './eventUtils.js' import { attachSnapshotActorRef, createInertActorScope, setInertActorScopeSnapshot } from './inertActorScope.js' -import { getProperAncestors, initialMicrostep, isAtomicStateNode, macrostep } from './stateUtils.js' +import { isAtomicStateNode } from './stateNodePredicates.js' +import { getProperAncestors, initialMicrostep, macrostep } from './stateUtils.js' import { hasAmbientInspector } from './system.js' import { beginSpawnAllocation, createSpawnEffect, finalizeTransitionResult } from './transitionActions.js' import type { diff --git a/packages/xstate/stryker.config.ts b/packages/xstate/stryker.config.ts index 80eb10a..e993d8d 100644 --- a/packages/xstate/stryker.config.ts +++ b/packages/xstate/stryker.config.ts @@ -12,4 +12,6 @@ export default packageStrykerConfig([ 'src/graph/pathFromEvents.ts', 'src/graph/shortestPaths.ts', 'src/graph/simplePaths.ts', + 'src/historyRecall.ts', + 'src/stateNodePredicates.ts', ]) diff --git a/packages/xstate/test/history.test.ts b/packages/xstate/test/history.test.ts deleted file mode 100644 index d507761..0000000 --- a/packages/xstate/test/history.test.ts +++ /dev/null @@ -1,1636 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createActor, createCallbackLogic, createMachine } from '../src/index.js' -import { StateNode } from '../src/StateNode.js' -import { trackEntries } from './utils.js' - -describe('history states', () => { - it('rejects a history state without a non-empty default target at runtime', () => { - expect(() => - (createMachine as any)({ - initial: 'on', - states: { - on: { - initial: 'active', - states: { - active: {}, - history: { type: 'history' }, - }, - }, - }, - }) - ).toThrow( - 'History state "(machine).on.history" must declare a non-empty `target`.', - ) - }) - - it('should go to the most recently visited state (explicit shallow history type)', () => { - const machine = createMachine({ - initial: 'on', - states: { - on: { - initial: 'first', - states: { - first: { - on: { SWITCH: { target: 'second' } }, - }, - second: {}, - hist: { - type: 'history', - history: 'shallow', - target: 'first', - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - off: { - on: { POWER: { target: 'on.hist' } }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ on: 'second' }) - }) - - it('should go to the most recently visited state (no explicit history type)', () => { - const machine = createMachine({ - initial: 'on', - states: { - on: { - initial: 'first', - states: { - first: { - on: { SWITCH: { target: 'second' } }, - }, - second: {}, - hist: { - type: 'history', - target: 'first', - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - off: { - on: { POWER: { target: 'on.hist' } }, - }, - }, - }) - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ on: 'second' }) - }) - - it('should go to the initial state when no history present (explicit shallow history type)', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { POWER: { target: 'on.hist' } }, - }, - on: { - initial: 'first', - states: { - first: {}, - second: {}, - hist: { - type: 'history', - history: 'shallow', - target: 'first', - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ on: 'first' }) - }) - - it('should go to the initial state when no history present (no explicit history type)', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { POWER: { target: 'on.hist' } }, - }, - on: { - initial: 'first', - states: { - first: {}, - second: {}, - hist: { - type: 'history', - target: 'first', - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ on: 'first' }) - }) - - it('should go to the most recently visited state by a transient transition', () => { - const machine = createMachine({ - initial: 'idle', - states: { - idle: { - id: 'idle', - initial: 'absent', - states: { - absent: { - on: { - DEPLOY: { target: '#deploy' }, - }, - }, - present: { - on: { - DEPLOY: { target: '#deploy' }, - DESTROY: { target: '#destroy' }, - }, - }, - hist: { - type: 'history', - target: 'absent', - }, - }, - }, - deploy: { - id: 'deploy', - on: { - SUCCESS: { target: 'idle.present' }, - FAILURE: { target: 'idle.hist' }, - }, - }, - destroy: { - id: 'destroy', - always: { target: 'idle.absent' }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'DEPLOY' }) - actorRef.send({ type: 'SUCCESS' }) - actorRef.send({ type: 'DESTROY' }) - actorRef.send({ type: 'DEPLOY' }) - actorRef.send({ type: 'FAILURE' }) - - expect(actorRef.getSnapshot().value).toEqual({ idle: 'absent' }) - }) - - it('should reenter persisted state during reentering transition targeting a history state', () => { - const actual: string[] = [] - - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { - REENTER: { - target: '#b_hist', - reenter: true, - }, - }, - initial: 'a1', - states: { - a1: { - on: { - NEXT: { target: 'a2' }, - }, - }, - a2: { - // TODO: investigate why enq(actual.push, 'a2 entered') throws - entry: (_, enq) => enq(() => actual.push('a2 entered')), - exit: (_, enq) => enq(() => actual.push('a2 exited')), - }, - a3: { - type: 'history', - id: 'b_hist', - target: 'a1', - }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'NEXT' }) - - actual.length = 0 - actorRef.send({ type: 'REENTER' }) - - expect(actual).toEqual(['a2 exited', 'a2 entered']) - }) - - it('should go to the configured default target when a history state is the initial state of the machine', () => { - const machine = createMachine({ - initial: 'foo', - states: { - foo: { - type: 'history', - target: 'bar', - }, - bar: {}, - }, - }) - - const actorRef = createActor(machine).start() - - expect(actorRef.getSnapshot().value).toBe('bar') - }) - - it(`should go to the configured default target when a history state is the initial state of the transition's target`, () => { - const machine = createMachine({ - initial: 'foo', - states: { - foo: { - on: { - NEXT: { target: 'bar' }, - }, - }, - bar: { - initial: 'baz', - states: { - baz: { - type: 'history', - target: 'qwe', - }, - qwe: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'NEXT' }) - - expect(actorRef.getSnapshot().value).toEqual({ - bar: 'qwe', - }) - }) - - it('should enter a legal multi-target default for deep parallel history', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { on: { POWER: { target: 'on.hist' } } }, - on: { - type: 'parallel', - states: { - A: { initial: 'B', states: { B: {}, C: {} } }, - K: { initial: 'L', states: { L: {}, M: {} } }, - hist: { - type: 'history', - history: 'deep', - target: ['A.C', 'K.M'], - }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { A: 'C', K: 'M' }, - }) - }) - - it('should execute parent entry actions when a history default is used before its parent was visited', () => { - const spy = vi.fn() - - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { NEXT: { target: '#hist' } }, - }, - b: { - // initial: { - // target: 'b1', - // actions: spy - // }, - entry: (_, enq) => enq(spy), - initial: 'b1', - states: { - b1: {}, - b2: { - id: 'hist', - type: 'history', - target: 'b1', - }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'NEXT' }) - - expect(spy).toHaveBeenCalledTimes(1) - }) - - it('should enter a deep parallel history default before its parent was visited', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { GO: { target: 'on.hist' } }, - }, - on: { - type: 'parallel', - states: { - regA: { initial: 'a1', states: { a1: {}, a2: {} } }, - regB: { initial: 'b1', states: { b1: {}, b2: {} } }, - hist: { - type: 'history', - history: 'deep', - target: ['regA.a1', 'regB.b1'], - }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'GO' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { regA: 'a1', regB: 'b1' }, - }) - }) - - it('should enter a shallow parallel history default before its parent was visited', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { GO: { target: 'on.hist' } }, - }, - on: { - type: 'parallel', - states: { - regA: { initial: 'a1', states: { a1: {}, a2: {} } }, - regB: { initial: 'b1', states: { b1: {}, b2: {} } }, - hist: { - type: 'history', - history: 'shallow', - target: ['regA', 'regB'], - }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'GO' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { regA: 'a1', regB: 'b1' }, - }) - }) - - // TODO: discuss - the workaround is that the entry action should be - // on the b1 state node instead of the b state node - it.skip('should not execute actions of the initial transition when a history state with a default target is targeted and its parent state was never visited yet', () => { - const spy = vi.fn() - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { NEXT: { target: '#hist' } }, - }, - b: { - // initial: { - // target: 'b1', - // actions: spy - // }, - entry: (_, enq) => enq(spy), - initial: 'b1', - states: { - b1: {}, - b2: { - id: 'hist', - type: 'history', - target: 'b3', - }, - b3: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'NEXT' }) - - expect(spy).not.toHaveBeenCalled() - }) - - it('should execute entry actions of a parent of the targeted history state when its parent state was never visited yet', () => { - const spy = vi.fn() - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { NEXT: { target: '#hist' } }, - }, - b: { - entry: (args, enq) => { - enq(spy) - }, - initial: 'b1', - states: { - b1: {}, - b2: { - id: 'hist', - type: 'history', - target: 'b3', - }, - b3: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'NEXT' }) - - expect(spy).toHaveBeenCalledTimes(1) - }) - - it('should execute actions of the initial transition when it select a history state as the initial state of its parent', () => { - const spy = vi.fn() - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { NEXT: { target: 'b' } }, - }, - b: { - // initial: { - // target: 'b1', - // actions: spy - // }, - entry: (_, enq) => enq(spy), - initial: 'b1', - states: { - b1: { - id: 'hist', - type: 'history', - target: 'b2', - }, - b2: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'NEXT' }) - - expect(spy).toHaveBeenCalledTimes(1) - }) - - // TODO: discuss - the workaround is that the entry action should be - // on the b1 state node instead of the b state node - it.skip('should execute parent entry actions when recorded history is restored', () => { - const spy = vi.fn() - - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { NEXT: { target: '#hist' } }, - }, - b: { - // initial: { - // target: 'b1', - // actions: spy - // }, - entry: (_, enq) => enq(spy), - initial: 'b1', - states: { - b1: {}, - b2: { - id: 'hist', - type: 'history', - target: 'b1', - }, - }, - on: { - NEXT: { target: 'a' }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'NEXT' }) - spy.mockClear() - - actorRef.send({ type: 'NEXT' }) - actorRef.send({ type: 'NEXT' }) - - expect(spy).toHaveBeenCalledTimes(0) - }) - - // TODO: discuss - the workaround is that the entry action should be - // on the b1 state node instead of the b state node - it.skip('should not execute actions of the initial transition when a history state with a default target is targeted and its parent state was already visited', () => { - const spy = vi.fn() - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { NEXT: { target: '#hist' } }, - }, - b: { - // initial: { - // target: 'b1', - // actions: spy - // }, - entry: (_, enq) => enq(spy), - initial: 'b1', - states: { - b1: {}, - b2: { - id: 'hist', - type: 'history', - target: 'b3', - }, - b3: {}, - }, - on: { - NEXT: { target: 'a' }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'NEXT' }) - spy.mockClear() - - actorRef.send({ type: 'NEXT' }) - actorRef.send({ type: 'NEXT' }) - - expect(spy).not.toHaveBeenCalled() - }) - - it('should execute entry actions of a parent of the targeted history state when its parent state was already visited', () => { - const spy = vi.fn() - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { NEXT: { target: '#hist' } }, - }, - b: { - entry: (args, enq) => { - enq(spy) - }, - initial: 'b1', - states: { - b1: {}, - b2: { - id: 'hist', - type: 'history', - target: 'b3', - }, - b3: {}, - }, - on: { - NEXT: { target: 'a' }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'NEXT' }) - spy.mockClear() - - actorRef.send({ type: 'NEXT' }) - actorRef.send({ type: 'NEXT' }) - - expect(spy).toHaveBeenCalledTimes(1) - }) - - it('should invoke an actor when reentering the stored configuration through the history state', () => { - const spy = vi.fn() - - const machine = createMachine({ - initial: 'running', - states: { - running: { - on: { - PING: { - target: 'refresh', - }, - }, - invoke: { - src: createCallbackLogic(spy), - }, - }, - refresh: { - type: 'history', - target: 'running', - }, - }, - }) - const actorRef = createActor(machine).start() - spy.mockClear() - - actorRef.send({ type: 'PING' }) - - expect(spy).toHaveBeenCalledTimes(1) - }) - - it('should not enter ancestors of the entered history state that lie outside of the transition domain when entering the default history configuration', () => { - const machine = createMachine({ - initial: 'closed', - states: { - closed: { - on: { - 'BUTTON.CLICK': { target: 'open.hist' }, - }, - }, - open: { - on: { - 'BUTTON.CLICK': { target: 'closed' }, - }, - initial: 'first', - states: { - hist: { type: 'history', target: 'first' }, - first: {}, - second: {}, - }, - }, - }, - }) - - const flushTracked = trackEntries(machine) - - const actorRef = createActor(machine).start() - flushTracked() - - actorRef.send({ type: 'BUTTON.CLICK' }) - expect(flushTracked()).toEqual([ - 'exit: closed', - 'enter: open', - 'enter: open.first', - ]) - }) - - it('should not enter ancestors of the entered history state that lie outside of the transition domain when restoring the stored history configuration', () => { - const machine = createMachine({ - initial: 'closed', - states: { - closed: { - id: 'closed', - on: { - 'BUTTON.CLICK': { target: 'open.hist' }, - }, - }, - open: { - on: { - 'BUTTON.CLICK': { target: 'closed' }, - }, - initial: 'first', - states: { - hist: { type: 'history', target: 'first' }, - first: { - on: { - NEXT: { target: 'second' }, - }, - }, - second: { - on: { - CLOSE: { target: '#closed' }, - }, - }, - }, - }, - }, - }) - - const flushTracked = trackEntries(machine) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'BUTTON.CLICK' }) - actorRef.send({ type: 'NEXT' }) - actorRef.send({ type: 'CLOSE' }) - - flushTracked() - - actorRef.send({ type: 'BUTTON.CLICK' }) - expect(flushTracked()).toEqual([ - 'exit: closed', - 'enter: open', - 'enter: open.second', - ]) - }) -}) - -describe('deep history states', () => { - it('should go to the shallow history', () => { - const machine = createMachine({ - initial: 'on', - states: { - off: { - on: { - POWER: { target: 'on.history' }, - }, - }, - on: { - initial: 'first', - states: { - first: { - on: { SWITCH: { target: 'second' } }, - }, - second: { - initial: 'A', - states: { - A: { - on: { INNER: { target: 'B' } }, - }, - B: { - initial: 'P', - states: { - P: {}, - Q: {}, - }, - }, - }, - }, - history: { history: 'shallow', target: 'first' }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'INNER' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { - second: 'A', - }, - }) - }) - - it('should go to the deep history (explicit)', () => { - const machine = createMachine({ - initial: 'on', - states: { - off: { - on: { - POWER: { target: 'on.history' }, - }, - }, - on: { - initial: 'first', - states: { - first: { - on: { SWITCH: { target: 'second' } }, - }, - second: { - initial: 'A', - states: { - A: { - on: { INNER: { target: 'B' } }, - }, - B: { - initial: 'P', - states: { - P: {}, - Q: {}, - }, - }, - }, - }, - history: { history: 'deep', target: 'first' }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'INNER' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { - second: { - B: 'P', - }, - }, - }) - }) - - it('should go to the deepest history', () => { - const machine = createMachine({ - initial: 'on', - states: { - off: { - on: { - POWER: { target: 'on.history' }, - }, - }, - on: { - initial: 'first', - states: { - first: { - on: { SWITCH: { target: 'second' } }, - }, - second: { - initial: 'A', - states: { - A: { - on: { INNER: { target: 'B' } }, - }, - B: { - initial: 'P', - states: { - P: { - on: { INNER: { target: 'Q' } }, - }, - Q: {}, - }, - }, - }, - }, - history: { history: 'deep', target: 'first' }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'INNER' }) - actorRef.send({ type: 'INNER' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { - second: { - B: 'Q', - }, - }, - }) - }) -}) - -describe('parallel history states', () => { - it('should ignore parallel state history', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { - SWITCH: { target: 'on' }, - POWER: { target: 'on.hist' }, - }, - }, - on: { - type: 'parallel', - states: { - A: { - initial: 'B', - states: { - B: { - on: { INNER_A: { target: 'C' } }, - }, - C: { - initial: 'D', - states: { - D: {}, - E: {}, - }, - }, - hist: { history: true, target: 'B' }, - }, - }, - K: { - initial: 'L', - states: { - L: {}, - M: {}, - hist: { history: true, target: 'L' }, - deepHistory: { - history: 'deep', - target: 'L', - }, - }, - }, - hist: { - history: true, - target: ['A', 'K'], - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { - A: 'B', - K: 'L', - }, - }) - }) - - it('should remember first level state history', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { - SWITCH: { target: 'on' }, - DEEP_POWER: { target: 'on.deepHistory' }, - }, - }, - on: { - type: 'parallel', - states: { - A: { - initial: 'B', - states: { - B: { - on: { INNER_A: { target: 'C' } }, - }, - C: { - initial: 'D', - states: { - D: {}, - E: {}, - }, - }, - hist: { history: true, target: 'B' }, - deepHistory: { - history: 'deep', - target: 'B', - }, - }, - }, - K: { - initial: 'L', - states: { - L: {}, - M: {}, - hist: { history: true, target: 'L' }, - deepHistory: { - history: 'deep', - target: 'L', - }, - }, - }, - deepHistory: { - history: 'deep', - target: ['A.B', 'K.L'], - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'DEEP_POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { - A: { - C: 'D', - }, - K: 'L', - }, - }) - }) - - it('should re-enter each regions of parallel state correctly', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { - SWITCH: { target: 'on' }, - DEEP_POWER: { target: 'on.deepHistory' }, - }, - }, - on: { - type: 'parallel', - states: { - A: { - initial: 'B', - states: { - B: { - on: { INNER_A: { target: 'C' } }, - }, - C: { - initial: 'D', - states: { - D: { - on: { INNER_A: { target: 'E' } }, - }, - E: {}, - }, - }, - hist: { history: true, target: 'B' }, - deepHistory: { - history: 'deep', - target: 'B', - }, - }, - }, - K: { - initial: 'L', - states: { - L: { - on: { INNER_K: { target: 'M' } }, - }, - M: { - initial: 'N', - states: { - N: { - on: { INNER_K: { target: 'O' } }, - }, - O: {}, - }, - }, - hist: { history: true, target: 'L' }, - deepHistory: { - history: 'deep', - target: 'L', - }, - }, - }, - hist: { - history: true, - target: ['A', 'K'], - }, - shallowHistory: { - history: 'shallow', - target: ['A', 'K'], - }, - deepHistory: { - history: 'deep', - target: ['A.B', 'K.L'], - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'INNER_K' }) - actorRef.send({ type: 'INNER_K' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'DEEP_POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { - A: { C: 'E' }, - K: { M: 'O' }, - }, - }) - }) - - it('should re-enter multiple history states', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { - SWITCH: { target: 'on' }, - PARALLEL_HISTORY: { - target: ['on.A.hist', 'on.K.hist'], - }, - }, - }, - on: { - type: 'parallel', - states: { - A: { - initial: 'B', - states: { - B: { - on: { INNER_A: { target: 'C' } }, - }, - C: { - initial: 'D', - states: { - D: { - on: { INNER_A: { target: 'E' } }, - }, - E: {}, - }, - }, - hist: { history: true, target: 'B' }, - deepHistory: { - history: 'deep', - target: 'B', - }, - }, - }, - K: { - initial: 'L', - states: { - L: { - on: { INNER_K: { target: 'M' } }, - }, - M: { - initial: 'N', - states: { - N: { - on: { INNER_K: { target: 'O' } }, - }, - O: {}, - }, - }, - hist: { history: true, target: 'L' }, - deepHistory: { - history: 'deep', - target: 'L', - }, - }, - }, - hist: { - history: true, - target: ['A', 'K'], - }, - shallowHistory: { - history: 'shallow', - target: ['A', 'K'], - }, - deepHistory: { - history: 'deep', - target: ['A.B', 'K.L'], - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'INNER_K' }) - actorRef.send({ type: 'INNER_K' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'PARALLEL_HISTORY' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { - A: { C: 'D' }, - K: { M: 'N' }, - }, - }) - }) - - it('should re-enter a parallel with partial history', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { - SWITCH: { target: 'on' }, - PARALLEL_SOME_HISTORY: { - target: ['on.A.C', 'on.K.hist'], - }, - }, - }, - on: { - type: 'parallel', - states: { - A: { - initial: 'B', - states: { - B: { - on: { INNER_A: { target: 'C' } }, - }, - C: { - initial: 'D', - states: { - D: { - on: { INNER_A: { target: 'E' } }, - }, - E: {}, - }, - }, - hist: { history: true, target: 'B' }, - deepHistory: { - history: 'deep', - target: 'B', - }, - }, - }, - K: { - initial: 'L', - states: { - L: { - on: { INNER_K: { target: 'M' } }, - }, - M: { - initial: 'N', - states: { - N: { - on: { INNER_K: { target: 'O' } }, - }, - O: {}, - }, - }, - hist: { history: true, target: 'L' }, - deepHistory: { - history: 'deep', - target: 'L', - }, - }, - }, - hist: { - history: true, - target: ['A', 'K'], - }, - shallowHistory: { - history: 'shallow', - target: ['A', 'K'], - }, - deepHistory: { - history: 'deep', - target: ['A.B', 'K.L'], - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'INNER_K' }) - actorRef.send({ type: 'INNER_K' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'PARALLEL_SOME_HISTORY' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { - A: { C: 'D' }, - K: { M: 'N' }, - }, - }) - }) - - it('should re-enter a parallel with full history', () => { - const machine = createMachine({ - initial: 'off', - states: { - off: { - on: { - SWITCH: { target: 'on' }, - PARALLEL_DEEP_HISTORY: { - target: ['on.A.deepHistory', 'on.K.deepHistory'], - }, - }, - }, - on: { - type: 'parallel', - states: { - A: { - initial: 'B', - states: { - B: { - on: { INNER_A: { target: 'C' } }, - }, - C: { - initial: 'D', - states: { - D: { - on: { INNER_A: { target: 'E' } }, - }, - E: {}, - }, - }, - hist: { history: true, target: 'B' }, - deepHistory: { - history: 'deep', - target: 'B', - }, - }, - }, - K: { - initial: 'L', - states: { - L: { - on: { INNER_K: { target: 'M' } }, - }, - M: { - initial: 'N', - states: { - N: { - on: { INNER_K: { target: 'O' } }, - }, - O: {}, - }, - }, - hist: { history: true, target: 'L' }, - deepHistory: { - history: 'deep', - target: 'L', - }, - }, - }, - hist: { - history: true, - target: ['A', 'K'], - }, - shallowHistory: { - history: 'shallow', - target: ['A', 'K'], - }, - deepHistory: { - history: 'deep', - target: ['A.B', 'K.L'], - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'INNER_A' }) - actorRef.send({ type: 'INNER_K' }) - actorRef.send({ type: 'INNER_K' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'PARALLEL_DEEP_HISTORY' }) - - expect(actorRef.getSnapshot().value).toEqual({ - on: { - A: { C: 'E' }, - K: { M: 'O' }, - }, - }) - }) -}) - -it('internal transition to a history state should enter default history state configuration if the containing state has never been exited yet', () => { - const service = createActor( - createMachine({ - initial: 'first', - states: { - first: { - on: { - NEXT: { target: 'second.other' }, - }, - }, - second: { - initial: 'nested', - states: { - nested: {}, - other: {}, - hist: { - history: true, - target: 'nested', - }, - }, - on: { - NEXT: { - target: '.hist', - }, - }, - }, - }, - }), - ).start() - - service.send({ type: 'NEXT' }) - service.send({ type: 'NEXT' }) - - expect(service.getSnapshot().value).toEqual({ - second: 'nested', - }) -}) - -describe('multistage history states', () => { - it('should go to the most recently visited state', () => { - const machine = createMachine({ - initial: 'running', - states: { - running: { - initial: 'normal', - states: { - normal: { - on: { SWITCH_TURBO: { target: 'turbo' } }, - }, - turbo: { - on: { SWITCH_TURBO: { target: 'normal' } }, - }, - H: { - history: true, - target: 'normal', - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - starting: { - on: { STARTED: { target: 'running.H' } }, - }, - off: { - on: { POWER: { target: 'starting' } }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'SWITCH_TURBO' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'POWER' }) - actorRef.send({ type: 'STARTED' }) - - expect(actorRef.getSnapshot().value).toEqual({ - running: 'turbo', - }) - }) -}) - -describe('revive history states', () => { - const machine = createMachine({ - initial: 'on', - states: { - on: { - initial: 'first', - states: { - first: { - on: { SWITCH: { target: 'second' } }, - }, - second: {}, - hist: { - type: 'history', - target: 'first', - }, - }, - on: { - POWER: { target: 'off' }, - }, - }, - off: { - on: { POWER: { target: 'on.hist' } }, - }, - }, - }) - - const sourceRef = createActor(machine).start() - - sourceRef.send({ type: 'SWITCH' }) - sourceRef.send({ type: 'POWER' }) - - const persistedSnapshot = JSON.parse( - JSON.stringify(sourceRef.getPersistedSnapshot()), - ) - const snapshot = sourceRef.getSnapshot() - - sourceRef.stop() - - it('should restore from stringified snapshot', () => { - expect(persistedSnapshot.value).toBe('off') - - const actorRef = createActor(machine, { - snapshot: persistedSnapshot, - }).start() - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ on: 'second' }) - }) - - it('should ignore unresolved ids as-is and log a warning', () => { - const consoleSpy = vi.spyOn(console, 'warn') - const fakeSnapshot = { - ...persistedSnapshot, - historyValue: { ['(machine).on.hist']: [{ id: 'nonexistent' }] }, - } - expect(fakeSnapshot.value).toBe('off') - - const actorRef = createActor(machine, { - snapshot: fakeSnapshot, - }).start() - actorRef.send({ type: 'POWER' }) - - expect(consoleSpy).toHaveBeenCalledWith( - 'Could not resolve StateNode for id: nonexistent', - ) - expect(actorRef.getSnapshot().value).toEqual({ on: 'first' }) - expect((actorRef.getPersistedSnapshot() as any).historyValue).toEqual({}) - consoleSpy.mockRestore() - }) - - it('should not re-resolve already-instantiated StateNode', () => { - expect(snapshot.value).toBe('off') - const historyNodes = snapshot.historyValue?.['(machine).on.hist'] - if (historyNodes === undefined) { - throw new Error('expected history value for (machine).on.hist') - } - expect(historyNodes[0]).toBeInstanceOf(StateNode) - - const actorRef = createActor(machine, { - snapshot, - }).start() - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ on: 'second' }) - }) - - it('should handle null, undefined, and primitive values', () => { - ;[null, undefined, 42, 'foo', true, false].forEach((val) => { - const fakeSnapshot = { ...persistedSnapshot, historyValue: val } - expect(fakeSnapshot.value).toBe('off') - - const actorRef = createActor(machine, { - snapshot: fakeSnapshot, - }).start() - actorRef.send({ type: 'POWER' }) - - expect(actorRef.getSnapshot().value).toEqual({ on: 'first' }) - expect((actorRef.getPersistedSnapshot() as any).historyValue).toEqual({}) - }) - }) -}) diff --git a/packages/xstate/tsconfig.tsgo.json b/packages/xstate/tsconfig.tsgo.json index 7ddb911..a3bb87c 100644 --- a/packages/xstate/tsconfig.tsgo.json +++ b/packages/xstate/tsconfig.tsgo.json @@ -8,5 +8,13 @@ "customConditions": ["@systemfsoftware/source"], "noEmit": true }, - "include": ["src/assert.ts", "src/stateMatching.ts", "src/mapState.ts", "src/fsm.ts", "src/graph"] + "include": [ + "src/assert.ts", + "src/stateMatching.ts", + "src/mapState.ts", + "src/fsm.ts", + "src/graph", + "src/historyRecall.ts", + "src/stateNodePredicates.ts" + ] } From e388f9dc3792f19bc5e7aa6834039b773de14136 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sat, 10 Oct 2026 00:25:57 +0000 Subject: [PATCH 2/3] test(xstate): test history resolution as a conformance spec tests/history.conformance.test.ts judges the published history resolution against the hand-written model in tests/__fixtures__/history.model.ts, over generated topologies, declaration forms, depths and event sequences on three seeds, through a live actor, the pure transition API and a driver that restarts from the persisted snapshot before every event. A liveness ledger proves each history form, depth and route was exercised, and two planted subjects (all-shallow declarations, a stripped history value) are rejected as model divergences. Fixed scenarios pin the creation-time refusal, history as an initial state, eventless detours, sibling and self-history reentry, invoke restart, revival of unresolved ids and of malformed history values, and the six reentering self-history shapes whose trace main got wrong. test/history.test.ts is deleted, and leaves the unguarded lists. --- packages/unguarded-tests.json | 1 - .../tests/__fixtures__/history.model.ts | 627 ++++++++++++++++++ packages/xstate/tests/__fixtures__/history.ts | 621 +++++++++++++++++ .../xstate/tests/history.conformance.test.ts | 456 +++++++++++++ packages/xstate/vitest.config.ts | 1 - 5 files changed, 1704 insertions(+), 2 deletions(-) create mode 100644 packages/xstate/tests/__fixtures__/history.model.ts create mode 100644 packages/xstate/tests/__fixtures__/history.ts create mode 100644 packages/xstate/tests/history.conformance.test.ts diff --git a/packages/unguarded-tests.json b/packages/unguarded-tests.json index 391b12b..451b025 100644 --- a/packages/unguarded-tests.json +++ b/packages/unguarded-tests.json @@ -94,7 +94,6 @@ "packages/xstate/test/final.test.ts", "packages/xstate/test/firstTenMinutes.v6.test.ts", "packages/xstate/test/guards.test.ts", - "packages/xstate/test/history.test.ts", "packages/xstate/test/id.test.ts", "packages/xstate/test/initial.test.ts", "packages/xstate/test/input.test.ts", diff --git a/packages/xstate/tests/__fixtures__/history.model.ts b/packages/xstate/tests/__fixtures__/history.model.ts new file mode 100644 index 0000000..5d509d8 --- /dev/null +++ b/packages/xstate/tests/__fixtures__/history.model.ts @@ -0,0 +1,627 @@ +import { Match, Schema } from 'effect' + +export type HistoryDepth = 'shallow' | 'deep' + +export const HistoryForm = Schema.Literals([ + 'type-history', + 'type-shallow', + 'history-true', + 'history-shallow', + 'type-deep', + 'history-deep', +]) +export type HistoryForm = Schema.Schema.Type + +const SHALLOW_FORMS: ReadonlyArray = ['type-history', 'type-shallow', 'history-true', 'history-shallow'] + +export const depthOfForm = (form: HistoryForm): HistoryDepth => SHALLOW_FORMS.includes(form) ? 'shallow' : 'deep' + +export const OnHTarget = Schema.Literals(['a', 'b', 'b.b2.y', 'p', 'p.r.r2.v', '[p.r.r2|p.s.s2]']) +export type OnHTarget = Schema.Schema.Type +export const BHTarget = Schema.Literals(['b1', 'b2', 'b2.y']) +export type BHTarget = Schema.Schema.Type +export const PHTarget = Schema.Literals(['r', 's.s2', '[r.r2.v|s.s2]', '[r|s]']) +export type PHTarget = Schema.Schema.Type +export const RHTarget = Schema.Literals(['r1', 'r2', 'r2.v']) +export type RHTarget = Schema.Schema.Type + +const OnHDeclaration = Schema.Struct({ form: HistoryForm, target: OnHTarget }) +const BHDeclaration = Schema.Struct({ form: HistoryForm, target: BHTarget }) +const PHDeclaration = Schema.Struct({ form: HistoryForm, target: PHTarget }) +const RHDeclaration = Schema.Struct({ form: HistoryForm, target: RHTarget }) + +export const HistoryEvent = Schema.Literals([ + 'ON', + 'OFF', + 'ON_H', + 'B_H', + 'P_H', + 'R_H', + 'RS_H', + 'REH', + 'MID', + 'TO_B', + 'TO_P', + 'TO_A', + 'NEXT', + 'XY', + 'R', + 'UV', + 'S', +]) +export type HistoryEvent = Schema.Schema.Type + +export const HistoryDriver = Schema.Literals(['actor', 'pure', 'persisted']) +export type HistoryDriver = Schema.Schema.Type + +export const HistoryCommand = Schema.Struct({ + onH: OnHDeclaration, + bH: BHDeclaration, + pH: PHDeclaration, + rH: RHDeclaration, + events: Schema.Array(HistoryEvent), + driver: HistoryDriver, +}) +export type HistoryCommand = Schema.Schema.Type + +export const targetPaths = (target: string): ReadonlyArray => + target.startsWith('[') ? target.slice(1, -1).split('|') : [target] + +export type HistoryDeclaration = + | { readonly type: 'history'; readonly target: string | string[]; readonly history?: 'shallow' | 'deep' } + | { readonly history: true | 'shallow' | 'deep'; readonly target: string | string[] } + +const declarationBuilders: Record HistoryDeclaration> = { + 'type-history': (to) => ({ type: 'history', target: to }), + 'type-shallow': (to) => ({ type: 'history', history: 'shallow', target: to }), + 'history-true': (to) => ({ history: true, target: to }), + 'history-shallow': (to) => ({ history: 'shallow', target: to }), + 'type-deep': (to) => ({ type: 'history', history: 'deep', target: to }), + 'history-deep': (to) => ({ history: 'deep', target: to }), +} + +export interface HistoryDeclarationRequest { + readonly form: HistoryForm + readonly target: string +} + +export const declarationOf = ({ form, target }: HistoryDeclarationRequest): HistoryDeclaration => { + const paths = targetPaths(target) + const to = paths.length === 1 ? paths[0] ?? target : [...paths] + return declarationBuilders[form](to) +} + +const join = (parent: string, path: string): string => parent === '' ? path : `${parent}.${path}` + +export interface HistoryNodeSpec { + readonly depth: HistoryDepth + readonly form: HistoryForm + readonly defaultTargets: ReadonlyArray +} + +export interface HistorySpec { + readonly onH: HistoryNodeSpec + readonly bH: HistoryNodeSpec + readonly pH: HistoryNodeSpec + readonly rH: HistoryNodeSpec +} + +const nodeSpec = (parent: string, form: HistoryForm, target: string): HistoryNodeSpec => ({ + depth: depthOfForm(form), + form, + defaultTargets: targetPaths(target).map((path) => join(parent, path)), +}) + +export const specOf = (command: HistoryCommand): HistorySpec => ({ + onH: nodeSpec('on', command.onH.form, command.onH.target), + bH: nodeSpec('on.b', command.bH.form, command.bH.target), + pH: nodeSpec('on.p', command.pH.form, command.pH.target), + rH: nodeSpec('on.p.r', command.rH.form, command.rH.target), +}) + +type NodeKind = 'atomic' | 'compound' | 'parallel' | 'history' + +interface Transition { + readonly targets: ReadonlyArray + readonly internal?: boolean + readonly reenter?: boolean +} + +interface Node { + readonly key: string + readonly local: string + readonly kind: NodeKind + readonly parent: string | undefined + readonly children: ReadonlyArray + readonly initial: string | undefined + readonly on: Readonly> + readonly historyDepth?: HistoryDepth + readonly historyDefault?: ReadonlyArray +} + +export type Topology = Readonly> + +const ROOT = '' + +interface FixedNode { + readonly key: string + readonly kind: NodeKind + readonly parent: string + readonly children?: ReadonlyArray + readonly initial?: string + readonly on?: Readonly> +} + +const fixedNodes: ReadonlyArray = [ + { key: ROOT, kind: 'compound', parent: ROOT, children: ['off', 'mid', 'on'], initial: 'off' }, + { + key: 'off', + kind: 'atomic', + parent: ROOT, + on: { + ON: { targets: ['on'] }, + ON_H: { targets: ['on.onH'] }, + B_H: { targets: ['on.b.bH'] }, + P_H: { targets: ['on.p.pH'] }, + R_H: { targets: ['on.p.r.rH'] }, + RS_H: { targets: ['on.p.r.rH', 'on.p.s.s2'] }, + MID: { targets: ['mid'] }, + }, + }, + { + key: 'mid', + kind: 'atomic', + parent: ROOT, + on: { ON_H: { targets: ['on.onH'] }, B_H: { targets: ['on.b.bH'] }, P_H: { targets: ['on.p.pH'] } }, + }, + { + key: 'on', + kind: 'compound', + parent: ROOT, + children: ['on.a', 'on.b', 'on.p', 'on.onH'], + initial: 'on.a', + on: { + OFF: { targets: ['off'] }, + REH: { targets: ['on.onH'], internal: true }, + }, + }, + { key: 'on.a', kind: 'atomic', parent: 'on', on: { TO_B: { targets: ['on.b'] }, TO_P: { targets: ['on.p'] } } }, + { + key: 'on.b', + kind: 'compound', + parent: 'on', + children: ['on.b.b1', 'on.b.b2', 'on.b.bH'], + initial: 'on.b.b1', + on: { TO_A: { targets: ['on.a'] } }, + }, + { key: 'on.b.b1', kind: 'atomic', parent: 'on.b', on: { NEXT: { targets: ['on.b.b2'] } } }, + { + key: 'on.b.b2', + kind: 'compound', + parent: 'on.b', + children: ['on.b.b2.x', 'on.b.b2.y'], + initial: 'on.b.b2.x', + on: { NEXT: { targets: ['on.b.b1'] } }, + }, + { key: 'on.b.b2.x', kind: 'atomic', parent: 'on.b.b2', on: { XY: { targets: ['on.b.b2.y'] } } }, + { key: 'on.b.b2.y', kind: 'atomic', parent: 'on.b.b2', on: { XY: { targets: ['on.b.b2.x'] } } }, + { + key: 'on.p', + kind: 'parallel', + parent: 'on', + children: ['on.p.r', 'on.p.s', 'on.p.pH'], + on: { TO_A: { targets: ['on.a'] } }, + }, + { + key: 'on.p.r', + kind: 'compound', + parent: 'on.p', + children: ['on.p.r.r1', 'on.p.r.r2', 'on.p.r.rH'], + initial: 'on.p.r.r1', + }, + { key: 'on.p.r.r1', kind: 'atomic', parent: 'on.p.r', on: { R: { targets: ['on.p.r.r2'] } } }, + { + key: 'on.p.r.r2', + kind: 'compound', + parent: 'on.p.r', + children: ['on.p.r.r2.u', 'on.p.r.r2.v'], + initial: 'on.p.r.r2.u', + on: { R: { targets: ['on.p.r.r1'] } }, + }, + { key: 'on.p.r.r2.u', kind: 'atomic', parent: 'on.p.r.r2', on: { UV: { targets: ['on.p.r.r2.v'] } } }, + { key: 'on.p.r.r2.v', kind: 'atomic', parent: 'on.p.r.r2', on: { UV: { targets: ['on.p.r.r2.u'] } } }, + { key: 'on.p.s', kind: 'compound', parent: 'on.p', children: ['on.p.s.s1', 'on.p.s.s2'], initial: 'on.p.s.s1' }, + { key: 'on.p.s.s1', kind: 'atomic', parent: 'on.p.s', on: { S: { targets: ['on.p.s.s2'] } } }, + { key: 'on.p.s.s2', kind: 'atomic', parent: 'on.p.s', on: { S: { targets: ['on.p.s.s1'] } } }, +] + +const historyParents: Readonly> = { + 'on.onH': 'on', + 'on.b.bH': 'on.b', + 'on.p.pH': 'on.p', + 'on.p.r.rH': 'on.p.r', +} + +const localOf = (key: string): string => key.slice(key.lastIndexOf('.') + 1) + +const historyNode = (key: string, spec: HistoryNodeSpec): Node => ({ + key, + local: localOf(key), + kind: 'history', + parent: historyParents[key] ?? ROOT, + children: [], + initial: undefined, + on: {}, + historyDepth: spec.depth, + historyDefault: spec.defaultTargets, +}) + +const fixedNode = (node: FixedNode): Node => ({ + key: node.key, + local: localOf(node.key), + kind: node.kind, + parent: node.parent === ROOT && node.key === ROOT ? undefined : node.parent, + children: node.children ?? [], + initial: node.initial, + on: node.on ?? {}, +}) + +export const topologyOf = (spec: HistorySpec): Topology => { + const historySpec: Record = { + 'on.onH': spec.onH, + 'on.b.bH': spec.bH, + 'on.p.pH': spec.pH, + 'on.p.r.rH': spec.rH, + } + const entries = [ + ...fixedNodes.map((node): [string, Node] => [node.key, fixedNode(node)]), + ...Object.entries(historySpec).map(([key, spec]): [string, Node] => [key, historyNode(key, spec)]), + ] + return Object.fromEntries(entries) +} + +export const nodeId = (key: string): string => key === ROOT ? '(machine)' : `(machine).${key}` + +const node = (topology: Topology, key: string): Node => { + const found = topology[key] + if (found === undefined) { + throw new Error(`unknown state node ${key}`) + } + return found +} + +const order = (topology: Topology): Record => { + const visited: Record = {} + let next = 0 + const walk = (key: string): void => { + visited[key] = next++ + node(topology, key).children.forEach(walk) + } + walk(ROOT) + return visited +} + +const depth = (topology: Topology, key: string): number => { + let marker = node(topology, key).parent + let levels = 0 + while (marker !== undefined) { + levels += 1 + marker = node(topology, marker).parent + } + return levels +} + +const isStrictDescendant = (topology: Topology, child: string, parent: string): boolean => { + let marker = node(topology, child).parent + while (marker !== undefined && marker !== parent) { + marker = node(topology, marker).parent + } + return marker === parent +} + +const properAncestors = (topology: Topology, key: string, stop: string | undefined): ReadonlyArray => { + const ancestors: Array = [] + let marker = node(topology, key).parent + while (marker !== undefined && marker !== stop) { + ancestors.push(marker) + marker = node(topology, marker).parent + } + return ancestors +} + +const regions = (topology: Topology, key: string): ReadonlyArray => + node(topology, key).children.filter((child) => node(topology, child).kind !== 'history') + +const activeStrictDescendants = ( + topology: Topology, + active: ReadonlySet, + domain: string, +): ReadonlyArray => [...active].filter((key) => isStrictDescendant(topology, key, domain)) + +const activeChild = (topology: Topology, active: ReadonlySet, key: string): string | undefined => + node(topology, key).children.find((child) => active.has(child)) + +export interface SimulationState { + readonly active: ReadonlySet + readonly records: Readonly>> +} + +const initialSimulation = (): SimulationState => ({ active: new Set([ROOT, 'off']), records: {} }) + +const deepestHandler = (topology: Topology, active: ReadonlySet, event: string): string | undefined => { + const visited = order(topology) + const handlers = [...active].filter((key) => node(topology, key).on[event] !== undefined) + return handlers.sort((left, right) => + depth(topology, right) - depth(topology, left) || (visited[left] ?? 0) - (visited[right] ?? 0) + )[0] +} + +const narrowParallel = ( + topology: Topology, + domain: string, + targets: ReadonlyArray, +): string => { + let narrowed = domain + while (node(topology, narrowed).kind === 'parallel') { + const region = regions(topology, narrowed).find((candidate) => + targets.every((target) => target === candidate || isStrictDescendant(topology, target, candidate)) + ) + if (region === undefined) { + break + } + narrowed = region + } + return narrowed +} + +const domainOf = ( + topology: Topology, + handler: string, + targets: ReadonlyArray, + transition: Transition, +): string => { + const insideSource = targets.every((target) => target === handler || isStrictDescendant(topology, target, handler)) + if (transition.internal === true && insideSource) { + return handler + } + if (insideSource) { + return transition.reenter === true ? handler : narrowParallel(topology, handler, targets) + } + const head = targets[0] ?? handler + const rest = [...targets.slice(1), handler] + const ancestor = properAncestors(topology, head, undefined).find((candidate) => + rest.every((target) => isStrictDescendant(topology, target, candidate)) + ) + if (ancestor !== undefined) { + return transition.reenter === true ? ancestor : narrowParallel(topology, ancestor, targets) + } + return transition.reenter === true ? ROOT : narrowParallel(topology, ROOT, targets) +} + +const recordSetFor = (topology: Topology, active: ReadonlySet, historyKey: string): ReadonlyArray => { + const spec = node(topology, historyKey) + const parent = spec.parent ?? ROOT + const visited = order(topology) + return spec.historyDepth === 'deep' + ? [...active] + .filter((key) => node(topology, key).kind === 'atomic' && isStrictDescendant(topology, key, parent)) + .sort((left, right) => (visited[left] ?? 0) - (visited[right] ?? 0)) + : node(topology, parent).children.filter((child) => active.has(child)) +} + +const recordOnExit = ( + topology: Topology, + state: SimulationState, + exited: ReadonlySet, +): Readonly>> => { + const records: Record> = { ...state.records } + Object.keys(topology) + .filter((key) => node(topology, key).kind === 'history') + .forEach((historyKey) => { + const parent = node(topology, historyKey).parent ?? ROOT + if (exited.has(parent)) { + records[historyKey] = recordSetFor(topology, state.active, historyKey) + } + }) + return records +} + +const resolveTargets = ( + topology: Topology, + declared: ReadonlyArray, + records: Readonly>>, +): ReadonlyArray => + declared.flatMap((key) => { + const spec = node(topology, key) + if (spec.kind !== 'history') { + return [key] + } + const recorded = records[key] + return recorded !== undefined && recorded.length > 0 ? recorded : spec.historyDefault ?? [] + }) + +const enterStates = ( + topology: Topology, + handler: string, + declared: ReadonlyArray, + targets: ReadonlyArray, + domain: string, + reenter: boolean, + records: Readonly>>, +): ReadonlySet => { + const visited = order(topology) + const toEnter = new Set() + const hasDescendant = (parent: string): boolean => + [...toEnter].some((key) => isStrictDescendant(topology, key, parent)) + const addAncestorStates = (ancestors: ReadonlyArray, reentrancyDomain: string | undefined): void => { + ancestors.forEach((ancestor) => { + if (reentrancyDomain === undefined || isStrictDescendant(topology, ancestor, reentrancyDomain)) { + toEnter.add(ancestor) + } + if (node(topology, ancestor).kind === 'parallel') { + regions(topology, ancestor).forEach((region) => { + if (!hasDescendant(region)) { + toEnter.add(region) + addDescendantStates(region) + } + }) + } + }) + } + const addDescendantStates = (key: string): void => { + Match.value(node(topology, key).kind).pipe( + Match.when('history', () => { + const recorded = records[key] + const chosen = recorded !== undefined && recorded.length > 0 + ? recorded + : node(topology, key).historyDefault ?? [] + chosen.forEach((target) => { + toEnter.add(target) + addDescendantStates(target) + }) + chosen.forEach((target) => + addAncestorStates(properAncestors(topology, target, node(topology, key).parent), undefined) + ) + }), + Match.when('compound', () => { + const initial = node(topology, key).initial + if (initial !== undefined) { + if (node(topology, initial).kind !== 'history') { + toEnter.add(initial) + } + addDescendantStates(initial) + addAncestorStates(properAncestors(topology, initial, key), undefined) + } + }), + Match.when('parallel', () => { + regions(topology, key).forEach((region) => { + if (!hasDescendant(region)) { + toEnter.add(region) + addDescendantStates(region) + } + }) + }), + Match.orElse(() => {}), + ) + } + declared.forEach((target) => { + const isHistory = node(topology, target).kind === 'history' + if (!isHistory && !(handler === target && handler === domain && !reenter)) { + toEnter.add(target) + } + addDescendantStates(target) + }) + targets.forEach((target) => { + const ancestors = properAncestors(topology, target, domain) + const withDomain = node(topology, domain).kind === 'parallel' ? [...ancestors, domain] : ancestors + addAncestorStates(withDomain, node(topology, handler).parent === undefined && reenter ? undefined : domain) + }) + if (reenter && domain === handler) { + toEnter.add(handler) + } + return new Set([...toEnter].sort((left, right) => (visited[left] ?? 0) - (visited[right] ?? 0))) +} + +const step = ( + topology: Topology, + state: SimulationState, + event: string, +): { readonly state: SimulationState; readonly trace: ReadonlyArray } => { + const handler = deepestHandler(topology, state.active, event) + const transition = handler === undefined ? undefined : node(topology, handler).on[event] + if (handler === undefined || transition === undefined) { + return { state, trace: [] } + } + const visited = order(topology) + const declared = transition.targets.length > 1 ? [transition.targets[0] ?? handler] : transition.targets + const domain = domainOf(topology, handler, resolveTargets(topology, declared, state.records), transition) + const reenter = transition.reenter === true + const exitKeys = [...activeStrictDescendants(topology, state.active, domain)] + const exited = new Set(exitKeys) + if (reenter && domain === handler) { + exited.add(handler) + } + const exitedOrdered = [...exited].sort((left, right) => (visited[right] ?? 0) - (visited[left] ?? 0)) + const records = recordOnExit(topology, state, exited) + const targets = resolveTargets(topology, declared, records) + const entered = enterStates(topology, handler, declared, targets, domain, reenter, records) + const active = new Set([...state.active].filter((key) => !exited.has(key))) + entered.forEach((key) => active.add(key)) + return { + state: { active, records }, + trace: [ + ...exitedOrdered.map((key) => `exit:${node(topology, key).local}`), + ...[...entered].map((key) => `enter:${node(topology, key).local}`), + ], + } +} + +const subtreeValue = (topology: Topology, active: ReadonlySet, key: string): HistoryStateValue => { + const spec = node(topology, key) + if (spec.kind === 'parallel') { + return Object.fromEntries( + regions(topology, key).map((region) => [node(topology, region).local, subtreeValue(topology, active, region)]), + ) + } + if (spec.kind === 'atomic') { + return spec.local + } + const child = activeChild(topology, active, key) + if (child === undefined) { + return spec.local + } + return node(topology, child).kind === 'atomic' + ? node(topology, child).local + : { [node(topology, child).local]: subtreeValue(topology, active, child) } +} + +const historyValueOf = ( + topology: Topology, + records: Readonly>>, +): Readonly>> => + Object.fromEntries( + Object.keys(topology) + .filter((key) => node(topology, key).kind === 'history') + .flatMap((key) => { + const recorded = records[key] + return recorded === undefined || recorded.length === 0 + ? [] + : [[nodeId(key), [...recorded].map(nodeId).sort()] as const] + }) + .sort((left, right) => left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0), + ) + +export type HistoryStateValue = string | { readonly [key: string]: HistoryStateValue } + +export interface HistoryObservation { + readonly value: HistoryStateValue + readonly trace: ReadonlyArray + readonly historyValue: Readonly>> +} + +export const predict = (command: HistoryCommand): HistoryObservation => { + const topology = topologyOf(specOf(command)) + const reduced = command.events.reduce<{ readonly state: SimulationState; readonly trace: ReadonlyArray }>( + (accumulated, event) => { + const next = step(topology, accumulated.state, event) + return { state: next.state, trace: [...accumulated.trace, ...next.trace] } + }, + { state: initialSimulation(), trace: [] }, + ) + return { + value: subtreeValue(topology, reduced.state.active, ROOT), + trace: reduced.trace, + historyValue: historyValueOf(topology, reduced.state.records), + } +} + +const HistoryModelState = Schema.Struct({}) +export type HistoryModelState = Schema.Schema.Type + +export const historyModel = { + state: HistoryModelState, + initial: {}, + precondition: (): boolean => true, + step: (state: HistoryModelState, command: HistoryCommand): readonly [HistoryModelState, HistoryObservation] => + [state, predict(command)] as const, +} diff --git a/packages/xstate/tests/__fixtures__/history.ts b/packages/xstate/tests/__fixtures__/history.ts new file mode 100644 index 0000000..bb8c574 --- /dev/null +++ b/packages/xstate/tests/__fixtures__/history.ts @@ -0,0 +1,621 @@ +import { + type AnyActor, + type AnyMachineSnapshot, + type AnyStateMachine, + createActor, + createMachine, + initialTransition, + transition, +} from '@systemfsoftware/xstate' +import { Context, Effect, Layer } from 'effect' +import { + declarationOf, + type HistoryCommand, + type HistoryDeclaration, + type HistoryDriver, + type HistoryEvent, + type HistoryForm, + type HistoryObservation, + type HistoryStateValue, + specOf, + targetPaths, +} from './history.model.js' + +type TraceEnqueue = { emit: (event: { type: 'trace'; at: string }) => void } + +const enterTrace = (key: string) => (_: object, enq: TraceEnqueue) => { + enq.emit({ type: 'trace', at: `enter:${key}` }) +} + +const leaveTrace = (key: string) => (_: object, enq: TraceEnqueue) => { + enq.emit({ type: 'trace', at: `exit:${key}` }) +} + +interface Behaviour { + readonly allShallow: boolean + readonly stripHistoryValue: boolean +} + +const publishedBehaviour: Behaviour = { allShallow: false, stripHistoryValue: false } + +const declFor = (behaviour: Behaviour, form: HistoryForm, target: string) => + declarationOf({ form: behaviour.allShallow ? 'type-shallow' : form, target }) + +const machineOf = (config: object): AnyStateMachine => Reflect.apply(createMachine, undefined, [config]) + +export interface ReviveRequest { + readonly machine: object + readonly options: object +} + +export const reviveActor = ({ machine, options }: ReviveRequest): AnyActor => + Reflect.apply(createActor, undefined, [machine, options]) + +const buildMachine = (command: HistoryCommand, behaviour: Behaviour): AnyStateMachine => + machineOf({ + initial: 'off', + states: { + off: { + entry: enterTrace('off'), + exit: leaveTrace('off'), + on: { + ON: 'on', + ON_H: 'on.onH', + B_H: 'on.b.bH', + P_H: 'on.p.pH', + R_H: 'on.p.r.rH', + RS_H: ['on.p.r.rH', 'on.p.s.s2'], + MID: 'mid', + }, + }, + mid: { + entry: enterTrace('mid'), + exit: leaveTrace('mid'), + on: { ON_H: 'on.onH', B_H: 'on.b.bH', P_H: 'on.p.pH' }, + }, + on: { + entry: enterTrace('on'), + exit: leaveTrace('on'), + initial: 'a', + on: { OFF: 'off', REH: '.onH' }, + states: { + a: { entry: enterTrace('a'), exit: leaveTrace('a'), on: { TO_B: 'b', TO_P: 'p' } }, + b: { + entry: enterTrace('b'), + exit: leaveTrace('b'), + initial: 'b1', + on: { TO_A: 'a' }, + states: { + b1: { entry: enterTrace('b1'), exit: leaveTrace('b1'), on: { NEXT: 'b2' } }, + b2: { + entry: enterTrace('b2'), + exit: leaveTrace('b2'), + initial: 'x', + on: { NEXT: 'b1' }, + states: { + x: { entry: enterTrace('x'), exit: leaveTrace('x'), on: { XY: 'y' } }, + y: { entry: enterTrace('y'), exit: leaveTrace('y'), on: { XY: 'x' } }, + }, + }, + bH: declFor(behaviour, specOf(command).bH.form, command.bH.target), + }, + }, + p: { + entry: enterTrace('p'), + exit: leaveTrace('p'), + type: 'parallel', + on: { TO_A: 'a' }, + states: { + r: { + entry: enterTrace('r'), + exit: leaveTrace('r'), + initial: 'r1', + states: { + r1: { entry: enterTrace('r1'), exit: leaveTrace('r1'), on: { R: 'r2' } }, + r2: { + entry: enterTrace('r2'), + exit: leaveTrace('r2'), + initial: 'u', + on: { R: 'r1' }, + states: { + u: { entry: enterTrace('u'), exit: leaveTrace('u'), on: { UV: 'v' } }, + v: { entry: enterTrace('v'), exit: leaveTrace('v'), on: { UV: 'u' } }, + }, + }, + rH: declFor(behaviour, specOf(command).rH.form, command.rH.target), + }, + }, + s: { + entry: enterTrace('s'), + exit: leaveTrace('s'), + initial: 's1', + states: { + s1: { entry: enterTrace('s1'), exit: leaveTrace('s1'), on: { S: 's2' } }, + s2: { entry: enterTrace('s2'), exit: leaveTrace('s2'), on: { S: 's1' } }, + }, + }, + pH: declFor(behaviour, specOf(command).pH.form, command.pH.target), + }, + }, + onH: declFor(behaviour, specOf(command).onH.form, command.onH.target), + }, + }, + }, + }) + +type HistoryNodeKey = 'onH' | 'bH' | 'pH' | 'rH' + +const HISTORY_IDS: Record = { + onH: '(machine).on.onH', + bH: '(machine).on.b.bH', + pH: '(machine).on.p.pH', + rH: '(machine).on.p.r.rH', +} + +const RESTORE_TARGETS: Partial>> = { + ON_H: ['onH'], + B_H: ['bH'], + P_H: ['pH'], + R_H: ['rH'], + RS_H: ['rH'], +} + +const MULTISTAGE_EVENTS: ReadonlyArray = ['ON_H', 'B_H', 'P_H'] + +interface HistoryLedger { + restoredFromRecord: Record + restoredFromDefault: Record + deepRecordBelowChild: number + forms: Record + depths: Record + parallelMultiTargetDefault: number + regionHistoryBesidePlain: number + internalSelfHistoryNoRecord: number + multistageViaMid: number + drivers: Record + restartsWithHistory: number +} + +const EMPTY_FORMS: Record = { + 'type-history': 0, + 'type-shallow': 0, + 'history-true': 0, + 'history-shallow': 0, + 'type-deep': 0, + 'history-deep': 0, +} + +const emptyLedger = (): HistoryLedger => ({ + restoredFromRecord: { onH: 0, bH: 0, pH: 0, rH: 0 }, + restoredFromDefault: { onH: 0, bH: 0, pH: 0, rH: 0 }, + deepRecordBelowChild: 0, + forms: { ...EMPTY_FORMS }, + depths: { shallow: 0, deep: 0 }, + parallelMultiTargetDefault: 0, + regionHistoryBesidePlain: 0, + internalSelfHistoryNoRecord: 0, + multistageViaMid: 0, + drivers: { actor: 0, pure: 0, persisted: 0 }, + restartsWithHistory: 0, +}) + +const isLive = (observed: HistoryLedger): boolean => + [ + ...Object.values(observed.restoredFromRecord), + ...Object.values(observed.restoredFromDefault), + observed.deepRecordBelowChild, + ...Object.values(observed.forms), + ...Object.values(observed.depths), + observed.parallelMultiTargetDefault, + observed.regionHistoryBesidePlain, + observed.internalSelfHistoryNoRecord, + observed.multistageViaMid, + ...Object.values(observed.drivers), + observed.restartsWithHistory, + ].every((occurrences) => occurrences > 0) + +export type RecordedIds = Readonly>> + +type ForeignValue = object | string | number | boolean | null | undefined + +interface Probe { + readonly value: HistoryStateValue + readonly historyValue: RecordedIds +} + +type Observe = (event: HistoryEvent, before: Probe, after: Probe, restartedWithHistory: boolean) => void + +const stateValueOf = (foreign: Value): HistoryStateValue => { + if (typeof foreign === 'string') { + return foreign + } + if (typeof foreign !== 'object' || foreign === null) { + return '' + } + const entries: Array = [] + Reflect.ownKeys(foreign).forEach((key) => { + if (typeof key === 'string') { + entries.push([key, stateValueOf(Reflect.get(foreign, key))]) + } + }) + return Object.fromEntries(entries) +} + +const traceOf = (action: object): string | undefined => { + if (Reflect.get(action, 'kind') !== 'emit') { + return undefined + } + const event = Reflect.get(action, 'event') + if (typeof event !== 'object' || event === null) { + return undefined + } + const at = Reflect.get(event, 'at') + return typeof at === 'string' ? at : undefined +} + +const recordedIdOf = (item: ForeignValue): string => { + if (typeof item === 'string') { + return item + } + if (typeof item === 'object' && item !== null) { + const id = Reflect.get(item, 'id') + if (typeof id === 'string') { + return id + } + } + return '' +} + +const canonicalHistory = (historyValue: ForeignValue): RecordedIds => { + if (typeof historyValue !== 'object' || historyValue === null) { + return {} + } + const entries: Array]> = [] + Object.entries(historyValue).forEach(([key, list]) => { + if (Array.isArray(list)) { + entries.push([key, list.map(recordedIdOf).sort()] as const) + } + }) + return Object.fromEntries(entries.sort((left, right) => left[0] < right[0] ? -1 : 1)) +} + +const historyValueOfDocument = (document: object): ForeignValue => { + const found = Reflect.get(document, 'historyValue') + return typeof found === 'string' || typeof found === 'number' || typeof found === 'boolean' || + typeof found === 'object' + ? found + : undefined +} + +const probeOf = (actor: AnyActor): Probe => ({ + value: stateValueOf(actor.getSnapshot().value), + historyValue: canonicalHistory(historyValueOfDocument(actor.getPersistedSnapshot())), +}) + +export const valueOf = (actor: AnyActor): HistoryStateValue => stateValueOf(actor.getSnapshot().value) + +export const persistedHistoryValueOf = (actor: AnyActor): RecordedIds => + canonicalHistory(historyValueOfDocument(actor.getPersistedSnapshot())) + +const runActor = (machine: AnyStateMachine, command: HistoryCommand, observe: Observe): HistoryObservation => { + const actor: AnyActor = createActor(machine) + actor.start() + const trace: Array = [] + actor.on('trace', (event: { at: string }) => { + trace.push(event.at) + }) + command.events.forEach((event) => { + const before = probeOf(actor) + actor.send({ type: event }) + observe(event, before, probeOf(actor), false) + }) + const observation = probeOf(actor) + actor.stop() + return { value: observation.value, trace, historyValue: observation.historyValue } +} + +const runPure = (machine: AnyStateMachine, command: HistoryCommand, observe: Observe): HistoryObservation => { + let snapshot: AnyMachineSnapshot = initialTransition(machine)[0] + const trace: Array = [] + command.events.forEach((event) => { + const before: Probe = { + value: stateValueOf(snapshot.value), + historyValue: canonicalHistory(snapshot.historyValue), + } + const [next, actions] = transition(machine, snapshot, { type: event }) + actions.forEach((action) => { + const at = traceOf(action) + if (at !== undefined) { + trace.push(at) + } + }) + snapshot = next + observe(event, before, { + value: stateValueOf(snapshot.value), + historyValue: canonicalHistory(snapshot.historyValue), + }, false) + }) + return { + value: stateValueOf(snapshot.value), + trace, + historyValue: canonicalHistory(snapshot.historyValue), + } +} + +const runPersisted = ( + machine: AnyStateMachine, + command: HistoryCommand, + observe: Observe, + stripHistoryValue: boolean, +): HistoryObservation => { + const persist = (actor: AnyActor): object => { + const document: object = JSON.parse(JSON.stringify(actor.getPersistedSnapshot())) + return stripHistoryValue ? { ...document, historyValue: {} } : document + } + const bootstrap: AnyActor = createActor(machine) + bootstrap.start() + let persisted: object = persist(bootstrap) + const trace: Array = [] + let value: HistoryStateValue = stateValueOf(bootstrap.getSnapshot().value) + let historyValue: RecordedIds = probeOf(bootstrap).historyValue + bootstrap.stop() + command.events.forEach((event) => { + const actor: AnyActor = reviveActor({ machine, options: { snapshot: persisted } }) + actor.start() + const before: Probe = { + value: stateValueOf(actor.getSnapshot().value), + historyValue: canonicalHistory(historyValueOfDocument(persisted)), + } + const here: Array = [] + actor.on('trace', (emitted: { at: string }) => { + here.push(emitted.at) + }) + actor.send({ type: event }) + trace.push(...here) + persisted = persist(actor) + const after = probeOf(actor) + value = after.value + historyValue = after.historyValue + observe(event, before, after, (before.historyValue[HISTORY_IDS.onH] ?? []).length > 0) + actor.stop() + }) + return { value, trace, historyValue } +} + +const noteRestores = (ledger: HistoryLedger, event: HistoryEvent, before: Probe): void => { + RESTORE_TARGETS[event]?.forEach((nodeKey) => { + const id = HISTORY_IDS[nodeKey] + if ((before.historyValue[id] ?? []).length > 0) { + ledger.restoredFromRecord[nodeKey] += 1 + } else { + ledger.restoredFromDefault[nodeKey] += 1 + } + }) +} + +const noteAfter = ( + ledger: HistoryLedger, + command: HistoryCommand, + event: HistoryEvent, + before: Probe, + after: Probe, +): void => { + Object.entries(after.historyValue).forEach(([id, recorded]) => { + if (recorded.some((recordedId) => recordedId.slice(id.length + 1).includes('.'))) { + ledger.deepRecordBelowChild += 1 + } + }) + if (event === 'RS_H') { + ledger.regionHistoryBesidePlain += 1 + } + if (event === 'REH' && (before.historyValue[HISTORY_IDS.onH] ?? []).length === 0) { + ledger.internalSelfHistoryNoRecord += 1 + } + if (MULTISTAGE_EVENTS.includes(event) && before.value === 'mid') { + ledger.multistageViaMid += 1 + } + if ( + event === 'P_H' && + (before.historyValue[HISTORY_IDS.pH] ?? []).length === 0 && + targetPaths(command.pH.target).length > 1 + ) { + ledger.parallelMultiTargetDefault += 1 + } +} + +const noteCommand = (ledger: HistoryLedger, command: HistoryCommand): void => { + const spec = specOf(command) + const forms = [spec.onH, spec.bH, spec.pH, spec.rH] + forms.forEach((entry) => { + ledger.forms[entry.form] += 1 + ledger.depths[entry.depth] = (ledger.depths[entry.depth] ?? 0) + 1 + }) + ledger.drivers[command.driver] += 1 +} + +const drive = ( + machine: AnyStateMachine, + command: HistoryCommand, + observe: Observe, + behaviour: Behaviour, +): HistoryObservation => { + if (command.driver === 'pure') { + return runPure(machine, command, observe) + } + if (command.driver === 'persisted') { + return runPersisted(machine, command, observe, behaviour.stripHistoryValue) + } + return runActor(machine, command, observe) +} + +export class HistorySubject extends Context.Service HistoryObservation>()( + '@systemfsoftware/xstate/tests/history/HistorySubject', +) {} + +export interface HistoryHandle { + readonly layer: Layer.Layer + readonly observed: HistoryLedger + readonly run: (command: HistoryCommand) => HistoryObservation +} + +const subjectOf = (behaviour: Behaviour): HistoryHandle => { + const observed = emptyLedger() + const run = (command: HistoryCommand): HistoryObservation => { + noteCommand(observed, command) + const observe: Observe = (event, before, after, restartedWithHistory) => { + noteRestores(observed, event, before) + noteAfter(observed, command, event, before, after) + if (restartedWithHistory) { + observed.restartsWithHistory += 1 + } + } + return drive(buildMachine(command, behaviour), command, observe, behaviour) + } + return { observed, layer: Layer.succeed(HistorySubject, run), run } +} + +export const makeHistorySubject = (): HistoryHandle => subjectOf(publishedBehaviour) + +export const makeAllShallowSubject = (): HistoryHandle => subjectOf({ ...publishedBehaviour, allShallow: true }) + +export const makeStrippedPersistedSubject = (): HistoryHandle => + subjectOf({ ...publishedBehaviour, stripHistoryValue: true }) + +export const runHistoryCommand = (command: HistoryCommand): Effect.Effect => + Effect.gen(function*() { + const run = yield* HistorySubject + return run(command) + }) + +export { isLive } +export type { HistoryLedger } + +export type RehReKind = + | 'atomic-default' + | 'parallel-multi-shallow' + | 'parallel-multi-deep' + | 'compound-default' + | 'compound-deep-default' + | 'parallel-child-default' + +export const rehReKinds: ReadonlyArray = [ + 'atomic-default', + 'parallel-multi-shallow', + 'parallel-multi-deep', + 'compound-default', + 'compound-deep-default', + 'parallel-child-default', +] + +const rehReRegion = (): object => ({ + r: { + entry: enterTrace('r'), + exit: leaveTrace('r'), + initial: 'r1', + states: { + r1: { entry: enterTrace('r1'), exit: leaveTrace('r1'), on: { R: 'r2' } }, + r2: { entry: enterTrace('r2'), exit: leaveTrace('r2'), on: { R: 'r1' } }, + }, + }, + s: { + entry: enterTrace('s'), + exit: leaveTrace('s'), + initial: 's1', + states: { + s1: { entry: enterTrace('s1'), exit: leaveTrace('s1'), on: { S: 's2' } }, + s2: { entry: enterTrace('s2'), exit: leaveTrace('s2'), on: { S: 's1' } }, + }, + }, +}) + +const rehReCompound = (): Record => ({ + b: { + entry: enterTrace('b'), + exit: leaveTrace('b'), + initial: 'b1', + states: { + b1: { entry: enterTrace('b1'), exit: leaveTrace('b1') }, + b2: { entry: enterTrace('b2'), exit: leaveTrace('b2') }, + }, + }, +}) + +const rehReParallel = (): Record => ({ + p: { entry: enterTrace('p'), exit: leaveTrace('p'), type: 'parallel', states: rehReRegion() }, +}) + +interface RehReLayout { + readonly history: HistoryDeclaration + readonly siblings: Record +} + +const rehReLayout: Readonly> = { + 'atomic-default': { + history: declarationOf({ form: 'type-history', target: 'b1' }), + siblings: { b1: { entry: enterTrace('b1'), exit: leaveTrace('b1') } }, + }, + 'parallel-multi-shallow': { + history: declarationOf({ form: 'type-history', target: '[p.r.r2|p.s.s2]' }), + siblings: rehReParallel(), + }, + 'parallel-multi-deep': { + history: declarationOf({ form: 'history-deep', target: '[p.r.r2|p.s.s2]' }), + siblings: rehReParallel(), + }, + 'compound-default': { + history: declarationOf({ form: 'type-history', target: 'b' }), + siblings: rehReCompound(), + }, + 'compound-deep-default': { + history: declarationOf({ form: 'history-deep', target: 'b.b2' }), + siblings: rehReCompound(), + }, + 'parallel-child-default': { + history: declarationOf({ form: 'type-history', target: 'p.r.r2' }), + siblings: rehReParallel(), + }, +} + +const rehReMachineOf = (kind: RehReKind): object => { + const layout = rehReLayout[kind] + return { + initial: 'off', + states: { + off: { entry: enterTrace('off'), exit: leaveTrace('off'), on: { ON: 'on' } }, + on: { + entry: enterTrace('on'), + exit: leaveTrace('on'), + initial: 'a', + on: { REH_RE: { target: '.onH', reenter: true } }, + states: { + a: { entry: enterTrace('a'), exit: leaveTrace('a') }, + ...layout.siblings, + onH: layout.history, + }, + }, + }, + } +} + +export interface RehReObservation { + readonly kind: RehReKind + readonly trace: ReadonlyArray + readonly value: HistoryStateValue + readonly historyValue: RecordedIds +} + +export const observeRehRe = (kind: RehReKind): RehReObservation => { + const actor: AnyActor = createActor(machineOf(rehReMachineOf(kind))) + actor.start() + const trace: Array = [] + actor.on('trace', (event: { at: string }) => { + trace.push(event.at) + }) + actor.send({ type: 'ON' }) + trace.length = 0 + actor.send({ type: 'REH_RE' }) + const value = valueOf(actor) + const historyValue = persistedHistoryValueOf(actor) + actor.stop() + return { kind, trace, value, historyValue } +} diff --git a/packages/xstate/tests/history.conformance.test.ts b/packages/xstate/tests/history.conformance.test.ts new file mode 100644 index 0000000..9682a61 --- /dev/null +++ b/packages/xstate/tests/history.conformance.test.ts @@ -0,0 +1,456 @@ +import { Conformance } from '@systemfsoftware/conformance-spec' +import { And, Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { type AnyActor, createActor, createCallbackLogic, createMachine } from '@systemfsoftware/xstate' +import { Effect, Layer } from 'effect' +import { failReportOf, passReportOf } from './__fixtures__/checkReports.js' +import { + type HistoryHandle, + type HistoryLedger, + isLive, + makeAllShallowSubject, + makeHistorySubject, + makeStrippedPersistedSubject, + observeRehRe, + persistedHistoryValueOf, + rehReKinds, + reviveActor, + runHistoryCommand, + valueOf, +} from './__fixtures__/history.js' +import { HistoryCommand, historyModel } from './__fixtures__/history.model.js' + +const Feature = makeFeature({ it }) + +const sequences = 128 +const operations = 8 + +const liveness = (observed: HistoryLedger): boolean => isLive(observed) + +const checkOver = (handle: HistoryHandle, seed: number) => + Conformance.sequential(handle.layer, { + commands: HistoryCommand, + model: historyModel, + run: runHistoryCommand, + sequences, + operations, + seed, + }) + +const withoutTarget = (): void => { + Reflect.apply(createMachine, undefined, [{ + initial: 'on', + states: { + on: { + initial: 'active', + states: { active: {}, history: { type: 'history' } }, + }, + }, + }]) +} + +const historyAsInitial = () => { + const machine = createMachine({ + initial: 'foo', + states: { foo: { type: 'history', target: 'bar' }, bar: {} }, + }) + const actor = createActor(machine).start() + const value = actor.getSnapshot().value + actor.stop() + return value +} + +const historyAsCompoundInitial = () => { + const entries: Array = [] + const machine = createMachine({ + initial: 'foo', + states: { + foo: { on: { NEXT: { target: 'bar' } } }, + bar: { + entry: () => { + entries.push('bar entered') + }, + initial: 'baz', + states: { baz: { type: 'history', target: 'qwe' }, qwe: {} }, + }, + }, + }) + const actor = createActor(machine).start() + actor.send({ type: 'NEXT' }) + const value = actor.getSnapshot().value + actor.stop() + return { value, entries } +} + +const transientDetour = () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + id: 'idle', + initial: 'absent', + states: { + absent: { on: { DEPLOY: { target: '#deploy' } } }, + present: { on: { DEPLOY: { target: '#deploy' }, DESTROY: { target: '#destroy' } } }, + hist: { type: 'history', target: 'absent' }, + }, + }, + deploy: { id: 'deploy', on: { SUCCESS: { target: 'idle.present' }, FAILURE: { target: 'idle.hist' } } }, + destroy: { id: 'destroy', always: { target: 'idle.absent' } }, + }, + }) + const actor = createActor(machine).start() + actor.send({ type: 'DEPLOY' }) + actor.send({ type: 'SUCCESS' }) + actor.send({ type: 'DESTROY' }) + actor.send({ type: 'DEPLOY' }) + actor.send({ type: 'FAILURE' }) + const value = actor.getSnapshot().value + actor.stop() + return value +} + +const reenteringSiblingHistory = () => { + const actual: Array = [] + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { REENTER: { target: '#b_hist', reenter: true } }, + initial: 'a1', + states: { + a1: { on: { NEXT: { target: 'a2' } } }, + a2: { + entry: () => { + actual.push('a2 entered') + }, + exit: () => { + actual.push('a2 exited') + }, + }, + a3: { type: 'history', id: 'b_hist', target: 'a1' }, + }, + }, + }, + }) + const actor = createActor(machine).start() + actor.send({ type: 'NEXT' }) + actual.length = 0 + actor.send({ type: 'REENTER' }) + actor.stop() + return actual +} + +const sourceViaOwnHistory = () => { + let starts = 0 + const machine = createMachine({ + initial: 'running', + states: { + running: { + on: { PING: { target: 'refresh' } }, + invoke: { + src: createCallbackLogic(() => { + starts += 1 + }), + }, + }, + refresh: { type: 'history', target: 'running' }, + }, + }) + const actor = createActor(machine).start() + starts = 0 + actor.send({ type: 'PING' }) + actor.stop() + return starts +} + +const reviveMachine = () => + createMachine({ + initial: 'on', + states: { + on: { + initial: 'first', + states: { + first: { on: { SWITCH: { target: 'second' } } }, + second: {}, + hist: { type: 'history', target: 'first' }, + }, + on: { POWER: { target: 'off' } }, + }, + off: { on: { POWER: { target: 'on.hist' } } }, + }, + }) + +const persistedSample = () => { + const machine = reviveMachine() + const source = createActor(machine).start() + source.send({ type: 'SWITCH' }) + source.send({ type: 'POWER' }) + const persisted = source.getPersistedSnapshot() + const live = source.getSnapshot() + source.stop() + return { machine, persisted, live } +} + +const unknownHistoryId = () => { + const { machine, persisted } = persistedSample() + const warned: Array = [] + const document = { ...persisted, historyValue: { '(machine).on.hist': [{ id: 'nonexistent' }] } } + const actor: AnyActor = reviveActor({ + machine, + options: { snapshot: document, warn: (message: string) => warned.push(message) }, + }) + actor.start() + actor.send({ type: 'POWER' }) + const value = valueOf(actor) + const historyValue = persistedHistoryValueOf(actor) + actor.stop() + return { warned, value, historyValue } +} + +const liveSnapshotRestore = () => { + const { machine, live } = persistedSample() + const actor: AnyActor = reviveActor({ machine, options: { snapshot: live } }) + actor.start() + actor.send({ type: 'POWER' }) + const value = valueOf(actor) + actor.stop() + return value +} + +const primitiveHistoryValues = () => { + const { machine, persisted } = persistedSample() + return [null, undefined, 42, 'foo', true, false].map((primitive) => { + const document = { ...persisted, historyValue: primitive } + const actor: AnyActor = reviveActor({ machine, options: { snapshot: document } }) + actor.start() + actor.send({ type: 'POWER' }) + const value = valueOf(actor) + const historyValue = persistedHistoryValueOf(actor) + actor.stop() + return { value, historyValue } + }) +} + +Feature('Judging the published history resolution against a hand-written model', { timeout: 0 }) + .withLayer(Layer.empty) + .live('each scenario drives the simulation kernel itself, and a conformance check cannot run inside a kernel run') + .body(({ scenario, scenarioOutline }) => { + scenarioOutline( + 'Every topology, history declaration and event sequence the model draws from seed agrees with the published history resolution', + [{ seed: 1 }, { seed: 2 }, { seed: 3 }], + (row) => + Gherkin.Do.pipe( + Given('a subject bound to the published API, with a fresh ledger')( + 'subject', + () => Effect.succeed(makeHistorySubject()), + ), + When('the sequential model check runs the generated machines and event sequences through it')( + 'report', + (s) => checkOver(s.subject, row.seed), + ), + Then('every sequence is explained by the hand-written model of history resolution')((s, expect) => + expect(passReportOf(s.report), 'the published history resolution agrees with the model').toMatchObject({ + _tag: 'Pass', + histories: sequences, + }) + ), + When('the history behaviours the run produced are read off the fixture ledger')( + 'observed', + (s) => Effect.succeed(s.subject.observed), + ), + And( + 'every history node was restored from a record and from its default, deep records reached below a child, every declaration form and depth occurred, a parallel multi-target default and a region history beside a plain target were entered, an internal self-history without a record ran, a multistage restore went through the detour, all three drivers ran, and a restart carried a non-empty history value', + )( + (s, expect) => + expect(s.observed, JSON.stringify(s.observed)).toSatisfy( + liveness, + 'the run exercised every history restore kind, form, depth, driver and restart shape', + ), + ), + ), + ) + + const divergesFromTheModel = (makeSubject: () => HistoryHandle) => + Gherkin.Do.pipe( + Given('a planted subject that behaves differently from the published history resolution')( + 'subject', + () => Effect.succeed(makeSubject()), + ), + When('the same check runs the machines drawn from seed 1 through it')( + 'report', + (s) => checkOver(s.subject, 1), + ), + Then('the run is rejected with the model-diverged judgement at a numbered step')((s, expect) => { + const failure = failReportOf(s.report).failure + const rendered = Conformance.render(s.report) + return expect({ problem: failure.judgement.problem, step: failure.judgement.step, rendered }, rendered) + .toSatisfy( + (value) => + value.problem === 'model-diverged' && + value.step !== undefined && + value.rendered.includes(`the model diverged at step ${value.step}`), + 'the run diverged from the model at a numbered step, reported as the model-diverged judgement', + ) + }), + ) + + scenario( + 'A builder that declares every history node shallow is caught as a model divergence', + divergesFromTheModel(makeAllShallowSubject), + ) + scenario( + 'A persisted driver that strips the history value before restarting is caught as a model divergence', + divergesFromTheModel(makeStrippedPersistedSubject), + ) + + scenario( + 'A history node without a non-empty default target is rejected at createMachine', + Gherkin.Do.pipe( + Given('a machine whose history node declares no target')('thrown', () => + Effect.sync(() => { + try { + withoutTarget() + return 'no error' + } catch (error) { + return error instanceof Error ? error.message : 'unrecognized' + } + })), + Then('createMachine throws the exact missing-target message')((s, expect) => + expect(s.thrown, s.thrown).toEqual( + 'History state "(machine).on.history" must declare a non-empty `target`.', + ) + ), + ), + ) + + scenario( + 'A history node as the machine initial state enters its configured default', + Gherkin.Do.pipe( + Given('a machine whose initial state is a history node defaulting to bar')( + 'value', + () => Effect.sync(historyAsInitial), + ), + Then('the actor starts in bar')((s, expect) => expect(s.value, s.value).toEqual('bar')), + ), + ) + + scenario( + "A history node as a compound's initial state enters its default and the parent enters once", + Gherkin.Do.pipe( + Given('a machine whose bar state is entered through a history initial')( + 'observed', + () => Effect.sync(historyAsCompoundInitial), + ), + Then('the value is {bar: qwe} and the parent entry ran once')((s, expect) => + expect(s.observed, JSON.stringify(s.observed)).toEqual({ value: { bar: 'qwe' }, entries: ['bar entered'] }) + ), + ), + ) + + scenario( + 'A history restore through an eventless detour reaches the most recently visited state', + Gherkin.Do.pipe( + Given('the most recent history after a transient detour')( + 'value', + () => Effect.sync(transientDetour), + ), + Then('the restore lands in {idle: absent}')((s, expect) => + expect(s.value, JSON.stringify(s.value)).toEqual({ idle: 'absent' }) + ), + ), + ) + + scenario( + 'A reentering transition to a sibling history records and restores the active child', + Gherkin.Do.pipe( + Given('the entry and exit order of a reentering sibling-history transition')( + 'actual', + () => Effect.sync(reenteringSiblingHistory), + ), + Then('the active child exits then enters')((s, expect) => + expect(s.actual, JSON.stringify(s.actual)).toEqual(['a2 exited', 'a2 entered']) + ), + ), + ) + + scenario( + 'Restoring the source through its own history restarts its invoked callback logic once', + Gherkin.Do.pipe( + Given('the callback starts of an invoked logic after a self-history restore')( + 'starts', + () => Effect.sync(sourceViaOwnHistory), + ), + Then('the callback logic started exactly once')((s, expect) => expect(s.starts, String(s.starts)).toEqual(1)), + ), + ) + + scenario( + 'An unresolved history id warns with the exact message, falls back to the default and clears the value', + Gherkin.Do.pipe( + Given('a persisted snapshot whose history id names a nonexistent node')( + 'observed', + () => Effect.sync(unknownHistoryId), + ), + Then('the warn option received the message, the default was used and historyValue is {}')((s, expect) => + expect(s.observed, JSON.stringify(s.observed)).toEqual({ + warned: ['Could not resolve StateNode for id: nonexistent'], + value: { on: 'first' }, + historyValue: {}, + }) + ), + ), + ) + + scenario( + 'A live snapshot holding state nodes restores its records', + Gherkin.Do.pipe( + Given('a snapshot taken from a live actor after a history record was made')( + 'value', + () => Effect.sync(liveSnapshotRestore), + ), + Then('the restored actor continues to the recorded state')((s, expect) => + expect(s.value, JSON.stringify(s.value)).toEqual({ on: 'second' }) + ), + ), + ) + + scenario( + 'Null, undefined and primitive history values revive as an empty record and use the default', + Gherkin.Do.pipe( + Given('a persisted snapshot for each of null, undefined, 42, foo, true and false')( + 'observed', + () => Effect.sync(primitiveHistoryValues), + ), + Then('each falls back to the default and persists an empty history value')((s, expect) => + expect(s.observed, JSON.stringify(s.observed)).toEqual( + [null, undefined, 42, 'foo', true, false].map(() => ({ value: { on: 'first' }, historyValue: {} })), + ) + ), + ), + ) + + scenarioOutline( + "A reentering transition to its source's own history exits and re-enters exactly the recorded states ()", + rehReKinds.map((kind) => ({ kind })), + (row) => + Gherkin.Do.pipe( + Given('a machine whose reentering transition targets the history node inside its source')( + 'observed', + () => Effect.sync(() => observeRehRe(row.kind)), + ), + Then('the transition exits and re-enters exactly the recorded states, reports them and records them')(( + s, + expect, + ) => + expect( + { trace: s.observed.trace, value: s.observed.value, historyValue: s.observed.historyValue }, + JSON.stringify(s.observed), + ).toEqual({ + trace: ['exit:a', 'exit:on', 'enter:on', 'enter:a'], + value: { on: 'a' }, + historyValue: { '(machine).on.onH': ['(machine).on.a'] }, + }) + ), + ), + ) + }) diff --git a/packages/xstate/vitest.config.ts b/packages/xstate/vitest.config.ts index d752ef7..26ae0a9 100644 --- a/packages/xstate/vitest.config.ts +++ b/packages/xstate/vitest.config.ts @@ -50,7 +50,6 @@ const unguarded = [ 'test/final.test.ts', 'test/firstTenMinutes.v6.test.ts', 'test/guards.test.ts', - 'test/history.test.ts', 'test/id.test.ts', 'test/initial.test.ts', 'test/input.test.ts', From 20dfc3a228b598101cb1891d8c9b41d9b9d8f1d3 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Sat, 10 Oct 2026 00:59:28 +0000 Subject: [PATCH 3/3] style(repo): re-pad the packages AGENTS.md rule table Merging #81 into capability 7 lengthened the XS1 row, and the merge resolution left the table padded for the shorter row. --- packages/AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 58ecd60..ca8894e 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -4,7 +4,7 @@ Root `AGENTS.md` governs; these are the facts no default gate reports. Forked from statelyai/xstate, branch `next` at `2146ae26ebfc7e6a624b3a1f237f9e6ddc30b9f5` (MIT). Members are owned (`REPO-O1`); upstream is a reference, never a merge source. Plan: systemfsoftware's `docs/plans/2026-10-05-2317-feat-xstate-v6-fork-plan.md`. Not forked (no consumer in this program): the Solid, Svelte, Vue, Angular and Preact bindings, `@xstate/codemod`, `@xstate/scxml`; core's `bin/xstate.js` only ran `@xstate/codemod` through `npx`, so it is not shipped. -| ID | Rule | Gate | -| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ID | Rule | Gate | +| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **XS1** | A member gains `stryker.config.ts`, `oxlint.config.ts` (extending the root `oxlint.shared.ts`) and its `mutation` and `lint` scripts with its first capability PR, and its debt-ledger entry goes in that PR: `scripts/stryker-plan-gate.ts` (`exemption`) reads any `XS1` entry for a member as leave to schedule no mutants. Until its last capability PR, a member whose `src` tsconfig lacks `@effect/language-service` points `lint:tsgo` at `tsconfig.tsgo.json`, which turns the plugin on for the enrolled `src` files only. Each capability PR adds the `src` files it covers to `mutate`, to the `lint` script and to `tsconfig.tsgo.json`, with its test files, and fixes their diagnostics without disable comments. The last capability PR turns the plugin on for the whole `src` tsconfig and deletes `tsconfig.tsgo.json`. Remaining debt: in `@systemfsoftware/xstate`, every `src` file outside `mutate` is unmutated, unlinted and unchecked by the Effect plugin until its capability PR (enrolled: `src/assert.ts`, `src/stateMatching.ts`, `src/mapState.ts`, `src/fsm.ts`, `src/graph/`, `src/transitionGuards.ts`, `src/historyRecall.ts`, `src/stateNodePredicates.ts`; the `lint` script and `tsconfig.tsgo.json` name the whole folder, and `mutate` names its seven files with runtime logic, leaving out `types.ts` and `index.ts`). | `review` — wrong: a PR mutates `src/assert.ts` and leaves it out of the `lint` script or `tsconfig.tsgo.json`, or keeps a ledger entry for an enrolled member; right: `mutate`, the `lint` script, `tsconfig.tsgo.json` and this row's enrolled list all name `src/assert.ts`, and `lint:tsgo` prints a nonzero `Checked N files` | -| **XS2** | The persisted snapshot wire format is upstream v6's (actor refs `{ xstate$type: 'actorRef', id }`, children by logical address, timers with `startedAt`); it changes only through a versioned machine migration. | `review` — wrong: an encoder emits `{ _tag: 'ActorRef', id }`; right: the encoder writes `{ xstate$type: 'actorRef', id }` and the tagged class exists only after decode | +| **XS2** | The persisted snapshot wire format is upstream v6's (actor refs `{ xstate$type: 'actorRef', id }`, children by logical address, timers with `startedAt`); it changes only through a versioned machine migration. | `review` — wrong: an encoder emits `{ _tag: 'ActorRef', id }`; right: the encoder writes `{ xstate$type: 'actorRef', id }` and the tagged class exists only after decode |