From f0d5ca4432774f5f88c1f0cc54ad7410a3c7d2fb Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Sun, 19 Jul 2026 12:41:17 +0200
Subject: [PATCH 01/10] =?UTF-8?q?feat(dialog):=20closeOnBack=20=E2=80=94?=
=?UTF-8?q?=20the=20host's=20Back=20navigation=20closes=20the=20open=20dia?=
=?UTF-8?q?log?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An opt-in flag (default false): while the dialog is open, Back closes it
instead of leaving the page — the pattern mobile users expect from a
full-screen overlay. It follows the shared dismissal contract: a new
onBackNavigation callback fires first (preventDefault vetoes), a
controlled dialog only records the intent, a nested stack unwinds one
layer per press, and it composes with `animated`.
The decision (gate, veto, controlled fork) lives once in the core
connect's backNavigate; substrates wire only their host mechanics. The
web half ships framework-free in @dunky.dev/dom-dialog:
interceptBackNavigation plants a guard entry in the session history,
re-arms it on a declined close, and consumes a still-current entry when
the dialog closes any other way — deferring the consume a microtask and
adopting an orphaned entry in place on synchronous release + re-register
(StrictMode's double-invoked effects), because a history traversal
queued by history.back() is not reliably delivered once another entry
is pushed before it lands.
Co-Authored-By: Claude Fable 5
---
.changeset/dialog-close-on-back.md | 30 +++++
packages/core/dialog/SPEC.md | 32 +++--
packages/core/dialog/src/connect.ts | 19 +++
packages/core/dialog/src/index.ts | 1 +
packages/core/dialog/src/machine.ts | 3 +
packages/core/dialog/src/types.ts | 17 +++
packages/core/dialog/tests/machine.test.ts | 46 +++++++
packages/dom/utils/dialog/README.md | 7 ++
packages/dom/utils/dialog/src/index.ts | 1 +
.../dialog/src/intercept-back-navigation.ts | 113 ++++++++++++++++++
.../tests/intercept-back-navigation.test.ts | 95 +++++++++++++++
packages/react/dialog/SPEC.md | 8 ++
packages/react/dialog/src/dialog.tsx | 21 ++++
packages/react/dialog/tests/dialog.test.tsx | 42 +++++++
14 files changed, 425 insertions(+), 10 deletions(-)
create mode 100644 .changeset/dialog-close-on-back.md
create mode 100644 packages/dom/utils/dialog/src/intercept-back-navigation.ts
create mode 100644 packages/dom/utils/dialog/tests/intercept-back-navigation.test.ts
diff --git a/.changeset/dialog-close-on-back.md b/.changeset/dialog-close-on-back.md
new file mode 100644
index 0000000..84abc67
--- /dev/null
+++ b/.changeset/dialog-close-on-back.md
@@ -0,0 +1,30 @@
+---
+'@dunky.dev/dialog': minor
+'@dunky.dev/react-dialog': minor
+'@dunky.dev/dom-dialog': minor
+---
+
+Add `closeOnBack` — the host's Back navigation closes the open dialog instead
+of leaving the page, the pattern mobile users expect from a full-screen
+overlay. Off by default.
+
+```tsx
+
+```
+
+It follows the shared dismissal contract: `onBackNavigation` fires first and
+`preventDefault()` vetoes, a controlled dialog only records the intent (close
+it from your own state as usual), a nested stack unwinds one layer per Back
+press, and it composes with `animated` (Back plays the exit animation). The
+decision — gate, veto, controlled — lives once in the core's `backNavigate`;
+substrates only wire their host's mechanics to it.
+
+On the web (`@dunky.dev/dom-dialog`'s `interceptBackNavigation`), opening
+plants a guard entry in the session history and Back consumes it. A dialog
+closed any other way consumes its own entry too, so no leftover ever swallows
+a later Back press — including across reopen races (React StrictMode's
+double-invoked effects adopt the entry in place rather than queueing a
+history traversal, which browsers don't reliably deliver once another entry
+is pushed).
diff --git a/packages/core/dialog/SPEC.md b/packages/core/dialog/SPEC.md
index 337a6f9..7d5b991 100644
--- a/packages/core/dialog/SPEC.md
+++ b/packages/core/dialog/SPEC.md
@@ -70,6 +70,17 @@ Opting into the alert-dialog role changes the defaults
for urgent, destructive interruptions — modality is inherent and outside
presses don't dismiss by default, so the user must choose an action.
+The host's Back navigation can be a dismissal too (`closeOnBack`, off by
+default): while the dialog is open, Back closes it instead of leaving the
+page — the pattern mobile users expect from a full-screen overlay. It follows
+the shared dismissal contract: `onBackNavigation` fires first and
+`preventDefault()` vetoes, a controlled dialog only records the intent, and a
+nested stack unwinds one layer per press. The substrate wires the host
+mechanics (the web plants a guard entry in the session history; a native host
+wires its hardware back handler); a dialog closed any other way leaves no
+trace behind — its guard entry is consumed, not left to swallow the next
+Back press.
+
Dialogs can be nested — a dialog opened from within another stacks on top of
it, and the stack unwinds one layer at a time. The full contract is
[Nesting](#nesting) under Accessibility.
@@ -191,13 +202,14 @@ choice, not the behavior it produces (that's spec'd above). The dialog ships
headless: parts carry behavior and ARIA wiring plus a `data-state` attribute
(`open` / `closed`) for styling and animation; visuals belong to the consumer.
-| Position | Why |
-| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
-| `open` delegates to `@dunky.dev/controllable`; `onOpenChange` reacts to the state, not to intents | One shared mechanic across primitives, and the callback structurally can't drift from the controlled contract. |
-| Dismissal intents are distinct events (`escape`, `interact.outside`) | Their gating lives in core guards — no substrate re-implements the settings. |
-| One base id, per-part ids derived from it | The cross-part ARIA references (controls / labelledby / describedby) can never disagree. |
-| Part presence lives in machine context (`part.presence` events) | The rendered-parts rule holds in every substrate with no substrate bookkeeping. |
-| This contract owns modality, dismissal, and focus | A substrate must not hand authority to host built-ins (e.g. `showModal()`) — behavior can't fork per host. |
-| The exit window is a machine state; `exit.complete` comes from the substrate | Reopen-during-exit is a named transition, not a substrate-side unmount race; only the host knows when paint finished. |
-| A `closing` dialog has already left the stack — focus, Escape, containment move on immediately | The exit is purely cosmetic; the layer beneath must not wait on an animation to become interactive again. |
-| The `intent` slot records every declared intent, drives no callback | Reserved as the request channel a stack-scoped close needs to traverse controlled layers. |
+| Position | Why |
+| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| `open` delegates to `@dunky.dev/controllable`; `onOpenChange` reacts to the state, not to intents | One shared mechanic across primitives, and the callback structurally can't drift from the controlled contract. |
+| Dismissal intents are distinct events (`escape`, `interact.outside`, `history.back`) | Their gating lives in core guards — no substrate re-implements the settings. |
+| Back navigation reports through one `backNavigate` on the api | The callback, veto, and controlled fork live once in the connect; only the host's back mechanics differ per substrate. |
+| One base id, per-part ids derived from it | The cross-part ARIA references (controls / labelledby / describedby) can never disagree. |
+| Part presence lives in machine context (`part.presence` events) | The rendered-parts rule holds in every substrate with no substrate bookkeeping. |
+| This contract owns modality, dismissal, and focus | A substrate must not hand authority to host built-ins (e.g. `showModal()`) — behavior can't fork per host. |
+| The exit window is a machine state; `exit.complete` comes from the substrate | Reopen-during-exit is a named transition, not a substrate-side unmount race; only the host knows when paint finished. |
+| A `closing` dialog has already left the stack — focus, Escape, containment move on immediately | The exit is purely cosmetic; the layer beneath must not wait on an animation to become interactive again. |
+| The `intent` slot records every declared intent, drives no callback | Reserved as the request channel a stack-scoped close needs to traverse controlled layers. |
diff --git a/packages/core/dialog/src/connect.ts b/packages/core/dialog/src/connect.ts
index d809a99..1adbfc2 100644
--- a/packages/core/dialog/src/connect.ts
+++ b/packages/core/dialog/src/connect.ts
@@ -1,6 +1,7 @@
import { makeReaction, type Connect } from '@dunky.dev/state-machine'
import type { AttrBindings, EventBindings, PointerPayload } from '@dunky.dev/state-machine-bindings'
import type {
+ BackNavigationPayload,
DialogContext,
DialogIds,
DialogMachineEvent,
@@ -36,6 +37,12 @@ export interface DialogApi {
role: DialogRole
ids: DialogIds
setOpen: (open: boolean) => void
+ /** Reports the host's Back navigation. The whole decision lives here, once:
+ * `onBackNavigation` fires first (`preventDefault()` vetoes), the machine
+ * gates on `closeOnBack`, and the controlled contract applies — a substrate
+ * only wires its host mechanics (a session-history guard entry on the web, a
+ * hardware back handler on native) to this call. */
+ backNavigate: () => void
parts: {
trigger: DialogPartBindings
backdrop: DialogPartBindings
@@ -76,6 +83,18 @@ export const dialogConnect: Connect<
if (open === next) return
send({ type: next ? 'open' : 'close' })
},
+ backNavigate() {
+ // The host's back has no cancelable event — synthesize the veto payload
+ // so the callback contract matches the other dismissals.
+ const payload: BackNavigationPayload = {
+ defaultPrevented: false,
+ preventDefault() {
+ payload.defaultPrevented = true
+ },
+ }
+ props.onBackNavigation?.(payload)
+ if (payload.defaultPrevented !== true) send({ type: 'history.back' })
+ },
parts: {
trigger: {
hasPopup: 'dialog',
diff --git a/packages/core/dialog/src/index.ts b/packages/core/dialog/src/index.ts
index c1f7e99..bfac106 100644
--- a/packages/core/dialog/src/index.ts
+++ b/packages/core/dialog/src/index.ts
@@ -1,6 +1,7 @@
export { dialogMachine, type DialogMachine } from './machine'
export { dialogConnect, type DialogApi, type DialogPartBindings } from './connect'
export type {
+ BackNavigationPayload,
DialogCallbacks,
DialogContext,
DialogIds,
diff --git a/packages/core/dialog/src/machine.ts b/packages/core/dialog/src/machine.ts
index 6fd5e53..ad27a49 100644
--- a/packages/core/dialog/src/machine.ts
+++ b/packages/core/dialog/src/machine.ts
@@ -16,6 +16,7 @@ type DialogGuard = Guard
const canEscape: DialogGuard = ({ context }) => context.closeOnEscape
const canDismissOutside: DialogGuard = ({ context }) => context.closeOnInteractOutside
+const canCloseOnBack: DialogGuard = ({ context }) => context.closeOnBack
// Every open/close intent is recorded in `open.intent`; whether it also
// transitions is intent's controlled/uncontrolled fork. `synced` is the full
@@ -44,6 +45,7 @@ export function dialogMachine(
// An alert dialog interrupts for a response — an outside press must not
// dismiss it unless explicitly opted in.
closeOnInteractOutside: options.closeOnInteractOutside ?? role === 'dialog',
+ closeOnBack: options.closeOnBack ?? false,
open: controllable(options.open),
// The substrate supplies a unique id; `dialog` is only a bare fallback.
id: options.id ?? 'dialog',
@@ -75,6 +77,7 @@ export function dialogMachine(
target: exitTo,
value: false,
}),
+ 'history.back': intend('open', { guard: canCloseOnBack, target: exitTo, value: false }),
'controlled.sync': synced('open', { value: false, target: exitTo }),
},
},
diff --git a/packages/core/dialog/src/types.ts b/packages/core/dialog/src/types.ts
index b217424..34b72df 100644
--- a/packages/core/dialog/src/types.ts
+++ b/packages/core/dialog/src/types.ts
@@ -33,6 +33,7 @@ export interface DialogContext {
modal: boolean
closeOnEscape: boolean
closeOnInteractOutside: boolean
+ closeOnBack: boolean
// The consumer-ownable open value. A controlled machine never moves on its
// own — only `controlled.sync` (the prop echo) transitions it, and the
// controlled flag tracks the prop's presence live.
@@ -57,10 +58,19 @@ export type DialogMachineEvent =
| { type: 'toggle' }
| { type: 'escape' }
| { type: 'interact.outside' }
+ | { type: 'history.back' }
| { type: 'exit.complete' }
| ControlledSync
| { type: 'part.presence'; part: DialogPart; present: boolean }
+/** The payload for a back-navigation dismissal. Synthesized by the connect —
+ * the host's back has no cancelable event of its own — carrying only the veto
+ * contract every dismissal callback shares. */
+export interface BackNavigationPayload {
+ defaultPrevented?: boolean
+ preventDefault?: () => void
+}
+
export interface DialogCallbacks {
/** Fired on every open/close transition with the new value. */
onOpenChange?: (open: boolean) => void
@@ -68,6 +78,8 @@ export interface DialogCallbacks {
onEscapeKeyDown?: (event: KeyboardPayload) => void
/** Fired before an outside-press dismissal; `preventDefault()` vetoes it. */
onInteractOutside?: (event?: PointerPayload) => void
+ /** Fired before a back-navigation dismissal; `preventDefault()` vetoes it. */
+ onBackNavigation?: (event?: BackNavigationPayload) => void
}
/**
@@ -94,6 +106,11 @@ export interface DialogOptions extends DialogCallbacks {
/** Whether pressing the backdrop closes the dialog.
* @default true — false when `role="alertdialog"` */
closeOnInteractOutside?: boolean
+ /** Treats the host's Back navigation as a dismissal: while the dialog is
+ * open, Back closes it instead of leaving the page — one layer per press in
+ * a nested stack. The substrate wires the host mechanics (the web plants a
+ * guard entry in the session history). @default false */
+ closeOnBack?: boolean
/** Reserves an exit window for a close animation: closing passes through the
* `closing` state (`data-state="closing"` styles the exit) and the dialog
* unmounts on `exit.complete` instead of immediately. @default false */
diff --git a/packages/core/dialog/tests/machine.test.ts b/packages/core/dialog/tests/machine.test.ts
index eca2327..3be44f6 100644
--- a/packages/core/dialog/tests/machine.test.ts
+++ b/packages/core/dialog/tests/machine.test.ts
@@ -242,6 +242,52 @@ describe('dialog connect — reactions', () => {
})
})
+describe('dialog machine — back navigation', () => {
+ it('ignores history.back without closeOnBack (the default)', () => {
+ const { service } = build({ defaultOpen: true })
+ service.send({ type: 'history.back' })
+ expect(service.state).toBe('open')
+ expect(service.context.open.intent).toBeNull()
+ })
+
+ it('closes on history.back when closeOnBack, through the exit window when animated', () => {
+ const { service } = build({ defaultOpen: true, closeOnBack: true })
+ service.send({ type: 'history.back' })
+ expect(service.state).toBe('closed')
+
+ const animated = build({ defaultOpen: true, closeOnBack: true, animated: true })
+ animated.service.send({ type: 'history.back' })
+ expect(animated.service.state).toBe('closing')
+ })
+
+ it('backNavigate fires the callback and dismisses unless vetoed', () => {
+ const onBackNavigation = vi.fn()
+ const { service, connection } = build({
+ defaultOpen: true,
+ closeOnBack: true,
+ onBackNavigation,
+ })
+ connection.snapshot.backNavigate()
+ expect(onBackNavigation).toHaveBeenCalledTimes(1)
+ expect(service.state).toBe('closed')
+
+ const vetoed = build({
+ defaultOpen: true,
+ closeOnBack: true,
+ onBackNavigation: event => event?.preventDefault?.(),
+ })
+ vetoed.connection.snapshot.backNavigate()
+ expect(vetoed.service.state).toBe('open')
+ })
+
+ it('a controlled dialog records the intent and stays put', () => {
+ const { service, connection } = build({ open: true, closeOnBack: true })
+ connection.snapshot.backNavigate()
+ expect(service.state).toBe('open')
+ expect(service.context.open.intent).toEqual({ value: false })
+ })
+})
+
describe('dialog machine — animated exit', () => {
it('a close intent holds the exit window open until exit.complete', () => {
const { service } = build({ defaultOpen: true, animated: true })
diff --git a/packages/dom/utils/dialog/README.md b/packages/dom/utils/dialog/README.md
index 0d40efd..5bcb887 100644
--- a/packages/dom/utils/dialog/README.md
+++ b/packages/dom/utils/dialog/README.md
@@ -14,6 +14,13 @@ what every substrate's dialog must agree on:
- **Initial focus** — `getInitialFocus` resolves where focus moves on open: a
dialog that collects input starts at its first form field; any other
content keeps focus on the dialog window itself.
+- **Back navigation** — `interceptBackNavigation` plants a guard entry in the
+ session history so the host's Back closes the dialog instead of leaving the
+ page. Guards stack in open order, so a nested stack unwinds one layer per
+ press; a declined close (veto, controlled) re-arms the entry; releasing
+ consumes a still-current entry so it can't swallow the next Back, and a
+ synchronous release + re-register adopts the entry in place — no traversal,
+ no race.
- **The exit window** — an animated dialog leaves the stack the moment it
starts closing, but keeps painting until its exit visual finishes.
`hideExitingLayer` takes the still-painting layer out of the page's
diff --git a/packages/dom/utils/dialog/src/index.ts b/packages/dom/utils/dialog/src/index.ts
index e07bf03..9ce7f89 100644
--- a/packages/dom/utils/dialog/src/index.ts
+++ b/packages/dom/utils/dialog/src/index.ts
@@ -2,3 +2,4 @@ export { registerDialog, isTopmostDialog, type DialogLayer } from './stack'
export { getInitialFocus } from './get-initial-focus'
export { watchExitAnimation } from './watch-exit-animation'
export { hideExitingLayer } from './hide-exiting-layer'
+export { interceptBackNavigation } from './intercept-back-navigation'
diff --git a/packages/dom/utils/dialog/src/intercept-back-navigation.ts b/packages/dom/utils/dialog/src/intercept-back-navigation.ts
new file mode 100644
index 0000000..a589d85
--- /dev/null
+++ b/packages/dom/utils/dialog/src/intercept-back-navigation.ts
@@ -0,0 +1,113 @@
+// Marks a dialog's guard entry in the session history; the value says which
+// interceptor owns the entry.
+const STATE_KEY = 'dunky.dialog.back'
+
+interface BackGuard {
+ id: number
+ onBack: () => boolean
+}
+
+// One shared registry + one popstate listener across every dialog (mirroring
+// the layer stack): a Back press pops exactly one entry, so only the
+// interceptor whose guard entry vanished may answer — the ones beneath see
+// their entry still current and stay armed. That ordering is what makes a
+// nested stack unwind one layer per press with no cross-dialog bookkeeping.
+const guards: BackGuard[] = []
+let nextGuardId = 0
+// Pops this module caused itself (consuming a guard entry on release). The
+// browser reports them through the same popstate as a user's Back — count
+// them so they are never read as one and unwind another layer.
+let swallow = 0
+
+function currentGuardId(): number | undefined {
+ const state: unknown = history.state
+ if (typeof state !== 'object' || state === null) return undefined
+ const id = (state as Record)[STATE_KEY]
+ return typeof id === 'number' ? id : undefined
+}
+
+function isRegistered(id: number): boolean {
+ for (const guard of guards) if (guard.id === id) return true
+ return false
+}
+
+// The listener detaches only when nothing is left to hear: an in-flight
+// self-caused pop (swallow) still needs it even with every guard released.
+function detachWhenIdle(): void {
+ if (guards.length === 0 && swallow === 0) {
+ window.removeEventListener('popstate', onPopState)
+ }
+}
+
+function onPopState(): void {
+ if (swallow > 0) {
+ swallow--
+ // Self-heal: if our own pop consumed an entry a live guard still needs
+ // (it adopted the entry while the traversal was in flight), re-arm it.
+ const top = guards[guards.length - 1]
+ if (top !== undefined && top.id !== currentGuardId()) {
+ history.pushState({ [STATE_KEY]: top.id }, '')
+ }
+ detachWhenIdle()
+ return
+ }
+ // Unwind every guard the traversal jumped over, topmost first — a Back
+ // press covers one; a multi-entry jump (history.go(-n)) covers several.
+ const current = currentGuardId()
+ while (guards.length > 0) {
+ const top = guards[guards.length - 1] as BackGuard
+ if (top.id === current) break
+ if (top.onBack()) {
+ guards.pop()
+ continue
+ }
+ // Declined — vetoed, or a controlled dialog that hasn't followed yet:
+ // re-arm the guard entry so the next Back reaches this layer again.
+ history.pushState({ [STATE_KEY]: top.id }, '')
+ break
+ }
+ detachWhenIdle()
+}
+
+/**
+ * Plants a guard entry in the session history so the host's Back closes the
+ * dialog instead of leaving the page. `onBack` fires when the user pops the
+ * entry and returns whether the dialog actually closed — a decline re-arms
+ * the guard. The returned release (for a dialog closed by any other means)
+ * consumes a still-current guard entry so it can't swallow the next Back;
+ * an entry buried under later navigation is unreachable and left alone.
+ *
+ * Consumption is deferred a microtask and an orphaned-but-current entry is
+ * adopted (rewritten in place) by the next interceptor: a synchronous
+ * release -> re-register — StrictMode's double-invoked effect, a reopen in
+ * the same commit — nets out to zero traversals. That matters because a
+ * traversal queued by `history.back()` is not reliably delivered once
+ * another entry is pushed before it lands; never queuing one in that window
+ * removes the race instead of compensating for it.
+ */
+export function interceptBackNavigation(onBack: () => boolean): () => void {
+ const guard: BackGuard = { id: ++nextGuardId, onBack }
+ // Identical (type, listener) pairs dedupe, so attaching is idempotent.
+ window.addEventListener('popstate', onPopState)
+ const current = currentGuardId()
+ const adoptable = current !== undefined && !isRegistered(current)
+ guards.push(guard)
+ if (adoptable) history.replaceState({ [STATE_KEY]: guard.id }, '')
+ else history.pushState({ [STATE_KEY]: guard.id }, '')
+
+ return () => {
+ const index = guards.indexOf(guard)
+ if (index === -1) return // already unwound by the Back press itself
+ guards.splice(index, 1)
+ queueMicrotask(() => {
+ // Still ours and still current: nobody adopted it and no Back popped
+ // it — consume the entry. The listener stays until the pop lands.
+ if (currentGuardId() === guard.id) {
+ swallow++
+ history.back()
+ } else {
+ detachWhenIdle()
+ }
+ })
+ }
+}
diff --git a/packages/dom/utils/dialog/tests/intercept-back-navigation.test.ts b/packages/dom/utils/dialog/tests/intercept-back-navigation.test.ts
new file mode 100644
index 0000000..2d5f530
--- /dev/null
+++ b/packages/dom/utils/dialog/tests/intercept-back-navigation.test.ts
@@ -0,0 +1,95 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi } from 'vitest'
+import { interceptBackNavigation } from '@dunky.dev/dom-dialog'
+
+// jsdom's history traversal is asynchronous: back() returns immediately and
+// the state change + popstate land on a later task — await the event itself.
+const nextPop = (): Promise =>
+ new Promise(resolve => {
+ window.addEventListener('popstate', () => resolve(), { once: true })
+ })
+
+const pressBack = async (): Promise => {
+ const pop = nextPop()
+ history.back()
+ await pop
+}
+
+describe('interceptBackNavigation', () => {
+ it('plants a guard entry; Back pops it and fires onBack once', async () => {
+ const before: unknown = history.state
+ const onBack = vi.fn(() => true)
+ interceptBackNavigation(onBack)
+ expect(history.state).not.toEqual(before)
+
+ await pressBack()
+ expect(onBack).toHaveBeenCalledTimes(1)
+ expect(history.state).toEqual(before)
+ })
+
+ it('release consumes a still-current guard entry without firing onBack', async () => {
+ const before: unknown = history.state
+ const onBack = vi.fn(() => true)
+ const release = interceptBackNavigation(onBack)
+
+ const pop = nextPop()
+ release()
+ await pop
+ expect(onBack).not.toHaveBeenCalled()
+ expect(history.state).toEqual(before)
+ })
+
+ it('a declined close re-arms the guard so the next Back reaches it again', async () => {
+ const before: unknown = history.state
+ let accept = false
+ const onBack = vi.fn(() => accept)
+ interceptBackNavigation(onBack)
+
+ await pressBack()
+ expect(onBack).toHaveBeenCalledTimes(1)
+ expect(history.state).not.toEqual(before) // re-armed
+
+ accept = true
+ await pressBack()
+ expect(onBack).toHaveBeenCalledTimes(2)
+ expect(history.state).toEqual(before)
+ })
+
+ it('stacked guards unwind topmost-first, one layer per press', async () => {
+ const lower = vi.fn(() => true)
+ const upper = vi.fn(() => true)
+ interceptBackNavigation(lower)
+ interceptBackNavigation(upper)
+
+ await pressBack()
+ expect(upper).toHaveBeenCalledTimes(1)
+ expect(lower).not.toHaveBeenCalled()
+
+ await pressBack()
+ expect(lower).toHaveBeenCalledTimes(1)
+ })
+
+ // The StrictMode shape: a synchronous release -> re-register (double-invoked
+ // effect, same-commit reopen) must adopt the still-current entry in place —
+ // zero traversals, so there is no self-caused pop to race or misread.
+ it('a synchronous release + re-register adopts the entry with no traversal', async () => {
+ const before: unknown = history.state
+ const first = vi.fn(() => true)
+ const second = vi.fn(() => true)
+
+ const release = interceptBackNavigation(first)
+ const lengthAfterFirst = history.length
+ release()
+ interceptBackNavigation(second)
+ expect(history.length).toBe(lengthAfterFirst) // rewritten in place, not pushed
+
+ // Flush the deferred consume — adoption must have cancelled it.
+ await new Promise(resolve => queueMicrotask(resolve))
+ expect(second).not.toHaveBeenCalled()
+
+ await pressBack()
+ expect(second).toHaveBeenCalledTimes(1) // the user's Back still lands
+ expect(first).not.toHaveBeenCalled()
+ expect(history.state).toEqual(before)
+ })
+})
diff --git a/packages/react/dialog/SPEC.md b/packages/react/dialog/SPEC.md
index 53e045c..918b5e8 100644
--- a/packages/react/dialog/SPEC.md
+++ b/packages/react/dialog/SPEC.md
@@ -61,6 +61,12 @@ React-specific notes on top of the core contract:
Enter needs no state — the parts mount straight into `data-state="open"`,
so a CSS animation (or a transition via `@starting-style`) plays from
mount.
+- **Back navigation** (`closeOnBack`): opening plants a guard entry in the
+ session history, so the browser's Back closes the dialog instead of leaving
+ the page — one layer per press in a nested stack, per the core contract. A
+ dialog closed any other way consumes its entry, leaving nothing to swallow
+ a later Back; an entry buried under in-app navigation while the dialog is
+ open is left alone (Back then both navigates and closes the dialog).
- Everything ships headless, per the core contract's
[Internals](../../core/dialog/SPEC.md#internals).
@@ -82,6 +88,8 @@ The root: owns open/close state, renders no DOM. Accepts the core
| `escapeScope` | `'layer' \| 'stack'` | `'layer'` | How far an allowed Escape reaches: this dialog, or its whole stack. |
| `closeOnInteractOutside` | `boolean` | `true` — `false` for `role="alertdialog"` | Whether pressing the backdrop/viewport closes the dialog. |
| `animated` | `boolean` | `false` | Keeps the dialog mounted through `data-state="closing"` while its exit animation plays. |
+| `closeOnBack` | `boolean` | `false` | The browser's Back closes the open dialog instead of navigating (a guard entry in the session history). |
+| `onBackNavigation` | `(event?) => void` | — | Fired before a back-navigation dismissal; `preventDefault()` vetoes. |
| `onEscapeKeyDown` | `(event) => void` | — | Fired before an Escape dismissal; `preventDefault()` vetoes. |
| `onInteractOutside` | `(event?) => void` | — | Fired before an outside-press dismissal; `preventDefault()` vetoes. |
| `id` | `string` | auto (`useId`) | Base id for the parts; per-part ids are derived from it. |
diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx
index 1ffa0a0..ad94e9f 100644
--- a/packages/react/dialog/src/dialog.tsx
+++ b/packages/react/dialog/src/dialog.tsx
@@ -19,6 +19,7 @@ import type { DialogOptions } from '@dunky.dev/dialog'
import {
getInitialFocus,
hideExitingLayer,
+ interceptBackNavigation,
isTopmostDialog,
registerDialog,
watchExitAnimation,
@@ -44,6 +45,26 @@ export const Dialog: ((props: DialogProps) => ReactNode) & Parts = ({ children,
const depth = (useContext(DialogContext)?.depth ?? 0) + 1
const { api, machine } = useDialog(options)
const backdropRef = useRef(null)
+
+ // Read through a ref so the history guard's lifecycle follows the open
+ // state alone: re-arming on every render (fresh callback identities) would
+ // churn real session-history entries.
+ const apiRef = useRef(api)
+ apiRef.current = api
+
+ // closeOnBack: while open, a guard entry in the session history turns the
+ // host's Back into a dismissal instead of a navigation. Every decision
+ // (gate, veto, controlled) lives in the core's backNavigate; this effect
+ // only wires the web mechanics. It lives on the root — the guard concerns
+ // the dialog's openness, not any rendered part.
+ useEffect(() => {
+ if (!api.open || !machine.context.closeOnBack) return
+ return interceptBackNavigation(() => {
+ apiRef.current.backNavigate()
+ return !machine.matches('open')
+ })
+ }, [api.open, machine])
+
return (
{children}
diff --git a/packages/react/dialog/tests/dialog.test.tsx b/packages/react/dialog/tests/dialog.test.tsx
index 9dd3fd1..48fd63f 100644
--- a/packages/react/dialog/tests/dialog.test.tsx
+++ b/packages/react/dialog/tests/dialog.test.tsx
@@ -390,6 +390,48 @@ describe('Dialog', () => {
})
})
+ describe('back navigation', () => {
+ // jsdom's history traversal is asynchronous — await the popstate itself.
+ const nextPop = (): Promise =>
+ new Promise(resolve => {
+ window.addEventListener('popstate', () => resolve(), { once: true })
+ })
+
+ it('closes on the browser Back instead of navigating', async () => {
+ const before: unknown = window.history.state
+ render()
+ openDialog()
+ expect(window.history.state).not.toEqual(before) // the guard entry is planted
+
+ const pop = nextPop()
+ await act(async () => {
+ window.history.back()
+ await pop
+ })
+ expect(screen.queryByRole('dialog')).toBeNull()
+ expect(window.history.state).toEqual(before) // consumed by the press itself
+ })
+
+ it('closing any other way consumes the guard entry', async () => {
+ const before: unknown = window.history.state
+ render()
+ expect(window.history.state).not.toEqual(before)
+
+ const pop = nextPop()
+ act(pressEscape)
+ await act(async () => {
+ await pop
+ })
+ expect(window.history.state).toEqual(before) // no leftover to swallow a Back
+ })
+
+ it('plants no history entry without the flag', () => {
+ const before: unknown = window.history.state
+ render()
+ expect(window.history.state).toEqual(before)
+ })
+ })
+
describe('exit animation', () => {
const fireTransitionEnd = (element: Element): void => {
act(() => {
From a53256560df06e52ed0554a4d78ed5900296a813 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Sun, 19 Jul 2026 13:40:51 +0200
Subject: [PATCH 02/10] docs(dialog): nested story drives its controlled layers
at the source
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The nested story predates the controlled-open rework (onOpenChange
reports actual changes only): its three layers are controlled but wired
no dismissal handlers, so under the reworked contract Trigger, Close,
and Escape only recorded intent and the stack never moved. Wire each
layer the way the contract prescribes — the consumer's own handlers on
Trigger/Close, plus onEscapeKeyDown/onInteractOutside driving the prop —
and cover that consumer pattern with a test so the story's shape can't
silently rot again.
Co-Authored-By: Claude Fable 5
---
.../react/dialog/stories/dialog.stories.tsx | 47 ++++++++++++----
packages/react/dialog/tests/dialog.test.tsx | 56 ++++++++++++++++++-
2 files changed, 92 insertions(+), 11 deletions(-)
diff --git a/packages/react/dialog/stories/dialog.stories.tsx b/packages/react/dialog/stories/dialog.stories.tsx
index 7607a85..1e91422 100644
--- a/packages/react/dialog/stories/dialog.stories.tsx
+++ b/packages/react/dialog/stories/dialog.stories.tsx
@@ -298,7 +298,11 @@ export const scoped: StoryType = {
}
// "Close all" is consumer-side for now — `Close scope="stack"` is spec-only, so
-// the three layers are controlled and one handler drops them together.
+// the three layers are controlled and one handler drops them together. And a
+// controlled dialog never moves on its own: each layer decides its dismissals
+// at the source — its own handlers on Trigger/Close, and the dismissal
+// callbacks (`onEscapeKeyDown` / `onInteractOutside`) — per the controlled
+// contract; `onOpenChange` only reports changes that actually happened.
const NestedDialogs = () => {
const [outerOpen, setOuterOpen] = useState(true)
const [innerOpen, setInnerOpen] = useState(false)
@@ -309,8 +313,13 @@ const NestedDialogs = () => {
setOuterOpen(false)
}
return (
-
))}
-
-
-
-
-
- ),
-}
-
-export const withCloseButton: StoryType = {
- render: () => (
-
- Open dialog
-
-
-
-
-
- ×
-
- Share board
-
- Anyone with the link can view this board. The corner button and Escape both dismiss.
-
-
@@ -201,7 +201,8 @@ export const loginForm: StoryType = {
-
+
+ Sign in
Focus moves to the first field on open, and stays trapped inside while the dialog is
@@ -227,7 +228,6 @@ export const loginForm: StoryType = {
/>
- CancelSign in
@@ -245,10 +245,10 @@ export const trigger: StoryType = {
-
+
+ Closed by defaultOnly the trigger renders until it is pressed.
-
@@ -275,14 +275,14 @@ const ScopedDialog = () => {
-
+
+ Scoped dialog
Portaled into the panel boundary; the backdrop and viewport are `absolute`, so
the overlay fills the panel's visible box and stays put while the
background scrolls behind it.
-
@@ -300,9 +300,10 @@ export const scoped: StoryType = {
// "Close all" is consumer-side for now — `Close scope="stack"` is spec-only, so
// the three layers are controlled and one handler drops them together. And a
// controlled dialog never moves on its own: each layer decides its dismissals
-// at the source — its own handlers on Trigger/Close, and the dismissal
-// callbacks (`onEscapeKeyDown` / `onInteractOutside`) — per the controlled
-// contract; `onOpenChange` only reports changes that actually happened.
+// at the source — its Trigger handler, its own action buttons, and the
+// dismissal callbacks (`onEscapeKeyDown` / `onInteractOutside`) — per the
+// controlled contract; `onOpenChange` only reports changes that actually
+// happened.
const NestedDialogs = () => {
const [outerOpen, setOuterOpen] = useState(true)
const [innerOpen, setInnerOpen] = useState(false)
@@ -365,23 +366,21 @@ const NestedDialogs = () => {
Close all
- setInnermostOpen(false)}>
- Close
-
+ setInnermostOpen(false)}>Close
diff --git a/packages/react/dialog/tests/dialog.test.tsx b/packages/react/dialog/tests/dialog.test.tsx
index f24ba93..d82dbe3 100644
--- a/packages/react/dialog/tests/dialog.test.tsx
+++ b/packages/react/dialog/tests/dialog.test.tsx
@@ -180,9 +180,9 @@ describe('Dialog', () => {
expect(onOpenChange).toHaveBeenCalledTimes(1)
})
- // The controlled contract's consumer side (the nested story's pattern): the
- // dialog never moves on its own, so the consumer's own handlers on
- // Trigger/Close and the dismissal callbacks are what drive the prop.
+ // The controlled contract's consumer side: the dialog never moves on its
+ // own, so the consumer's own handlers on the parts and the dismissal
+ // callbacks are what drive the prop.
it('a controlled stack closes through handlers wired at the source', () => {
const ControlledStack = () => {
const [outerOpen, setOuterOpen] = useState(true)
From 61cc700fd111e6aaa41e567c6846b9a021f2b43c Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Sun, 19 Jul 2026 16:10:02 +0200
Subject: [PATCH 06/10] Update ARCHITECTURE.md
---
ARCHITECTURE.md | 2 --
1 file changed, 2 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 38e7991..8c30a8f 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -28,9 +28,7 @@ packages/
|
+- dom/
| +- utils/ framework-free DOM utilities, one package per util
-| +- dialog/ @dunky.dev/dom-dialog
| +- focus-trap/ @dunky.dev/dom-focus-trap
-| +- navigation/ @dunky.dev/dom-navigation
| +- scroll-lock/ @dunky.dev/dom-scroll-lock
| +- ...
|
From 888266d24379eb70075f46e15b283622824c945b Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Sun, 19 Jul 2026 17:39:13 +0200
Subject: [PATCH 07/10] refactor(dialog): namespace the back-guard history
state key
The session-history marker becomes @dunky.back (was dunky.back), matching the @dunky namespace used elsewhere. Pre-release, so no compatibility concern.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/dom/utils/navigation/src/intercept-back-navigation.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/dom/utils/navigation/src/intercept-back-navigation.ts b/packages/dom/utils/navigation/src/intercept-back-navigation.ts
index ff102bf..84d3651 100644
--- a/packages/dom/utils/navigation/src/intercept-back-navigation.ts
+++ b/packages/dom/utils/navigation/src/intercept-back-navigation.ts
@@ -1,6 +1,6 @@
// Marks a layer's guard entry in the session history; the value says which
// interceptor owns the entry.
-const STATE_KEY = 'dunky.back'
+const STATE_KEY = '@dunky.back'
interface BackGuard {
id: number
From f58ab104c915c5c47fbdb4dbca4d2599ddf88309 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Sun, 19 Jul 2026 17:39:14 +0200
Subject: [PATCH 08/10] docs(dialog): document the reload caveat in the
navigation README
The guard entry survives a reload but the layer's open-state does not, so a transiently-opened layer leaves a dead same-URL entry and the first Back appears to do nothing. Document the URL-as-source-of-truth recipe for layers that must survive reload, and slim the README prose.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/dom/utils/navigation/README.md | 64 +++++++++++++++----------
1 file changed, 38 insertions(+), 26 deletions(-)
diff --git a/packages/dom/utils/navigation/README.md b/packages/dom/utils/navigation/README.md
index 8445bdc..88fb70f 100644
--- a/packages/dom/utils/navigation/README.md
+++ b/packages/dom/utils/navigation/README.md
@@ -2,44 +2,56 @@
Framework-free browser-navigation helpers.
-## Back navigation
-
-`interceptBackNavigation` plants a
-guard entry in the session history so the host's Back dismisses a layer — a
-dialog, drawer, sheet, anything overlaid on the page — instead of leaving it,
-the pattern mobile users expect from a full-screen overlay.
-
-Guards stack in open order and one shared popstate listener arbitrates: a
-Back press pops exactly one entry, so stacked layers unwind one per press
-with no cross-layer bookkeeping. A declined close (a veto, or a controlled
-layer that decides later) re-arms the entry; releasing consumes a
-still-current entry so it can't swallow the next Back; and a synchronous
-release + re-register (StrictMode's double-invoked effects, a same-commit
-reopen) adopts the entry in place — no history traversal is queued, so there
-is no race to compensate for. An entry buried under later in-app navigation
-is unreachable and left alone.
-
-Substrate bindings wrap this — e.g. `@dunky.dev/react-dialog`'s
-`closeOnBack` — so every framework inherits identical Back semantics.
-
## Install
```sh
npm install @dunky.dev/dom-navigation
```
-## Usage
+## interceptBackNavigation
+
+Plants a guard entry in the session history so the host's Back dismisses an
+overlaid layer — a dialog, drawer, sheet — instead of leaving the page.
```ts
import { interceptBackNavigation } from '@dunky.dev/dom-navigation'
-// On open: plant the guard. `onBack` returns whether the layer closed —
-// returning false (vetoed, deferred) re-arms the guard for the next press.
+// On open — arm the guard. `onBack` returns whether the layer closed.
const release = interceptBackNavigation(() => {
- requestClose()
- return isClosed()
+ close()
+ return true // return false to veto: the guard re-arms for the next Back
})
-// On close by any other means: consume the guard entry.
+// On close by any other means — release the guard.
release()
```
+
+Guards stack, so a Back press unwinds one layer per press. Substrate bindings
+wrap this — e.g. `@dunky.dev/react-dialog`'s `closeOnBack`.
+
+## Reload
+
+The guard entry survives a reload; the layer's open-state doesn't. A layer
+opened transiently and then reloaded boots closed, leaving a dead same-URL
+entry — so the first Back appears to do nothing.
+
+The interceptor can't fix this: on reload only the host knows whether the layer
+should reopen. So when a layer must survive reload (or be shareable, or reopen
+on Forward), keep its open-state in the URL and derive the layer from it. Back
+closes for free, and reload restores the layer because the URL says so.
+
+```ts
+// The URL is the source of truth — survives reload, no orphan to step over.
+const isOpen = () => location.hash === '#dialog'
+
+const open = () => history.pushState(null, '', '#dialog')
+const close = () => {
+ if (isOpen()) history.back()
+}
+
+// Re-render from isOpen() on every history change: Back, Forward, and reload.
+window.addEventListener('popstate', render)
+```
+
+For a dialog that need not outlive a reload, `interceptBackNavigation` is
+exactly right and needs none of this.
From 0bc1253376fe5c9b3f9a4fe3ded67e4468738a00 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Sun, 19 Jul 2026 17:39:15 +0200
Subject: [PATCH 09/10] docs(dialog): add a closeOnBack Storybook story
A defaultOpen dialog with closeOnBack; an in-dialog button stands in for the browser's Back so the dismissal is demonstrable in the chrome-less canvas.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../react/dialog/stories/dialog.stories.tsx | 30 +++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/packages/react/dialog/stories/dialog.stories.tsx b/packages/react/dialog/stories/dialog.stories.tsx
index 214d3c4..a44a56a 100644
--- a/packages/react/dialog/stories/dialog.stories.tsx
+++ b/packages/react/dialog/stories/dialog.stories.tsx
@@ -392,3 +392,33 @@ const NestedDialogs = () => {
export const nested: StoryType = {
render: () => ,
}
+
+// closeOnBack turns the host's Back into a dismissal: while the dialog is open,
+// a guard entry sits in the session history, so the browser's Back closes the
+// dialog instead of leaving the page — what mobile users expect from a
+// full-screen overlay. The canvas has no browser chrome, so the in-dialog
+// button stands in for a real Back press by calling `history.back()`.
+export const closeOnBack: StoryType = {
+ render: () => (
+
+ Open dialog
+
+
+
+
+
+ Rename board
+
+ The browser's Back closes this dialog instead of navigating away. Press Back — or
+ the button below, which stands in for it here — and the dialog dismisses while the
+ page stays put.
+
+
+ window.history.back()}>Simulate browser Back
+
+
+
+
+
+ ),
+}
From 966f4d84ff7a1f348b312423d6c121767608aa82 Mon Sep 17 00:00:00 2001
From: Ivan Banov
Date: Sun, 19 Jul 2026 17:50:47 +0200
Subject: [PATCH 10/10] docs(dialog): drop the React reference from the
agnostic back-guard doc
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The util is framework-free; its doc explained the deferred-consumption race in terms of StrictMode's double-invoked effect and a same-commit reopen. Restate it agnostically — a synchronous release then re-register in the same turn — so the substrate concern no longer leaks into the agnostic package.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../navigation/src/intercept-back-navigation.ts | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/packages/dom/utils/navigation/src/intercept-back-navigation.ts b/packages/dom/utils/navigation/src/intercept-back-navigation.ts
index 84d3651..a12cf33 100644
--- a/packages/dom/utils/navigation/src/intercept-back-navigation.ts
+++ b/packages/dom/utils/navigation/src/intercept-back-navigation.ts
@@ -79,13 +79,14 @@ function onPopState(): void {
* entry so it can't swallow the next Back; an entry buried under later
* navigation is unreachable and left alone.
*
- * Consumption is deferred a microtask and an orphaned-but-current entry is
- * adopted (rewritten in place) by the next interceptor: a synchronous
- * release -> re-register — StrictMode's double-invoked effect, a reopen in
- * the same commit — nets out to zero traversals. That matters because a
- * traversal queued by `history.back()` is not reliably delivered once
- * another entry is pushed before it lands; never queuing one in that window
- * removes the race instead of compensating for it.
+ * Consumption is deferred a microtask so a release immediately followed by a
+ * re-register in the same synchronous turn nets out to zero traversals: the
+ * re-register finds the entry still current but no longer owned and adopts it
+ * in place (rewrites the marker), so when the deferred consumption runs the
+ * entry is no longer this guard's and no `history.back()` is queued. That
+ * matters because a traversal queued by `history.back()` is not reliably
+ * delivered once another entry is pushed before it lands; not queuing one in
+ * that window removes the race instead of compensating for it.
*/
export function interceptBackNavigation(onBack: () => boolean): () => void {
const guard: BackGuard = { id: ++nextGuardId, onBack }