Problem
webjs's client router uses a Turbo-style URL-keyed snapshot cache: snapshotCurrent() (packages/core/src/router-client.js ~L766) captures document.documentElement.outerHTML, and Back/Forward (popstate) restores it instantly. Because the snapshot is a raw outerHTML clone of the live page, any OPEN transient overlay is captured open and restored open on Forward.
Repro (found dogfooding on iOS, #745): open a hover-card, press Back, press Forward, the hover-card is still open. This affects the whole class of transient overlays (hover-card, tooltip, dropdown-menu + sub, context-menu, popover, sonner toasts, possibly select/combobox/dialog), not just hover-card.
Turbo solves exactly this with turbo:before-cache. webjs has no equivalent; its router events are webjs:navigate / webjs:prefetch / webjs:navigation-error / webjs:frame-busy / webjs:submit-start|end, with no before-cache.
Design / approach
Add a webjs:before-cache event the router dispatches on document synchronously inside snapshotCurrent(), immediately BEFORE the document.documentElement.outerHTML read, so a handler's DOM mutations are captured in the cached snapshot. The timing contract is the whole point: it fires before the clone, on the page being cached (not the destination), and the live-DOM mutation is invisible because the page is about to be swapped for the navigation target.
Components and app code listen and strip transient state. The kit's transient overlays adopt it: in connectedCallback add a webjs:before-cache listener that resets their reflected open state (this.open = false), removed in disconnectedCallback. DRY it via a small shared helper in the registry lib (the registry is source-copied, so a helper is copied with it).
Optional future sugar (NOT this PR): a WebComponent base-class beforeCache() lifecycle or static resetOnCache = ['open']. The event stays the primitive.
Implementation notes (for the implementing agent)
- Core:
packages/core/src/router-client.js, snapshotCurrent(url) ~L766. Dispatch document.dispatchEvent(new CustomEvent('webjs:before-cache')) right before const snap = { html: document.documentElement.outerHTML, ... }. Match the existing CustomEvent dispatch style the router uses for its other webjs:* events. Confirm snapshotCurrent's call sites run before the destination renders (so the live mutation is not user-visible).
- Kit overlays:
packages/ui/packages/registry/components/{hover-card,tooltip,dropdown-menu,context-menu,popover,sonner}.ts (audit which carry a transient reflected open / visible state). Each wires the listener in connectedCallback (call super), removes it in disconnectedCallback. The hover-card host is UiHoverCard (open prop, reflect). sonner clears its items signal. Add a shared helper (e.g. packages/ui/packages/registry/lib/before-cache.ts or a fn in lib/utils.ts) so each overlay is one call.
- Landmines: the handler mutates the LIVE DOM right before the snapshot, so ensure no visible flash (the page is navigating away). Fire it ONLY for the real back/forward cache (
snapshotCurrent), NOT for prefetch/speculative snapshots if those have a separate path. Remove listeners on disconnect to avoid leaks across soft-navs. The Popover API open state lives in the top layer; resetting the reflected open attr drives the component's updated() / _syncContent to hidePopover(). Keep SSR-safe (no document at module scope; listeners are added in connectedCallback, which is client-only).
- Invariants:
webjs:before-cache becomes PUBLIC API, so document it (agent-docs/advanced.md client-router events section + the AGENTS.md client-router mention + the docs-site client-router page). It is a DOM event listener, not a new core lifecycle hook, so likely no elision-analyser change, but verify the kit overlays still ship (they already do, being interactive).
Acceptance criteria
Problem
webjs's client router uses a Turbo-style URL-keyed snapshot cache:
snapshotCurrent()(packages/core/src/router-client.js~L766) capturesdocument.documentElement.outerHTML, and Back/Forward (popstate) restores it instantly. Because the snapshot is a rawouterHTMLclone of the live page, any OPEN transient overlay is captured open and restored open on Forward.Repro (found dogfooding on iOS, #745): open a hover-card, press Back, press Forward, the hover-card is still open. This affects the whole class of transient overlays (hover-card, tooltip, dropdown-menu + sub, context-menu, popover, sonner toasts, possibly select/combobox/dialog), not just hover-card.
Turbo solves exactly this with
turbo:before-cache. webjs has no equivalent; its router events arewebjs:navigate/webjs:prefetch/webjs:navigation-error/webjs:frame-busy/webjs:submit-start|end, with no before-cache.Design / approach
Add a
webjs:before-cacheevent the router dispatches ondocumentsynchronously insidesnapshotCurrent(), immediately BEFORE thedocument.documentElement.outerHTMLread, so a handler's DOM mutations are captured in the cached snapshot. The timing contract is the whole point: it fires before the clone, on the page being cached (not the destination), and the live-DOM mutation is invisible because the page is about to be swapped for the navigation target.Components and app code listen and strip transient state. The kit's transient overlays adopt it: in
connectedCallbackadd awebjs:before-cachelistener that resets their reflected open state (this.open = false), removed indisconnectedCallback. DRY it via a small shared helper in the registry lib (the registry is source-copied, so a helper is copied with it).Optional future sugar (NOT this PR): a
WebComponentbase-classbeforeCache()lifecycle orstatic resetOnCache = ['open']. The event stays the primitive.Implementation notes (for the implementing agent)
packages/core/src/router-client.js,snapshotCurrent(url)~L766. Dispatchdocument.dispatchEvent(new CustomEvent('webjs:before-cache'))right beforeconst snap = { html: document.documentElement.outerHTML, ... }. Match the existingCustomEventdispatch style the router uses for its otherwebjs:*events. ConfirmsnapshotCurrent's call sites run before the destination renders (so the live mutation is not user-visible).packages/ui/packages/registry/components/{hover-card,tooltip,dropdown-menu,context-menu,popover,sonner}.ts(audit which carry a transient reflectedopen/ visible state). Each wires the listener inconnectedCallback(callsuper), removes it indisconnectedCallback. The hover-card host isUiHoverCard(openprop, reflect). sonner clears itsitemssignal. Add a shared helper (e.g.packages/ui/packages/registry/lib/before-cache.tsor a fn inlib/utils.ts) so each overlay is one call.snapshotCurrent), NOT for prefetch/speculative snapshots if those have a separate path. Remove listeners on disconnect to avoid leaks across soft-navs. The Popover API open state lives in the top layer; resetting the reflectedopenattr drives the component'supdated()/_syncContenttohidePopover(). Keep SSR-safe (nodocumentat module scope; listeners are added inconnectedCallback, which is client-only).webjs:before-cachebecomes PUBLIC API, so document it (agent-docs/advanced.mdclient-router events section + the AGENTS.md client-router mention + the docs-site client-router page). It is a DOM event listener, not a new core lifecycle hook, so likely no elision-analyser change, but verify the kit overlays still ship (they already do, being interactive).Acceptance criteria
webjs:before-cacheondocumentsynchronously before theouterHTMLsnapshot read; a handler's DOM edits ARE captured in the cached snapshot.webjs:before-cacheto strip their own transient state.agent-docs/advanced.md+ docs site + AGENTS.md); no listener leaks across soft-navs.