Skip to content

feat: webjs:before-cache hook so transient overlays reset on back/forward #766

Description

@vivek7405

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

  • The router dispatches webjs:before-cache on document synchronously before the outerHTML snapshot read; a handler's DOM edits ARE captured in the cached snapshot.
  • Open a hover-card (and tooltip/dropdown), Back, Forward to the overlay is CLOSED. Counterfactual (remove the listener or the dispatch) keeps it open.
  • App authors can listen to webjs:before-cache to strip their own transient state.
  • Tests at every layer touched (core for the dispatch/timing + counterfactual; kit for the overlay reset; a browser/e2e back to forward case).
  • Docs updated (agent-docs/advanced.md + docs site + AGENTS.md); no listener leaks across soft-navs.
  • Single PR.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions