Skip to content

feat(router): wrapNavigation + wrapUpdate hooks to animate navigations#150

Closed
sbesh91 wants to merge 2 commits into
preactjs:v3from
sbesh91:feat/v3-wrap-update
Closed

feat(router): wrapNavigation + wrapUpdate hooks to animate navigations#150
sbesh91 wants to merge 2 commits into
preactjs:v3from
sbesh91:feat/v3-wrap-update

Conversation

@sbesh91

@sbesh91 sbesh91 commented May 31, 2026

Copy link
Copy Markdown
Contributor

Problem

Animating navigations with the View Transition API needs two things preact-iso doesn't currently expose:

  1. Capture the old route before it swaps. document.startViewTransition must run before the navigation re-renders, so the browser snapshots the outgoing route (this is what makes shared-element morphs work). But link clicks / popstate / route() all go straight into the reducer and the swap happens in an async re-render — there's no point at which a consumer can wrap the navigation while the old route is still mounted.
  2. Wrap the async content commit. For a route that suspends (lazy import() / async data), the destination's content is committed via an internal post-suspense update (RESOLVED.then(update)) with no hook around it, so the transition can't be resolved when the content is ready.

Together these mean navigations can't be animated old→new: warm routes have no capture point, and cold routes also have no commit hook.

Change

Two small, optional, complementary hooks:

wrapNavigation on <LocationProvider> — wraps the commit of a navigation (the history update + the state change that drives the re-render). Split the reducer's handleNav into resolveNav (detection only, plus the synchronous preventDefault for link clicks) and a separately-invoked commit, so the commit can be deferred:

<LocationProvider
  wrapNavigation={commit =>
    document.startViewTransition(() => flushSync(commit))}
>

The browser captures the current route as the old snapshot before commit() swaps in the new one. Covers clicks, popstate, and programmatic route().

wrapUpdate on <Router> — wraps the post-suspense content commit, so a navigation to an async route can resolve its transition once the content lands:

<Router wrapUpdate={commit => /* resolve the in-flight transition */ commit()}>

Used together: wrapNavigation starts the transition and runs the route change; for a suspending route the consumer awaits wrapUpdate before completing it.

Backwards compatibility

Fully backward compatible. Both props are optional; with them omitted, resolveNav + immediate commit reproduces the previous reducer behavior exactly, and the Router's else RESOLVED.then(update) path is unchanged.

Tests

test/router.test.js, full suite green (41 router + node + lazy):

  • a suspending route's commit is routed through wrapUpdate, and content swaps in when the commit runs; omitting it still commits;
  • programmatic and link-click navigations are routed through wrapNavigation with the old route still mounted when it's called (the capture window), and preventDefault still fires; omitting it navigates normally.

Context / motivation

Surfaced building a view-transitions toolkit on top of preact-iso. With just wrapUpdate, async routes stopped throwing but still didn't morph, because the old element was already gone by the time any hook fired. wrapNavigation is the missing "navigation is starting" moment; the two hooks together make warm and cold navigations animate old→new.

Happy to adjust API/naming (prop vs. context, wrapNavigation/wrapUpdate vs. alternatives) — these are the smallest hooks that unblock animating navigations.

Navigations to suspending (async) routes commit their content via an internal
post-suspense update, which consumers currently have no way to wrap. Add an
optional `wrapUpdate` Router prop that receives the commit callback, so
consumers can wrap it (e.g. in `document.startViewTransition`) to animate
navigations to async/lazy routes.

Defaults to calling the commit directly, so existing behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`wrapUpdate` lets a consumer wrap the *content commit* of a suspending route,
but a view-transition morph also needs to capture the OLD route before it
swaps. The navigation start was unreachable: link clicks / popstate / route()
all went straight into the reducer and the swap happened in an async re-render,
so there was no point at which a consumer could call startViewTransition while
the old route was still mounted.

Split the reducer's `handleNav` into `resolveNav` (detection only, plus the
synchronous preventDefault for link clicks) and a separately-invoked commit
(history update + the state change that drives the re-render). LocationProvider
now routes that commit through a new optional `wrapNavigation` prop:

    wrapNavigation={commit =>
        document.startViewTransition(() => flushSync(commit))}

The consumer can run the commit inside a view-transition callback, so the
browser snapshots the current route as the old state before the new one swaps
in. For navigations to async routes, pair with `wrapUpdate` to resolve the
transition once the content commits. When `wrapNavigation` is omitted the commit
runs immediately — behavior is unchanged.

