Skip to content

feat(xstate): test xstate's history resolution as a conformance spec - #84

Open
systemfsoftware-maker wants to merge 5 commits into
xs/xstate-guardsfrom
xs/xstate-history
Open

systemfsoftware-maker wants to merge 5 commits into
xs/xstate-guardsfrom
xs/xstate-history

Conversation

@systemfsoftware-maker

@systemfsoftware-maker systemfsoftware-maker commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Capability 7 (history) of @systemfsoftware/xstate. It is stacked on #81 (guards) because both PRs edit stateUtils.ts and the enrolment lists; the trunk is main, and GitHub retargets this PR when #81 merges. History resolution moves into a pure module; one conformance spec over a hand-written model replaces test/history.test.ts; and a trace bug the spec found is fixed.

What changed

  • src/historyRecall.ts (new, pure): recallHistory decides between a recorded and a default recall, recordHistoryNodes records shallow and deep history on exit, and restoresSourceViaHistory tells whether a transition restores its own source. stateUtils.ts calls them.
  • src/stateNodePredicates.ts (new leaf): isAtomicStateNode and isDescendant move here, so both modules share them without an import cycle. isDescendant takes one object argument.
  • Deleted: the per-parent history-default map and its loop over default transitions. The map only ever held the parent's own initial, so the loop is now a single initial check.
  • Fix: see below.

Fix: a reentering self-history transition entered states it did not enter

{ target: '.h', reenter: true } on on makes h (no record yet) exit and re-enter on. The exit records h = [a], so SCXML enters exactly on and a. On main, when the default target lay below a child, its ancestors also ran entry. For example, with a default of b.b2 main emitted exit:a, exit:on, enter:on, enter:a, enter:b, and b was then exited on the next step without ever being in the value.

Root cause: 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. getEffectiveTargetStates now takes the history value to read. The transition domain still reads the snapshot's (so exit sets are unchanged), and the entry set reads the post-exit one.

