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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/history-reenter-ghost-entry.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions packages/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`, `src/transitionGuards.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 |
| 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 |
1 change: 0 additions & 1 deletion packages/unguarded-tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@
"packages/xstate/test/examples.cd.test.ts",
"packages/xstate/test/final.test.ts",
"packages/xstate/test/firstTenMinutes.v6.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",
Expand Down
2 changes: 1 addition & 1 deletion packages/xstate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 src/transitionGuards.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/guards.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/transitionGuards.ts 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/guards.conformance.test.ts tests/history.conformance.test.ts tests/__fixtures__ --format=${f:-default}",
"mutation": "stryker run"
},
"devDependencies": {
Expand Down
90 changes: 90 additions & 0 deletions packages/xstate/src/historyRecall.ts
Original file line number Diff line number Diff line change
@@ -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<AnyStateNode> {
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
}
2 changes: 1 addition & 1 deletion packages/xstate/src/mapState.ts
Original file line number Diff line number Diff line change
@@ -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 AnyMachineSnapshot> = TSnapshot extends MachineSnapshot<
Expand Down
29 changes: 29 additions & 0 deletions packages/xstate/src/stateNodePredicates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { AnyStateNode } from './types.js'

const IS_ATOMIC_TYPE: Record<AnyStateNode['type'], boolean> = {
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)
}
Loading
Loading