Adds tests covering programmatic and link-click navigations through
wrapNavigation (old route still mounted when it's called) and the omitted case.
@sbesh91 sbesh91 changed the title feat(router): add wrapUpdate to wrap the post-suspense route commit feat(router): wrapNavigation + wrapUpdate hooks to animate navigations May 31, 2026
@JoviDeCroock

Copy link
Copy Markdown
Member

This can probably be done with options.debounceRendering on the framework site. See flushSync in compat

sbesh91 added a commit to sbesh91/hono-preact that referenced this pull request May 31, 2026
Element morphs (and slides) never actually animated old->new, because the
framework started the transition from `onRouteChange` — which preact-iso fires
in a layout effect AFTER the route has already swapped into the DOM. By then the
outgoing element is gone, so `startViewTransition` only ever captured the new
page as both snapshots. Every cold-nav workaround on top of that (early-fire,
keep-name-on-detach, defer-naming, the cold-flat flag) removed the duplicate-name
error but could not produce a morph.

Rework the integration to start the transition at navigation START, using the
fork's new `wrapNavigation` hook (preactjs/preact-iso#150). LocationProvider now
hands each navigation's commit to the coordinator, which:

  startViewTransition(async () => {
    flushSync(commit);              // run the route change INSIDE the callback
    ...fire phases / apply nav types...
    if (loadingDepth > 0) await <content commit via wrapUpdate>; // cold: shell
  })

so the browser captures the current route as the old snapshot before the new one
swaps in. Warm navigations swap synchronously; cold (suspending) navigations wait
for the route's content via the Router's `wrapUpdate`, capped by a 500ms timeout
and abandoned cleanly if a newer navigation supersedes them.

Because old and new are now captured at the right moments, the source and
destination never coexist with the same view-transition-name — so all the
workaround machinery is deleted:
- route-change.ts: `__dispatchRouteChange`-driven cold/warm coordinator,
  early-fire, deferred-names, cold-flat flag -> a single `__wrapNavigation`
  entry plus a simplified `__wrapRouteCommit` cold bridge. (`__dispatchRouteChange`
  is kept only as a synchronous dispatch primitive for the phase/type/lifecycle
  unit tests.)
- view-transition-name.ts: back to plain apply-on-attach / clear-on-swap; no
  coordinator coupling.
- client-entry.ts: wire `wrapNavigation={__wrapNavigation}` on LocationProvider;
  drop the `onRouteChange` + `lastPath` plumbing (the coordinator tracks `from`).
- define-routes.tsx: `Routes` no longer takes/forwards `onRouteChange`.

Net change is a reduction. This makes morphs and directional slides animate on
ALL navigations (warm and cold, including back/forward), not just removing the
error. Bumps the preact-iso fork pin to the commit that adds `wrapNavigation`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sbesh91 sbesh91 closed this May 31, 2026
sbesh91 added a commit to sbesh91/hono-preact that referenced this pull request May 31, 2026
… the fork)

Replaces the preact-iso `wrapNavigation`/`wrapUpdate` fork hooks with a
framework-side render scheduler, so view transitions work on stock
`preactjs/preact-iso#v3` — no fork dependency. (Per Jovi De Croock's note on
preactjs/preact-iso#150 that this can be done with `options.debounceRendering`,
the way `flushSync` in compat does.)

`installNavTransitionScheduler()` (run once from the client entry) overrides
`options.debounceRendering` — the seam Preact uses to schedule a render flush.
When a flush is the result of a navigation (the URL changed since the last
flush, because the router pushes state before re-rendering), it wraps that flush
in `document.startViewTransition`, so the browser captures the current route as
the old snapshot before `process()` swaps in the new one. Everything else
schedules normally. This covers clicks, `route()`, and popstate uniformly,
with no event interception and no per-navigation wiring.

Cold (suspending) routes: the transition keeps routing the route's content
flushes into itself until every route module has loaded (`loadingDepth` back to
0 via the stock `onLoadStart`/`onLoadEnd` props) — the page-level shell. If the
outgoing route had named elements but no morph partner is in the shell yet (a
list whose items load with the route's DATA, behind inner Suspense that doesn't
move `loadingDepth`), it waits a short bounded grace for the partner to appear
so the morph can pair. Superseded/stalled navigations are abandoned and capped
by a timeout.

- route-change.ts: remove `__wrapNavigation`/`__wrapRouteCommit` and the
  flushSync/wrapUpdate cold bridge; add the scheduler. `__dispatchRouteChange`
  stays as the synchronous phase/type/lifecycle dispatch primitive (tests).
- client-entry.ts: `installNavTransitionScheduler()`; LocationProvider/Routes
  carry no transition props anymore.
- define-routes.tsx: Routers keep only `onLoadStart`/`onLoadEnd`.
- deps: point preact-iso back at `github:preactjs/preact-iso#v3`.

