diff --git a/.changeset/navigation-spec-scenarios.md b/.changeset/navigation-spec-scenarios.md new file mode 100644 index 0000000..20797c2 --- /dev/null +++ b/.changeset/navigation-spec-scenarios.md @@ -0,0 +1,24 @@ +--- +'@dunky.dev/browser-navigation': patch +--- + +`SPEC.md` (shipped with the package) gains a Scenarios section: 26 compact +traces of `interceptBackNavigation` and `watchSpentEntry` behavior, grouped +by one layer, release and consumption, forward/claims/reload, stacked +layers, and timing edges. + +The behavior contract was already fully stated, but as one mechanism per +prose bullet — nothing let a reader replay a concrete flow end to end. +Each trace is a replayable episode in the module's own vocabulary (`arm`, +`release`, `Back`, `Forward`), e.g.: + +``` +arm A -> arm B -> arm C -> Back -> release B -> Forward -> Forward + => the Back closes C; the first Forward soaks into B's abandoned entry; + the second asks C, which reopens only if it still can without B. +``` + +Two outcomes the prose previously left implicit are now stated outright: a +deliberately released entry absorbs one Forward press (it can't be deleted, +only left to soak the traversal), and any re-plant — a new layer or a +veto's re-arm — truncates every parked Forward watch above it. diff --git a/packages/dom/utils/navigation/SPEC.md b/packages/dom/utils/navigation/SPEC.md index a14e490..4fe6d77 100644 --- a/packages/dom/utils/navigation/SPEC.md +++ b/packages/dom/utils/navigation/SPEC.md @@ -88,6 +88,84 @@ URL, so it isn't shareable. A layer that must survive reload in its own right (or be linkable) keeps its open-state in the URL and derives itself from it — Back then closes for free and needs no interceptor. +## Scenarios + +Compact traces of the behavior above, in the module's own vocabulary. +`arm` registers a guard; `release` is a close this module didn't cause — +external, imperative, or similar — reaching it as the release call; +`release(keepClaim)` is a teardown. `watch` registers a spent-entry +watcher. `Back` and `Forward` are user traversals; `=>` is the observable +outcome. + +### One layer + +- `arm -> Back` => the layer closes; the page stays. +- `arm -> release` => the entry is consumed; the next Back leaves the page. +- `arm -> Back -> Forward` => the layer reopens, re-armed on the entry in + place. +- `arm -> Back -> Forward (reopen declined)` => stays closed, stays parked; + a later Forward offers again. +- `arm -> Back (onBack declines)` => re-armed; the next Back reaches the + same layer again. +- `arm -> Back (onBack throws)` => counts as a decline: re-armed, and the + error propagates. + +### Release and consumption + +- `arm -> release -> arm (same turn)` => the re-registration adopts the + entry in place; zero traversals. +- `arm (onForward) -> Back -> release` => the Forward watch ends; the spent + entry stays in the forward stack, not the module's to spend. +- `arm -> Back -> Forward (declined) -> release` => the declined reopen + left the entry current, so it is consumed. +- `arm A -> arm B -> release both (any order)` => both entries consumed, + one traversal at a time; the next Back leaves the page. +- `arm A -> arm B -> release A` => A's entry is buried under B: left + alone. The Back that closes B surfaces it, and it is consumed alongside. +- `arm -> Back -> release` => the Back already unwound the guard; the + release is a no-op. + +### Forward, claims, reload + +- `arm (claim) -> Back -> release(keepClaim) -> watch -> Forward` => the + sole watcher reopens; the new registration adopts the entry. +- `arm (claim) -> Back -> release -> Forward` => nothing. The close was + deliberate (abandoned), and the dead entry absorbs one Forward press — + an entry can't be deleted, only left to soak the traversal. +- Two watchers, one claim, `Forward` => neither answers: they can't be + told apart, and reopening the wrong layer is worse than reopening none. +- `arm (claim) -> Back -> reload -> watch -> Forward` => reopens. The + abandoned memory did not survive the reload: surrendered and lost ground + now look the same. +- `arm -> reload -> Back` => spends on nothing — the entry outlived the + open-state; a Forward back onto it offers the claim. +- `Forward` onto marked ground with no owner and no watcher => nothing — + residue never unwinds anything. +- `arm A -> Back -> arm B` => B's plant truncates the forward stack: A's + spent entry and its watch are gone. A veto's re-plant does the same — a + decline costs every parked watch above. + +### Stacked layers + +- `arm A -> arm B -> Back -> Back` => closes B, then A — one per press. +- `arm A -> arm B -> release B -> Back` => closes A; B's entry was + consumed in between. +- `arm A -> arm B -> arm C -> Back -> release B -> Forward -> Forward` => + the Back closes C; the first Forward soaks into B's abandoned entry; the + second asks C, which reopens only if it still can without B. +- `history.go(-2)` across A and B => unwinds topmost first; a decline + midway re-arms and stops the unwind there. +- `history.go(+2)` across two spent entries => offers each crossed layer + its reopen, lowest first. + +### Timing edges + +- A registration adopts an entry while a self-caused pop is in flight => + the swallow accounting re-plants the live guard (self-heal). +- Parent and child arm in one commit (child lifecycle runs first) => the + child arms beneath the parent: the first Back closes the outer layer. A + layer armed after its parent unwinds inside-out as expected. + ## API | Export | Description | diff --git a/packages/dom/utils/navigation/src/intercept-back-navigation.ts b/packages/dom/utils/navigation/src/intercept-back-navigation.ts index a9ecea0..6c3101b 100644 --- a/packages/dom/utils/navigation/src/intercept-back-navigation.ts +++ b/packages/dom/utils/navigation/src/intercept-back-navigation.ts @@ -74,7 +74,7 @@ function currentClaim(): string | undefined { // Offers a spent entry to the layer that has taken the planter's place. Only a // sole candidate may answer: two layers claiming the same ground can't be told // apart, and reopening the wrong one is worse than reopening none. -function offerToClaimant(): void { +function resolveClaim(): void { const id = currentGuardId() if (id !== undefined && abandoned.has(id)) return const claim = currentClaim() @@ -169,7 +169,7 @@ function onPopState(): void { } } } else { - offerToClaimant() + resolveClaim() } detachWhenIdle() return