Skip to content

Commit 5656f41

Browse files
Fix first-use nested history fallbacks (#26)
1 parent 2e45e12 commit 5656f41

12 files changed

Lines changed: 636 additions & 79 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Fix first-use nested history defaults by requiring a complete source-independent configuration containing the history owner and using it to rebuild inactive compound and parallel ancestors.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ dist/
33
references/
44
*.tgz
55
.DS_Store
6+
.pnpm-store

README.md

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,11 +313,37 @@ machine.handle({
313313
})
314314
```
315315

316-
A recorded nested history can rebuild inactive ancestors when it is restored.
317-
There is one current first-use boundary: if no history record exists, a nested
318-
history fallback constructs only its direct owner's snapshot and cannot
319-
reconstruct values for inactive ancestors above that owner. Those ancestors
320-
must already be active when that nested fallback is targeted.
316+
A history default is source-independent. It must construct a complete root
317+
configuration containing the history owner, including every inactive ancestor
318+
above a nested owner and every required region of a parallel ancestor. For a
319+
top-level owner, its owner snapshot is already a complete root snapshot.
320+
321+
For example, a history node owned by `App.Workspace` can be targeted from an
322+
unrelated `Closed` root and supplies the complete `App` configuration on first
323+
use:
324+
325+
```ts
326+
Workspace: {
327+
history: {
328+
resume: {
329+
default: ({ target }) =>
330+
target.App(
331+
State.cases.App.make({ workspaceId: "default" }),
332+
(app) =>
333+
app.Workspace(
334+
State.cases.Workspace.make({}),
335+
(workspace) =>
336+
workspace.Editing(State.cases.Editing.make({}))
337+
)
338+
)
339+
}
340+
}
341+
}
342+
```
343+
344+
The containing branch is enforced statically: unrelated roots, sibling
345+
compound branches that omit the owner, owner-only nested snapshots, and
346+
incomplete parallel configurations are rejected.
321347

322348
Deep history restores every remembered descendant value. Shallow history
323349
restores the parent and direct-child values, then follows normal initial paths.

docs/agent-guide.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,8 @@ const States = Machine.defineStates({
228228
})
229229
```
230230

231-
Every history node needs a default parent snapshot for the first use:
231+
Every history node needs a source-independent default for the first use. The
232+
default is a complete root snapshot containing the history owner:
232233

233234
```ts
234235
checkout: {
@@ -256,10 +257,35 @@ payment: {
256257
}
257258
```
258259

259-
A recorded nested history can rebuild inactive ancestors. A first-use fallback
260-
cannot: it constructs the history node's direct parent snapshot, so it cannot
261-
currently reconstruct values for inactive ancestors above that owner. Those
262-
ancestors must already be active when an unrecorded nested history is targeted.
260+
A nested default must include every ancestor above its owner and every region
261+
of any parallel ancestor. The containing branch is checked statically, so an
262+
unrelated root, a sibling compound branch, a direct-owner-only nested snapshot,
263+
or an incomplete parallel configuration is rejected. A canonical nested
264+
default looks like:
265+
266+
```ts
267+
Workspace: {
268+
history: {
269+
resume: {
270+
default: ({ target }) =>
271+
target.App(
272+
State.cases.App.make({ workspaceId: "default" }),
273+
(app) =>
274+
app.Workspace(
275+
State.cases.Workspace.make({}),
276+
(workspace) =>
277+
workspace.Editing(State.cases.Editing.make({}))
278+
)
279+
)
280+
}
281+
}
282+
}
283+
```
284+
285+
On first use from an inactive root, this complete configuration is entered. If
286+
a parallel ancestor is already active, unaffected active regions are retained.
287+
Once a history record exists, shallow or deep recorded restoration wins over
288+
the default.
263289

264290
The machine's readiness type tracks missing defaults and shallow initializers.
265291
History is an overwriteable register, not a stack: restoration does not consume
@@ -273,7 +299,7 @@ prior effects, actors, and timers are not rewound.
273299
| `target.local` | The destination is inside the nearest compound scope containing the source | The compound value, active ancestors, and unrelated parallel regions |
274300
| `target.branch` | The destination is elsewhere under the active top-level root | Omitted current ancestor values and parallel regions |
275301
| `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot |
276-
| `target.history` | The destination is a declared history pseudo-state | Its parent's remembered configuration, or its default before the first capture; an unrecorded nested fallback requires ancestors above its owner to be active |
302+
| `target.history` | The destination is a declared history pseudo-state | Its parent's remembered configuration, or a source-independent complete default containing that owner before the first capture |
277303

278304
Entering an inactive parallel state through `target.local` or `target.branch`
279305
requires a complete callback with one selection per region. A parallel state

examples/platformer/src/machine.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -137,22 +137,6 @@ export const CharacterStates = Machine.defineStates({
137137
}
138138
})
139139

140-
const initialPlaying = (): Machine.Machine.SnapshotByIdentifier<
141-
typeof CharacterStates.states,
142-
"Character.locomotion.Playing"
143-
> => ({
144-
path: "Character.locomotion.Playing",
145-
value: State.cases.Playing.make({}),
146-
state: {
147-
path: "Character.locomotion.Playing.Grounded",
148-
value: State.cases.Grounded.make({}),
149-
state: {
150-
path: "Character.locomotion.Playing.Grounded.Standing",
151-
value: State.cases.Standing.make({})
152-
}
153-
}
154-
})
155-
156140
const initialCharacter = () =>
157141
CharacterStates.initial.Character(State.cases.Character.make({}), (character) =>
158142
character
@@ -185,7 +169,7 @@ export const CharacterMachine = Machine.make({
185169
Playing: {
186170
history: {
187171
resume: {
188-
default: initialPlaying
172+
default: initialCharacter
189173
}
190174
},
191175
on: {

0 commit comments

Comments
 (0)