Skip to content

feat(dialog): the browser Forward reopens what Back closed - #39

Open
ivanbanov wants to merge 4 commits into
mainfrom
fix-dialog-forward-navigation
Open

feat(dialog): the browser Forward reopens what Back closed#39
ivanbanov wants to merge 4 commits into
mainfrom
fix-dialog-forward-navigation

Conversation

@ivanbanov

@ivanbanov ivanbanov commented Aug 14, 2026

Copy link
Copy Markdown
Member

What

closeOnBack becomes symmetric: the browser's Back closes the dialog (as before), and the browser's Forward now reopens what Back closed — guarded again for the next Back. No new setting: back-close and forward-reopen are one behavior (the dialog's openness tracking the history position), so the existing closeOnBack gates both.

The reopen follows the shared dismissal contract: a new onForwardNavigation callback fires first and preventDefault() vetoes; a controlled dialog only records the intent.

Both DOM substrates ship it — React and Solid — from the same code.

How

The model that keeps this cheap: the guard entry a Back press pops isn't gone — it survives in the browser's forward stack, still marking the dialog's open ground. A traversal re-entering it is the host's Forward.

  • @dunky.dev/dom-navigationinterceptBackNavigation(onBack, onForward?) grew the optional second callback. A Back-closed guard parks instead of dropping; a popstate landing on its spent entry offers the reopen and re-arms the guard on the entry in place (no new entry planted). The safety invariant: parked entries always sit above every armed entry (every planted entry truncates the forward stack), so ownership of the landing entry — not traversal direction, which popstate doesn't carry — decides reopen vs unwind. Marked ground with no armed owner never unwinds a layer; without that, Forward onto a dead nested-dialog entry would wrongly close its parent. Callers that don't pass onForward behave exactly as before.
  • @dunky.dev/dialoghistory.forward mirrors history.back, gated by the same closeOnBack, reachable from closed and from the animated closing state (Forward interrupts the exit). The connect gains forwardNavigate(), sharing the synthesized veto payload with backNavigate().
  • @dunky.dev/dom-dialog — owns the whole episode, so no host re-derives it. guardBackNavigation returns { sync, release } rather than a disposer: the guard deliberately outlives the open state, so a host reports every change through sync(open) and ends the episode with release(). Whether a close parks the registration (the Back press closed it, its spent entry is still worth watching) or releases it (any other close has no way back) is decided here, once.
  • @dunky.dev/react-dialog, @dunky.dev/solid-dialog — contribute only their host's lifecycle around that: React reports from an effect keyed on the open state and releases on unmount, Solid from a createEffect and onCleanup. Reopening through the trigger plants a fresh entry, truncating the spent one — exactly like navigating after a Back.

Merge with main

main extracted the Dialog's DOM half into @dunky.dev/dom-dialog while this branch grew the Forward reopen inline in the React root — the one conflict. Resolved by moving the fix down a layer rather than keeping it in the host, which is also what gave Solid the behavior for free instead of a second copy of the episode logic.

Caveats (spec'd)

  • A controlled dialog's Back-close is completed by the consumer rather than by the press itself, so its entry is consumed and Forward has nothing to re-enter (URL-held open state remains the answer there, per the navigation SPEC's Reload section).
  • The Forward watch lives in script, so it doesn't survive a reload.
  • Native has no Forward counterpart — the platform offers a Back gesture and no Forward one — so forwardNavigate stays unwired there, recorded in native-dialog's SPEC.

Testing

  • 20 new behavior tests: 5 dom-navigation (reopen + re-arm, decline keeps watching, release ends the watch, a planted entry truncates the watch, stacked guards reopen one per Forward lowest-first), 4 core machine/connect, 3 dom-dialog (park through a Back-close, release on any other close, release ends a parked episode), 4 React and 4 Solid (Forward reopens guarded again, no reopen after other closes, veto, trigger-reopen truncates).
  • Full suite, build, typecheck, lint, and format pass. Both closeOnBack stories gained a "Simulate browser Forward" button.

Housekeeping

  • Docs name React and Solid as the substrates instead of Vue, which was the stand-in second framework while Solid didn't exist. Published CHANGELOG entries keep their Vue mention — they are the release record for what shipped.
  • lint-staged moves to .lintstagedrc.ts and filters scripts/templates/**: oxlint and oxfmt both ignore that path and both exit non-zero when every file handed to them is ignored, so a commit touching only scaffold templates failed the pre-commit hook with nothing actually wrong.

🤖 Generated with Claude Code

ivanbanov and others added 4 commits August 14, 2026 22:27
closeOnBack becomes symmetric. The guard entry a Back press pops survives
in the forward stack, still marking the dialog's open ground: a traversal
re-entering it is the host's Forward and reopens the dialog, guarded again
for the next Back. One setting gates both directions.

- dom-navigation: interceptBackNavigation(onBack, onForward?) — a
  Back-closed guard parks instead of dropping; a landing on its spent
  entry offers the reopen and re-arms the guard on the entry in place.
  Ownership of the landing entry, not traversal direction, decides
  reopen vs unwind: marked ground with no armed owner is forward residue
  and never unwinds a layer.
- dialog (core): history.forward mirrors history.back, gated by the same
  closeOnBack, reachable from closed and from the animated closing state
  (Forward interrupts the exit); forwardNavigate() on the api with a new
  onForwardNavigation veto callback, per the shared dismissal contract.
- react-dialog: one registration spans the whole episode — armed while
  open, parked through a Back-close, released on any other close or on
  unmount. Reopening through the trigger plants a fresh entry, truncating
  the spent one, exactly like navigating after a Back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main extracted the Dialog's DOM half into @dunky.dev/dom-dialog, which took
the history guard with it; this branch had grown the Forward reopen inline in
the React root. Resolved by moving the fix down a layer instead of keeping it
in the host: the whole episode is dom-dialog's.

- dom-dialog: guardBackNavigation returns { sync, release } rather than a
  disposer. The guard outlives the open state — that is the Forward watch —
  so a host reports every change through sync(open) and ends the episode with
  release(). Whether a close parks the registration (the Back press closed it,
  the spent entry is still worth watching) or releases it is decided here,
  once for every substrate.
- react-dialog, solid-dialog: both contribute only their host's lifecycle, so
  Solid inherits the Forward reopen with the same tests, spec, and story as
  React.
- native-dialog: records why the Forward half doesn't port — the platform has
  a Back gesture and no Forward one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vue was the stand-in second framework in the docs while Solid didn't exist.
Solid ships now, so the examples name the substrates that are actually here —
including the DOM layer's "who schedules the effect" argument, which reads
better with two real hosts than with a hypothetical one.

Published CHANGELOG entries keep their Vue mention: they are the release
record for what shipped, not current docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
oxlint and oxfmt both ignore `scripts/templates/**`, and both exit non-zero
when every file handed to them is ignored rather than treating it as a no-op.
A commit touching only scaffold templates therefore failed the hook on "no
files to check" — nothing was wrong with the code.

lint-staged's config becomes `.lintstagedrc.ts` (auto-discovered, and the same
TS-config style as knip/tsdown/vitest) so it can filter those paths out and
skip the tasks entirely when nothing checkable is staged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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