Verified in-browser: into-a-project morph, the reverse "← all projects" morph
(data-loaded partner), warm revisits, directional slides, and back/forward all
animate, with no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sbesh91 added a commit to sbesh91/hono-preact that referenced this pull request May 31, 2026
…orphs (stock preact-iso) (#65)

* docs: spec for defer-aware view-transition coordinator

* docs: implementation plan for defer-aware view-transition coordinator

* docs(spec): record Phase 0 instrumentation findings

* feat(iso): defer-aware view-transition coordinator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(iso): drive Router transitions through the coordinator (load hooks + wrapUpdate)

Wire every Router (the top Router in `Routes` and the nested layout Router)
with `onLoadStart`/`onLoadEnd` (feeding the coordinator's loadingDepth) and
`wrapUpdate` (the post-suspense commit hook), and drop the old per-call
`wrapRouteUpdate`/`flushSync`/`__getLastNavTypes` path from `define-routes`.
The coordinator from the previous commit now owns all transition sequencing:
warm navs transition at `onRouteChange`, cold/suspending navs defer to the
first `wrapUpdate`, nested commits fill in directly.

This relies on the real-browser ordering where onRouteChange arrives while
the route is still loading and wrapUpdate commits the content afterward
(confirmed by in-browser instrumentation), so the coordinator defers a cold
navigation to its wrapUpdate commit. A navigation whose content commits before
dispatch (only reproducible with synchronous Promise.resolve test routes) has
no swap left to animate and is intentionally left un-transitioned.

The end-to-end integration test now exercises a warm navigation (revisit an
already-loaded route), which transitions deterministically; the cold-defer
sequencing is covered by route-change-coordinator.test.ts and the browser
verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(vite): clarify client-entry lastPath seed comment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(iso): format route-change-coordinator test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(iso): animate element morphs across cold navigations

Cold navigations into a route that shares a `view-transition-name` with the
originating page (a list-item -> detail-header morph) could not animate: the
old coordinator deferred the whole transition to the post-suspense commit, by
which point preact-iso had already painted the destination and dropped the
source, so the morph had no source and the two names transiently collided
("InvalidStateError: Duplicate view-transition-name").

Capture the old snapshot at dispatch instead, while the source route is still
mounted, and bridge the swap to the route's content commit:

- route-change.ts: a cold dispatch starts the transition immediately (old
  snapshot = the source route) and an async updateCallback awaits the route's
  content commit (`__wrapRouteCommit`) before swapping. A generation counter, a
  resume-on-next-navigation guard, and a timeout keep an abandoned or stalled
  cold navigation from leaving the page frozen.
- view-transition-name.ts: keep the name on an element that detaches (so the
  outgoing source survives as `prev` for the morph; preact removes the node and
  its name when it drops prev), and defer naming elements that mount while a
  cold transition is active so the destination is named only in the new
  snapshot.

Trade-off: a cold navigation holds the old snapshot until its shell commits
(the lazy module load, brief and first-visit-only), with a timeout backstop.

Warm and cold-flat navigations are unchanged. The coordinator unit tests are
updated for the dispatch-time start / commit-driven swap, plus a new case for a
cold navigation abandoned by a follow-up navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(spec): note the cold-path revision for element morphs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): handle cold-flat nav ordering; harden cold-transition supersede

Addresses deep-PR-review findings.

P1 — a cold "flat" navigation (the route's own top-level Router suspends, e.g.
/ -> /docs) has the opposite ordering from a cold-nested one: `wrapUpdate`
commits the content BEFORE `onRouteChange` fires (the suspended render's commit
effect early-returns on `didSuspend`). The dispatch then started a bridged
transition awaiting a commit that had already happened, freezing the page on the
old snapshot until the 2s timeout. Now `__wrapRouteCommit` flags a page-level
commit that lands during a load with no transition wrapping it, and the dispatch
reads that flag to run a synchronous transition instead (the directional root
slide still plays). The flag is cleared when the load settles so the initial
hydration load can't mislabel the next navigation.

Also hardens the cold supersede paths:
- bump `coldGen` when a navigation abandons an in-flight cold transition (so its
  resumed async callback bows out instead of re-firing lifecycle phases for an
  aborted transition — covers warm-supersedes-cold, not just cold-supersedes-cold);
- gate the cold timeout on the generation so a stale timer can't resume a newer
  transition's bridge early;
- in `useViewTransitionName`, keep the name on a detaching element only while a
  cold transition is actually active, so a name can't linger on a retained
  `prev` node outside a transition.

Adds coordinator tests for the cold-flat ordering and the initial-load /
cold-nested-nav disambiguation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): cap the cold-transition freeze at 500ms

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): keep the source name on detach so cold morphs actually animate

The previous commit gated keep-on-detach on `coldActive`, but the source
element (e.g. a list link) detaches the instant the link is clicked — before
any navigation hook fires, so `coldActive` (and `loadingDepth`) are both still
false at that point. The name was therefore stripped before the cold transition
could capture it, leaving the source as part of the root slide instead of its
own morph group: the page slid but nothing morphed.

Keep the name unconditionally on detach again. preact-iso retains the outgoing
route's DOM as `prev` during the suspense, so the named source survives into the
old snapshot; the destination still defers naming during the cold transition, so
the two never collide. preact removes the node (and its inline name) when it
drops prev. Trade-off: a name can briefly linger on a retained prev node (the
narrow case the reviewer flagged); accepted, since gating it breaks the morph
outright and the destination deferral prevents the collision that matters.

Note: this only animates a morph on a COLD navigation (the destination route
suspends, so preact-iso keeps the source as prev). A warm revisit has no
suspense, so the source isn't retained and only the root slide plays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(iso): drive view transitions from navigation start (real morphs)

Element morphs (and slides) never actually animated old->new, because the
framework started the transition from `onRouteChange` — which preact-iso fires
in a layout effect AFTER the route has already swapped into the DOM. By then the
outgoing element is gone, so `startViewTransition` only ever captured the new
page as both snapshots. Every cold-nav workaround on top of that (early-fire,
keep-name-on-detach, defer-naming, the cold-flat flag) removed the duplicate-name
error but could not produce a morph.

Rework the integration to start the transition at navigation START, using the
fork's new `wrapNavigation` hook (preactjs/preact-iso#150). LocationProvider now
hands each navigation's commit to the coordinator, which:

  startViewTransition(async () => {
    flushSync(commit);              // run the route change INSIDE the callback
    ...fire phases / apply nav types...
    if (loadingDepth > 0) await <content commit via wrapUpdate>; // cold: shell
  })

so the browser captures the current route as the old snapshot before the new one
swaps in. Warm navigations swap synchronously; cold (suspending) navigations wait
for the route's content via the Router's `wrapUpdate`, capped by a 500ms timeout
and abandoned cleanly if a newer navigation supersedes them.