Red on main, green here: the scenario "A reentering transition to its source's own history exits and re-enters exactly the recorded states" runs over six shapes, each expecting the hand-written trace exit:a, exit:on, enter:on, enter:a.

  • On main (spec copied onto main's src), seed 1 gives 4 failed | 16 passed. The four red shapes are compound-deep-default (extra enter:b), parallel-child-default, parallel-multi-shallow and parallel-multi-deep (extra enter:p, enter:r, enter:r1, enter:s, enter:s1). In every shape, the value and the persisted history value were already right.
  • On this head it is 20/20.
  • The changeset declares the fix as a patch.

Spec: tests/history.conformance.test.ts (20 cases)

  • Outline, seeds 1-3: generated topologies, all six history declaration forms, shallow and deep, and event sequences. These run through a live actor, the pure transition API, and a driver that restarts from the persisted snapshot before every event. Each step is compared exactly against tests/__fixtures__/history.model.ts (which imports only effect) on value, ordered entry/exit trace and persisted history value. A liveness ledger requires every history node to be restored from both a record and a default, plus deep records below a child, parallel multi-target defaults, region history beside a plain target, internal self-history with no record, a restore from a detour, and all three drivers.
  • Planted subjects: a builder that declares every history node shallow, and a persisted driver that strips the history value. Each is rejected as model-diverged at a numbered step.
  • Fixed scenarios: the 9 listed in the map below, plus the reentering self-history outline over six shapes.

Behaviour-to-test map (CONST-T9)

test/history.test.ts on main has 37 cases (34 active, 3 it.skip). Every one is below.

Covering-name shorthand (exact spec case names):

  • OUTLINE = Every topology, history declaration and event sequence the model draws from seed <seed> agrees with the published history resolution
  • F1 = A history node without a non-empty default target is rejected at createMachine
  • F2 = A history node as the machine initial state enters its configured default
  • F3 = A history node as a compound's initial state enters its default and the parent enters once
  • F4 = A history restore through an eventless detour reaches the most recently visited state
  • F5 = A reentering transition to a sibling history records and restores the active child
  • F6 = Restoring the source through its own history restarts its invoked callback logic once
  • F7 = An unresolved history id warns with the exact message, falls back to the default and clears the value
  • F8 = A live snapshot holding state nodes restores its records
  • F9 = Null, undefined and primitive history values revive as an empty record and use the default

history states

  • history.test.ts › history states › rejects a history state without a non-empty default target at runtime → F1 (the same machine is built through the untyped entry point and the exact thrown message is asserted).
  • history.test.ts › history states › should go to the most recently visited state (explicit shallow history type) → OUTLINE (every history node is restored from a record; the ledger counts all four nodes and forms; the predicted value/trace/historyValue must match the published restore).
  • history.test.ts › history states › should go to the most recently visited state (no explicit history type) → OUTLINE (the generated type-history declaration form is exactly a bare { type: 'history', target }).
  • history.test.ts › history states › should go to the initial state when no history present (explicit shallow history type) → OUTLINE (ledger restoredFromDefault is non-zero for every history node; the default-target resolution is predicted).
  • history.test.ts › history states › should go to the initial state when no history present (no explicit history type) → OUTLINE (restoredFromDefault for the type-history form).
  • history.test.ts › history states › should go to the most recently visited state by a transient transition → F4 (the deleted machine is rebuilt verbatim and { idle: 'absent' } is asserted).
  • history.test.ts › history states › should reenter persisted state during reentering transition targeting a history state → F5 (the deleted machine is rebuilt verbatim and ['a2 exited', 'a2 entered'] is asserted).
  • history.test.ts › history states › should go to the configured default target when a history state is the initial state of the machine → F2.
  • history.test.ts › history states › should go to the configured default target when a history state is the initial state of the transition's target → F3.
  • history.test.ts › history states › should enter a legal multi-target default for deep parallel history → OUTLINE (ledger parallelMultiTargetDefault plus the deep parallel history node with a multi-target array; the predicted configuration is compared per step).
  • history.test.ts › history states › should execute parent entry actions when a history default is used before its parent was visited → OUTLINE (the transition is generated from off/mid, before the history parent is visited, and the full ordered entry/exit trace is compared exactly).
  • history.test.ts › history states › should enter a deep parallel history default before its parent was visited → OUTLINE (same route through a deep parallel history default).
  • history.test.ts › history states › should enter a shallow parallel history default before its parent was visited → OUTLINE (same route through a shallow parallel history default).
  • history.test.ts › history states › 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 → uncovered: it.skip, contradicted by an active case. It builds the same machine and sends the same event as the active line-452 case, but expects the parent's entry NOT to run. Observed on main and on this branch: b's entry runs exactly once, which is what line 452 asserts and what OUTLINE pins in its exact entry trace. The skipped expectation relies on v4's "actions on the initial transition", which v6 does not have (the case's own commented-out initial: { target, actions }).
  • history.test.ts › history states › should execute entry actions of a parent of the targeted history state when its parent state was never visited yet → OUTLINE (a history target reached from off/mid; the parent's entry action is part of the exactly-compared trace).
  • history.test.ts › history states › should execute actions of the initial transition when it select a history state as the initial state of its parent → F3 (a history node is the compound's initial; the parent's entry runs once and the default child is entered).
  • history.test.ts › history states › should execute parent entry actions when recorded history is restored → uncovered: it.skip, contradicted by an active case. Its title says the parent entry runs, yet its assertion expects 0 calls after b is re-entered from a. Observed: b's entry runs once per entry of b, as active line 603 asserts and OUTLINE's record-restore traces pin.
  • history.test.ts › history states › 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 → uncovered: it.skip, contradicted by an active case. Same machine as active line 603, with the opposite expectation (entry not called on re-entry). Observed: b's entry runs on every entry of b, as line 603 and OUTLINE pin.
  • history.test.ts › history states › should execute entry actions of a parent of the targeted history state when its parent state was already visited → OUTLINE (restore events reach the history parent after it was visited; the trace is compared exactly).
  • history.test.ts › history states › should invoke an actor when reentering the stored configuration through the history state → F6 (a callback logic is invoked; the start count must be exactly 1 after the self-history restore).
  • history.test.ts › history states › should not enter ancestors of the entered history state that lie outside of the transition domain when entering the default history configuration → OUTLINE (history targets are generated from off and from mid; the model's transition-domain rules and the exact entry/exit trace pin the ancestors that must not be entered).
  • history.test.ts › history states › should not enter ancestors of the entered history state that lie outside of the transition domain when restoring the stored history configuration → OUTLINE (same, on the record-restore path; ledger restoredFromRecord is non-zero for every node).

deep history states

  • history.test.ts › deep history states › should go to the shallow history → OUTLINE (both depths are generated and the ledger counts shallow and deep; a shallow record is the parent's direct children).
  • history.test.ts › deep history states › should go to the deep history (explicit) → OUTLINE (the type-deep / history-deep forms are generated).
  • history.test.ts › deep history states › should go to the deepest history → OUTLINE (ledger deepRecordBelowChild: a deep record that reaches below a direct child).

parallel history states

  • history.test.ts › parallel history states › should ignore parallel state history → OUTLINE (history nodes live inside the parallel regions r and s; the model resolves a targeted history node per region).
  • history.test.ts › parallel history states › should remember first level state history → OUTLINE (shallow records are the direct active children of the history node's parent).
  • history.test.ts › parallel history states › should re-enter each regions of parallel state correctly → OUTLINE (multi-region p with rH/pH; the ledger counts regionHistoryBesidePlain for the array target ['on.p.r.rH', 'on.p.s.s2']).
  • history.test.ts › parallel history states › should re-enter multiple history states → OUTLINE (the generated RS_H event targets two history nodes in one array).
  • history.test.ts › parallel history states › should re-enter a parallel with partial history → OUTLINE (shallow parallel history restore).
  • history.test.ts › parallel history states › should re-enter a parallel with full history → OUTLINE (deep parallel history restore and the deep-record-below-child ledger item).

top-level

  • history.test.ts › internal transition to a history state should enter default history state configuration if the containing state has never been exited yet → OUTLINE (the REH internal self-history event is generated; ledger internalSelfHistoryNoRecord is non-zero).

multistage history states

  • history.test.ts › multistage history states › should go to the most recently visited state → OUTLINE (ledger multistageViaMid: a restore event is sent while the actor is in the mid detour).

revive history states

  • history.test.ts › revive history states › should restore from stringified snapshot → OUTLINE (the persisted driver restarts from the serialized snapshot before every event and the model predicts the restored value).
  • history.test.ts › revive history states › should ignore unresolved ids as-is and log a warning → F7 (warn message text, fallback value and emptied historyValue are asserted).
  • history.test.ts › revive history states › should not re-resolve already-instantiated StateNode → F8 (a snapshot taken from a live actor restores its records).
  • history.test.ts › revive history states › should handle null, undefined, and primitive values → F9 (null/undefined/42/'foo'/true/false each fall back to the default and persist an empty historyValue).

Enrolment (XS1)

src/historyRecall.ts and src/stateNodePredicates.ts join the stryker mutate set, the lint script (with the spec), tsconfig.tsgo.json, and XS1's enrolled list in packages/AGENTS.md. test/history.test.ts leaves packages/unguarded-tests.json and the unguarded vitest project. Mutation is measured by the release gate on main after merge. No mutation testing was run locally.

Gates on bb661da (main c8c376c and #81 merged in)

…ory 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.
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.
…-history

# Conflicts:
#	packages/AGENTS.md
#	packages/unguarded-tests.json
#	packages/xstate/package.json
#	packages/xstate/stryker.config.ts
#	packages/xstate/tsconfig.tsgo.json
#	packages/xstate/vitest.config.ts
@systemfsoftware-maker
systemfsoftware-maker added this pull request to stack #85 October 10, 2026 00:49
@systemfsoftware-maker systemfsoftware-maker changed the title xs/xstate history feat(xstate): test xstate's history resolution as a conformance spec Oct 10, 2026
Merging #81 into capability 7 lengthened the XS1 row, and the merge
resolution left the table padded for the shorter row.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant