Repository navigation
feat(xstate): test xstate's history resolution as a conformance spec - #84
Open
systemfsoftware-maker wants to merge 5 commits into
Open
systemfsoftware-maker wants to merge 5 commits into
systemfsoftware-maker wants to merge 5 commits into
Conversation
…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
added this pull request to stack #85
October 10, 2026 00:49
Merging #81 into capability 7 lengthened the XS1 row, and the merge resolution left the table padded for the shorter row.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Capability 7 (history) of
@systemfsoftware/xstate. It is stacked on #81 (guards) because both PRs editstateUtils.tsand 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 replacestest/history.test.ts; and a trace bug the spec found is fixed.What changed
src/historyRecall.ts(new, pure):recallHistorydecides between a recorded and a default recall,recordHistoryNodesrecords shallow and deep history on exit, andrestoresSourceViaHistorytells whether a transition restores its own source.stateUtils.tscalls them.src/stateNodePredicates.ts(new leaf):isAtomicStateNodeandisDescendantmove here, so both modules share them without an import cycle.isDescendanttakes one object argument.initial, so the loop is now a singleinitialcheck.Fix: a reentering self-history transition entered states it did not enter
{ target: '.h', reenter: true }ononmakesh(no record yet) exit and re-enteron. The exit recordsh = [a], so SCXML enters exactlyonanda. On main, when the default target lay below a child, its ancestors also ranentry. For example, with a default ofb.b2main emittedexit:a, exit:on, enter:on, enter:a, enter:b, andbwas 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.
getEffectiveTargetStatesnow 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.4 failed | 16 passed. The four red shapes arecompound-deep-default(extraenter:b),parallel-child-default,parallel-multi-shallowandparallel-multi-deep(extraenter:p, enter:r, enter:r1, enter:s, enter:s1). In every shape, the value and the persisted history value were already right.Spec:
tests/history.conformance.test.ts(20 cases)tests/__fixtures__/history.model.ts(which imports onlyeffect) 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.model-divergedat a numbered step.Behaviour-to-test map (CONST-T9)
test/history.test.tson main has 37 cases (34 active, 3it.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 resolutionF1=A history node without a non-empty default target is rejected at createMachineF2=A history node as the machine initial state enters its configured defaultF3=A history node as a compound's initial state enters its default and the parent enters onceF4=A history restore through an eventless detour reaches the most recently visited stateF5=A reentering transition to a sibling history records and restores the active childF6=Restoring the source through its own history restarts its invoked callback logic onceF7=An unresolved history id warns with the exact message, falls back to the default and clears the valueF8=A live snapshot holding state nodes restores its recordsF9=Null, undefined and primitive history values revive as an empty record and use the defaulthistory 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 generatedtype-historydeclaration 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(ledgerrestoredFromDefaultis 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(restoredFromDefaultfor thetype-historyform).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(ledgerparallelMultiTargetDefaultplus 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 fromoff/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'sentryNOT to run. Observed on main and on this branch:b's entry runs exactly once, which is what line 452 asserts and whatOUTLINEpins 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-outinitial: { 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 fromoff/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'sinitial; 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 afterbis re-entered froma. Observed:b's entry runs once per entry ofb, as active line 603 asserts andOUTLINE'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 ofb, as line 603 andOUTLINEpin.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 fromoffand frommid; 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; ledgerrestoredFromRecordis 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 countsshallowanddeep; a shallow record is the parent's direct children).history.test.ts › deep history states › should go to the deep history (explicit)→OUTLINE(thetype-deep/history-deepforms are generated).history.test.ts › deep history states › should go to the deepest history→OUTLINE(ledgerdeepRecordBelowChild: 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 regionsrands; 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-regionpwithrH/pH; the ledger countsregionHistoryBesidePlainfor 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 generatedRS_Hevent 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(theREHinternal self-history event is generated; ledgerinternalSelfHistoryNoRecordis non-zero).multistage history states
history.test.ts › multistage history states › should go to the most recently visited state→OUTLINE(ledgermultistageViaMid: a restore event is sent while the actor is in themiddetour).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.tsandsrc/stateNodePredicates.tsjoin the strykermutateset, thelintscript (with the spec),tsconfig.tsgo.json, and XS1's enrolled list inpackages/AGENTS.md.test/history.test.tsleavespackages/unguarded-tests.jsonand theunguardedvitest 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)
tsc -b,tsc -p tsconfig.test.json --noEmitandlint(oxlint) all exit 0.lint:tsgochecks 16 enrolled src files (with feat(repo): test xstate's guard evaluation as a conformance spec and give checkStateIn a data-last form #81'stransitionGuards.ts) with 0 diagnostics.tests/history.conformance.test.tspasses 20/20 on seeds 1, 2 and 3, with the same case names on every seed.@systemfsoftware/xstatesuite on this head: 137 files, 2320 passed / 20 skipped / 3 todo (2343) on seeds 1, 2 and 3. Before merging feat(repo): test xstate's guard evaluation as a conformance spec and give checkStateIn a data-last form #81 it was 138 files and 2337 / 23 / 3 on each seed; the difference is feat(repo): test xstate's guard evaluation as a conformance spec and give checkStateIn a data-last form #81's own test changes.tscchild killed at 55 s, and 5 s timeouts inactor,inspectandeventDescriptors). On re-run both were green with the counts above.