Because old and new are now captured at the right moments, the source and
destination never coexist with the same view-transition-name — so all the
workaround machinery is deleted:
- route-change.ts: `__dispatchRouteChange`-driven cold/warm coordinator,
  early-fire, deferred-names, cold-flat flag -> a single `__wrapNavigation`
  entry plus a simplified `__wrapRouteCommit` cold bridge. (`__dispatchRouteChange`
  is kept only as a synchronous dispatch primitive for the phase/type/lifecycle
  unit tests.)
- view-transition-name.ts: back to plain apply-on-attach / clear-on-swap; no
  coordinator coupling.
- client-entry.ts: wire `wrapNavigation={__wrapNavigation}` on LocationProvider;
  drop the `onRouteChange` + `lastPath` plumbing (the coordinator tracks `from`).
- define-routes.tsx: `Routes` no longer takes/forwards `onRouteChange`.

Net change is a reduction. This makes morphs and directional slides animate on
ALL navigations (warm and cold, including back/forward), not just removing the
error. Bumps the preact-iso fork pin to the commit that adds `wrapNavigation`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(iso): drive view transitions via options.debounceRendering (drop the fork)

Replaces the preact-iso `wrapNavigation`/`wrapUpdate` fork hooks with a
framework-side render scheduler, so view transitions work on stock
`preactjs/preact-iso#v3` — no fork dependency. (Per Jovi De Croock's note on
preactjs/preact-iso#150 that this can be done with `options.debounceRendering`,
the way `flushSync` in compat does.)

`installNavTransitionScheduler()` (run once from the client entry) overrides
`options.debounceRendering` — the seam Preact uses to schedule a render flush.
When a flush is the result of a navigation (the URL changed since the last
flush, because the router pushes state before re-rendering), it wraps that flush
in `document.startViewTransition`, so the browser captures the current route as
the old snapshot before `process()` swaps in the new one. Everything else
schedules normally. This covers clicks, `route()`, and popstate uniformly,
with no event interception and no per-navigation wiring.

Cold (suspending) routes: the transition keeps routing the route's content
flushes into itself until every route module has loaded (`loadingDepth` back to
0 via the stock `onLoadStart`/`onLoadEnd` props) — the page-level shell. If the
outgoing route had named elements but no morph partner is in the shell yet (a
list whose items load with the route's DATA, behind inner Suspense that doesn't
move `loadingDepth`), it waits a short bounded grace for the partner to appear
so the morph can pair. Superseded/stalled navigations are abandoned and capped
by a timeout.

- route-change.ts: remove `__wrapNavigation`/`__wrapRouteCommit` and the
  flushSync/wrapUpdate cold bridge; add the scheduler. `__dispatchRouteChange`
  stays as the synchronous phase/type/lifecycle dispatch primitive (tests).
- client-entry.ts: `installNavTransitionScheduler()`; LocationProvider/Routes
  carry no transition props anymore.
- define-routes.tsx: Routers keep only `onLoadStart`/`onLoadEnd`.
- deps: point preact-iso back at `github:preactjs/preact-iso#v3`.

Verified in-browser: into-a-project morph, the reverse "← all projects" morph
(data-loaded partner), warm revisits, directional slides, and back/forward all
animate, with no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(spec): note the final options.debounceRendering implementation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): reset loadingDepth at navigation start (B1 from PR review)

preact-iso fires onLoadStart without a matching onLoadEnd when a still-suspended
Router unmounts (onLoadEnd runs only on a committed render, not on unmount), so
navigating away from a route mid-load leaks loadingDepth. The leaked depth
persisted across navigations and made later navs look perpetually cold, burning
the 500ms cold-load timeout each time. Reset loadingDepth to 0 when a navigation
is detected (the previous route's loads are abandoned by definition); the new
nav re-increments it as its own route suspends.

Adds a test that fails without the reset (the next nav hangs on the cold loop
until the timeout).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): drive view-transition supersede from navigation, not the render seam (B2)

A second navigation that arrives in the brief window before a cold transition's
callback runs `process()` does not re-enter `scheduleRender`: Preact's render
batching coalesces its render into the in-flight flush, so the render-seam
supersede branch never fires and the transition keeps the first navigation's
types. (Narrow — during the actual slow load, after `process()` resets Preact's
batch counter, supersede already works via the render seam.)

Add an `onNavigation` notifier to the history shim (fired on pushState/
replaceState/popstate, before the re-render) and have the scheduler observe it: a
navigation observed while a transition is in flight abandons it (`navGen++`,
resolve the cold wait), so supersede is navigation-driven rather than dependent
on render-seam timing. The render-seam supersede branch stays as a fallback. A
`transitionActive` flag tracks the in-flight window for the observer.

No automated test: the unit harness calls `scheduleRender` directly, so it can't
reproduce the Preact render batching that creates the gap, and the render-seam
fallback masks the observer either way. Verified by reasoning through the
in-flight/supersede lifecycle and no regression (873 unit + 4 integration green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(iso): cover morph-grace + cap-expiry paths; scope VT scans; doc scheduler ownership

Addresses the deep-review should-fixes and nits on PR #65.

- Morph-partner grace had no automated coverage (the headline feature was only
  manually verified). Add tests driving a cold navigation through the grace
  window: one where the morph partner loads with the route data and the swap
  holds for it, one where no partner appears and the swap commits after the
  150ms cap. Also add a test for the 500ms cold-commit cap (route suspends
  mid-nav and never resolves -> the transition gives up instead of freezing).
  Both cap-expiry tests run on fake timers so a wall-clock wait can't starve the
  parallel pool. A new installFakeVtOnDoc helper augments the real happy-dom
  document (the old stub lacked querySelectorAll, which is why the grace path
  was untestable).

- Scope the view-transition-name DOM scans: collectVtNames/hasMorphPartner now
  share queryVtNamedElements, which queries `[style*="view-transition-name"]`
  (native filtering) instead of walking every element in JS on the frozen hot
  path.

- Document that installNavTransitionScheduler takes ownership of
  options.debounceRendering (prevDebounce delegated to for non-nav flushes; the
  flushSync temporary-swap composes safely).

- Fix a stale comment in the integration test that still described the removed
  wrapNavigation hook rather than the debounceRendering scheduler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hono-preact): raise exports.test timeout to fix cold-import flakiness

Every test in exports.test.ts does `await import('hono-preact'...)`, which the
vitest alias resolves to the package's source entry. The first import of each
entry triggers a cold Vite transform of a large graph (iso + server + the Vite
plugin), and under a saturated parallel pool that exceeds vitest's default
5000ms per-test timeout — surfacing as flaky `Test timed out in 5000ms` on the
heaviest graphs (the root runtime and the Vite plugin). Confirmed failing 3/3 on
a clean tree under load.

These are import-surface assertions, not timing tests, so give the file generous
headroom via vi.setConfig({ testTimeout: 30000 }). Full suite then passes across
repeated runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
sbesh91 added a commit to sbesh91/hono-preact that referenced this pull request May 31, 2026
…ute navigations (#64)

* docs: demo view transitions design spec

* docs: demo view transitions implementation plan

* feat(demo): directional view-transition slides + card-morph CSS

* feat(demo): name issue row title + badge for shared-element morph

* feat(demo): match issue header title + badge names to list row

* feat(demo): morph project name into project layout header

* fix(iso): source Form action identity from props, not hydrated DOM

A <Form> on the initial SSR page rendered __module/__action as empty hidden
inputs (server-side defineAction carries no name metadata), and Preact's
hydrate() does not patch existing DOM values. The submit handler read those
stale empties via new FormData(formEl), posting __action='' and 404ing with
"Action '' not found". Set both fields from the component's props before
sending. Forms reached via SPA navigation were unaffected (client-rendered).

* fix(demo): set client-guard flag at login to avoid post-login bounce

The client requireSession guard reads localStorage['demo:authed'] and
redirects to /demo/login when absent. Nothing set it at login time (only a
projects.tsx useEffect, which loses the race against the guard during the
post-login page's hydration), so an authed user landing on /demo/projects
got bounced to /demo/login and both routes ended up rendered. Set the flag as
the sign-in submits; a stale flag from a failed sign-in is rejected by the
server guard on the next request.

* fix(demo): drive comment optimistic list from the submitted action

The comment <Form> was given the bare serverActions.addComment stub, so it
never called addOptimistic on submit; the new comment only appeared after a
full reload. Pass the useOptimisticAction result to <Form> so the optimistic
append fires and the comment paints immediately, persisting until the loader
refetches the server-confirmed entry on the next mount/nav.

* docs: track client-redirect double-mount framework bug

* fix(demo): correct directional view-transition selectors

The type-scoped slide rules used a descendant combinator
(`:active-view-transition-type(nav-push) ::view-transition-old(root)`). The
::view-transition-* pseudo-elements are pseudo-elements OF the root, not
descendant elements, so that selector never matched and every navigation fell
back to the base root fade. Chain the type pseudo-class and the VT
pseudo-element directly on `html` per spec
(`html:active-view-transition-type(nav-push)::view-transition-old(root)`).

* feat(demo): reverse slide for in-app up-navigation

The project layout's in-app up-links (e.g. '← all projects') are pushState
navigations, so the history shim classifies them as forward nav-push. Emit a
nav-up type via useViewTransitionTypes when the destination is an ancestor of
the current path, and add nav-up reverse-slide CSS (placed after nav-push so it
wins the cascade when both types are active). Up-navigation now plays the
left-to-right back slide.

* fix(demo): host nav-up hook on a persistent /demo layout

The nav-up type hook was on project-layout, which unmounts when navigating to
/demo/projects, so it missed the route-change dispatch (which fires after the
new route commits). Move it to a thin /demo layout that stays mounted across
every demo navigation, so the up-link reverse slide actually fires.

* fix(vite): seed client-entry lastPath with initial pathname

preact-iso doesn't fire onRouteChange on the initial hydration mount, so the
first client navigation reported `from === undefined`. Any direction logic
keyed on `event.from` (e.g. the demo's up-navigation reverse slide) was
therefore skipped on the first nav after a page load and only worked
afterward. Seed lastPath from location.pathname at entry so the first nav has
a defined origin.

* docs: root-cause the first-nav no-op view transition

* docs: record rAF-block finding ruling out in-callback first-nav VT fix

* docs: upstream proposal for preact-iso view-transition support

* docs: add validated PoC results to preact-iso VT proposal

* build(deps): point preact-iso at the fork with the wrapUpdate hook

Switch the preact-iso dependency (and the pnpm override) from
preactjs/preact-iso#v3 to sbesh91/preact-iso#feat/v3-wrap-update, which adds the
optional Router `wrapUpdate` prop (preactjs/preact-iso#150) that the
suspending-route view-transition support relies on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(iso): animate navigations to suspending routes via wrapUpdate

A route whose loader or lazy view suspends commits its content through
preact-iso's internal post-suspense update, which the onRouteChange-anchored
view transition can't wrap (the swap lands after that transition's no-op
callback), so cold navigations snapped instead of animating.

Pass preact-iso's new `wrapUpdate` prop to every Router so the deferred content
commit runs inside a fresh view transition (flushed synchronously so the
old-route -> new-content swap is captured), re-applying the navigation's types
so a cold nav uses the same directional slide as a warm one. The initial route
load (no navigation dispatched yet) is excluded so first paint never animates.

Map nav-initial to a forward slide so the first navigation after a load also
animates (nav-up still overrides it for up-links).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(iso): view transitions via options.debounceRendering + element morphs (stock preact-iso) (#65)

* docs: spec for defer-aware view-transition coordinator

* docs: implementation plan for defer-aware view-transition coordinator

* docs(spec): record Phase 0 instrumentation findings

* feat(iso): defer-aware view-transition coordinator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(iso): drive Router transitions through the coordinator (load hooks + wrapUpdate)

Wire every Router (the top Router in `Routes` and the nested layout Router)
with `onLoadStart`/`onLoadEnd` (feeding the coordinator's loadingDepth) and
`wrapUpdate` (the post-suspense commit hook), and drop the old per-call
`wrapRouteUpdate`/`flushSync`/`__getLastNavTypes` path from `define-routes`.
The coordinator from the previous commit now owns all transition sequencing:
warm navs transition at `onRouteChange`, cold/suspending navs defer to the
first `wrapUpdate`, nested commits fill in directly.

This relies on the real-browser ordering where onRouteChange arrives while
the route is still loading and wrapUpdate commits the content afterward
(confirmed by in-browser instrumentation), so the coordinator defers a cold
navigation to its wrapUpdate commit. A navigation whose content commits before
dispatch (only reproducible with synchronous Promise.resolve test routes) has
no swap left to animate and is intentionally left un-transitioned.

The end-to-end integration test now exercises a warm navigation (revisit an
already-loaded route), which transitions deterministically; the cold-defer
sequencing is covered by route-change-coordinator.test.ts and the browser
verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(vite): clarify client-entry lastPath seed comment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(iso): format route-change-coordinator test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(iso): animate element morphs across cold navigations

Cold navigations into a route that shares a `view-transition-name` with the
originating page (a list-item -> detail-header morph) could not animate: the
old coordinator deferred the whole transition to the post-suspense commit, by
which point preact-iso had already painted the destination and dropped the
source, so the morph had no source and the two names transiently collided
("InvalidStateError: Duplicate view-transition-name").

Capture the old snapshot at dispatch instead, while the source route is still
mounted, and bridge the swap to the route's content commit:

- route-change.ts: a cold dispatch starts the transition immediately (old
  snapshot = the source route) and an async updateCallback awaits the route's
  content commit (`__wrapRouteCommit`) before swapping. A generation counter, a
  resume-on-next-navigation guard, and a timeout keep an abandoned or stalled
  cold navigation from leaving the page frozen.
- view-transition-name.ts: keep the name on an element that detaches (so the
  outgoing source survives as `prev` for the morph; preact removes the node and
  its name when it drops prev), and defer naming elements that mount while a
  cold transition is active so the destination is named only in the new
  snapshot.

Trade-off: a cold navigation holds the old snapshot until its shell commits
(the lazy module load, brief and first-visit-only), with a timeout backstop.

Warm and cold-flat navigations are unchanged. The coordinator unit tests are
updated for the dispatch-time start / commit-driven swap, plus a new case for a
cold navigation abandoned by a follow-up navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(spec): note the cold-path revision for element morphs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): handle cold-flat nav ordering; harden cold-transition supersede

Addresses deep-PR-review findings.

P1 — a cold "flat" navigation (the route's own top-level Router suspends, e.g.
/ -> /docs) has the opposite ordering from a cold-nested one: `wrapUpdate`
commits the content BEFORE `onRouteChange` fires (the suspended render's commit
effect early-returns on `didSuspend`). The dispatch then started a bridged
transition awaiting a commit that had already happened, freezing the page on the
old snapshot until the 2s timeout. Now `__wrapRouteCommit` flags a page-level
commit that lands during a load with no transition wrapping it, and the dispatch
reads that flag to run a synchronous transition instead (the directional root
slide still plays). The flag is cleared when the load settles so the initial
hydration load can't mislabel the next navigation.

Also hardens the cold supersede paths:
- bump `coldGen` when a navigation abandons an in-flight cold transition (so its
  resumed async callback bows out instead of re-firing lifecycle phases for an
  aborted transition — covers warm-supersedes-cold, not just cold-supersedes-cold);
- gate the cold timeout on the generation so a stale timer can't resume a newer
  transition's bridge early;
- in `useViewTransitionName`, keep the name on a detaching element only while a
  cold transition is actually active, so a name can't linger on a retained
  `prev` node outside a transition.

Adds coordinator tests for the cold-flat ordering and the initial-load /
cold-nested-nav disambiguation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): cap the cold-transition freeze at 500ms

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): keep the source name on detach so cold morphs actually animate

The previous commit gated keep-on-detach on `coldActive`, but the source
element (e.g. a list link) detaches the instant the link is clicked — before
any navigation hook fires, so `coldActive` (and `loadingDepth`) are both still
false at that point. The name was therefore stripped before the cold transition
could capture it, leaving the source as part of the root slide instead of its
own morph group: the page slid but nothing morphed.

Keep the name unconditionally on detach again. preact-iso retains the outgoing
route's DOM as `prev` during the suspense, so the named source survives into the
old snapshot; the destination still defers naming during the cold transition, so
the two never collide. preact removes the node (and its inline name) when it
drops prev. Trade-off: a name can briefly linger on a retained prev node (the
narrow case the reviewer flagged); accepted, since gating it breaks the morph
outright and the destination deferral prevents the collision that matters.

Note: this only animates a morph on a COLD navigation (the destination route
suspends, so preact-iso keeps the source as prev). A warm revisit has no
suspense, so the source isn't retained and only the root slide plays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(iso): drive view transitions from navigation start (real morphs)

Element morphs (and slides) never actually animated old->new, because the
framework started the transition from `onRouteChange` — which preact-iso fires
in a layout effect AFTER the route has already swapped into the DOM. By then the
outgoing element is gone, so `startViewTransition` only ever captured the new
page as both snapshots. Every cold-nav workaround on top of that (early-fire,
keep-name-on-detach, defer-naming, the cold-flat flag) removed the duplicate-name
error but could not produce a morph.

Rework the integration to start the transition at navigation START, using the
fork's new `wrapNavigation` hook (preactjs/preact-iso#150). LocationProvider now
hands each navigation's commit to the coordinator, which:

  startViewTransition(async () => {
    flushSync(commit);              // run the route change INSIDE the callback
    ...fire phases / apply nav types...
    if (loadingDepth > 0) await <content commit via wrapUpdate>; // cold: shell
  })

so the browser captures the current route as the old snapshot before the new one
swaps in. Warm navigations swap synchronously; cold (suspending) navigations wait
for the route's content via the Router's `wrapUpdate`, capped by a 500ms timeout
and abandoned cleanly if a newer navigation supersedes them.

Because old and new are now captured at the right moments, the source and
destination never coexist with the same view-transition-name — so all the
workaround machinery is deleted:
- route-change.ts: `__dispatchRouteChange`-driven cold/warm coordinator,
  early-fire, deferred-names, cold-flat flag -> a single `__wrapNavigation`
  entry plus a simplified `__wrapRouteCommit` cold bridge. (`__dispatchRouteChange`
  is kept only as a synchronous dispatch primitive for the phase/type/lifecycle
  unit tests.)
- view-transition-name.ts: back to plain apply-on-attach / clear-on-swap; no
  coordinator coupling.
- client-entry.ts: wire `wrapNavigation={__wrapNavigation}` on LocationProvider;
  drop the `onRouteChange` + `lastPath` plumbing (the coordinator tracks `from`).
- define-routes.tsx: `Routes` no longer takes/forwards `onRouteChange`.

Net change is a reduction. This makes morphs and directional slides animate on
ALL navigations (warm and cold, including back/forward), not just removing the
error. Bumps the preact-iso fork pin to the commit that adds `wrapNavigation`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(iso): drive view transitions via options.debounceRendering (drop the fork)

Replaces the preact-iso `wrapNavigation`/`wrapUpdate` fork hooks with a
framework-side render scheduler, so view transitions work on stock
`preactjs/preact-iso#v3` — no fork dependency. (Per Jovi De Croock's note on
preactjs/preact-iso#150 that this can be done with `options.debounceRendering`,
the way `flushSync` in compat does.)

`installNavTransitionScheduler()` (run once from the client entry) overrides
`options.debounceRendering` — the seam Preact uses to schedule a render flush.
When a flush is the result of a navigation (the URL changed since the last
flush, because the router pushes state before re-rendering), it wraps that flush
in `document.startViewTransition`, so the browser captures the current route as
the old snapshot before `process()` swaps in the new one. Everything else
schedules normally. This covers clicks, `route()`, and popstate uniformly,
with no event interception and no per-navigation wiring.

Cold (suspending) routes: the transition keeps routing the route's content
flushes into itself until every route module has loaded (`loadingDepth` back to
0 via the stock `onLoadStart`/`onLoadEnd` props) — the page-level shell. If the
outgoing route had named elements but no morph partner is in the shell yet (a
list whose items load with the route's DATA, behind inner Suspense that doesn't
move `loadingDepth`), it waits a short bounded grace for the partner to appear
so the morph can pair. Superseded/stalled navigations are abandoned and capped
by a timeout.

- route-change.ts: remove `__wrapNavigation`/`__wrapRouteCommit` and the
  flushSync/wrapUpdate cold bridge; add the scheduler. `__dispatchRouteChange`
  stays as the synchronous phase/type/lifecycle dispatch primitive (tests).
- client-entry.ts: `installNavTransitionScheduler()`; LocationProvider/Routes
  carry no transition props anymore.
- define-routes.tsx: Routers keep only `onLoadStart`/`onLoadEnd`.
- deps: point preact-iso back at `github:preactjs/preact-iso#v3`.

Verified in-browser: into-a-project morph, the reverse "← all projects" morph
(data-loaded partner), warm revisits, directional slides, and back/forward all
animate, with no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(spec): note the final options.debounceRendering implementation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): reset loadingDepth at navigation start (B1 from PR review)

preact-iso fires onLoadStart without a matching onLoadEnd when a still-suspended
Router unmounts (onLoadEnd runs only on a committed render, not on unmount), so
navigating away from a route mid-load leaks loadingDepth. The leaked depth
persisted across navigations and made later navs look perpetually cold, burning
the 500ms cold-load timeout each time. Reset loadingDepth to 0 when a navigation
is detected (the previous route's loads are abandoned by definition); the new
nav re-increments it as its own route suspends.

Adds a test that fails without the reset (the next nav hangs on the cold loop
until the timeout).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(iso): drive view-transition supersede from navigation, not the render seam (B2)

A second navigation that arrives in the brief window before a cold transition's
callback runs `process()` does not re-enter `scheduleRender`: Preact's render
batching coalesces its render into the in-flight flush, so the render-seam
supersede branch never fires and the transition keeps the first navigation's
types. (Narrow — during the actual slow load, after `process()` resets Preact's
batch counter, supersede already works via the render seam.)

Add an `onNavigation` notifier to the history shim (fired on pushState/
replaceState/popstate, before the re-render) and have the scheduler observe it: a
navigation observed while a transition is in flight abandons it (`navGen++`,
resolve the cold wait), so supersede is navigation-driven rather than dependent
on render-seam timing. The render-seam supersede branch stays as a fallback. A
`transitionActive` flag tracks the in-flight window for the observer.

No automated test: the unit harness calls `scheduleRender` directly, so it can't
reproduce the Preact render batching that creates the gap, and the render-seam
fallback masks the observer either way. Verified by reasoning through the
in-flight/supersede lifecycle and no regression (873 unit + 4 integration green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(iso): cover morph-grace + cap-expiry paths; scope VT scans; doc scheduler ownership

Addresses the deep-review should-fixes and nits on PR #65.

- Morph-partner grace had no automated coverage (the headline feature was only
  manually verified). Add tests driving a cold navigation through the grace
  window: one where the morph partner loads with the route data and the swap
  holds for it, one where no partner appears and the swap commits after the
  150ms cap. Also add a test for the 500ms cold-commit cap (route suspends
  mid-nav and never resolves -> the transition gives up instead of freezing).
  Both cap-expiry tests run on fake timers so a wall-clock wait can't starve the
  parallel pool. A new installFakeVtOnDoc helper augments the real happy-dom
  document (the old stub lacked querySelectorAll, which is why the grace path
  was untestable).

- Scope the view-transition-name DOM scans: collectVtNames/hasMorphPartner now
  share queryVtNamedElements, which queries `[style*="view-transition-name"]`
  (native filtering) instead of walking every element in JS on the frozen hot
  path.

- Document that installNavTransitionScheduler takes ownership of
  options.debounceRendering (prevDebounce delegated to for non-nav flushes; the
  flushSync temporary-swap composes safely).

- Fix a stale comment in the integration test that still described the removed
  wrapNavigation hook rather than the debounceRendering scheduler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hono-preact): raise exports.test timeout to fix cold-import flakiness

Every test in exports.test.ts does `await import('hono-preact'...)`, which the
vitest alias resolves to the package's source entry. The first import of each
entry triggers a cold Vite transform of a large graph (iso + server + the Vite
plugin), and under a saturated parallel pool that exceeds vitest's default
5000ms per-test timeout — surfacing as flaky `Test timed out in 5000ms` on the
heaviest graphs (the root runtime and the Vite plugin). Confirmed failing 3/3 on
a clean tree under load.

These are import-surface assertions, not timing tests, so give the file generous
headroom via vi.setConfig({ testTimeout: 30000 }). Full suite then passes across
repeated runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: reframe client-redirect double-mount research to the resolution

Root cause revised from preact-iso Router retention to expected Preact
Suspense+hydration behavior (preactjs/preact#4442, confirmed with maintainers).
Fix shipped consumer-side via the deferred post-hydration approach in
PageMiddlewareHost (PR #63), superseding the hard-navigation idea.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(site): calm fade+zoom view transition for docs pages with a persistent sidebar

The global directional left/right page slide felt wrong for docs, which are
navigated by a sidebar rather than a forward/back narrative. Give docs
navigations a calmer treatment: the content cross-fades and the new page zooms
in slightly (from 98%), with no horizontal motion. The docs sidebar is a
persistent UI element, lifted out of the transition so it neither fades nor
zooms with the content.

- A single always-on subscriber (apps/site/src/docs-transition.ts, side-effect
  imported by routes.ts) emits the `docs` type whenever a navigation's `from` or
  `to` is under /docs, so entering, leaving, and moving within docs all get the
  fade+zoom. It also emits `docs-within` only when both sides are docs.
- root.css keys the fade+zoom off `:active-view-transition-type(docs)`, placed
  after the directional-slide rules so it wins the specificity tie. The sidebar
  gets its own `view-transition-name: docs-sidebar` to lift it out of the root
  snapshot; it is frozen (`animation: none`) only under `docs-within`. On enter/
  leave it is captured on one side only and keeps the default fade, so the old
  sidebar does not freeze on screen for the transition.

A layout hook can't drive this: useViewTransitionTypes in DocsLayout only catches
docs->docs (it isn't subscribed yet on enter and is torn down on leave), and
there is no site component mounted across all top-level routes. The subscriber
uses the `__subscribePhase` escape hatch from hono-preact/internal; a public
`subscribeViewTransitionTypes(fn)` is the cleaner long-term home (noted in code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (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.

2 participants