feat(router): wrapNavigation + wrapUpdate hooks to animate navigations#150
Closed
sbesh91 wants to merge 2 commits into
Closed
feat(router): wrapNavigation + wrapUpdate hooks to animate navigations#150sbesh91 wants to merge 2 commits into
sbesh91 wants to merge 2 commits into
Conversation
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.
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Animating navigations with the View Transition API needs two things preact-iso doesn't currently expose:
document.startViewTransitionmust 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.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:
wrapNavigationon<LocationProvider>— wraps the commit of a navigation (the history update + the state change that drives the re-render). Split the reducer'shandleNavintoresolveNav(detection only, plus the synchronouspreventDefaultfor link clicks) and a separately-invoked commit, so the commit can be deferred:The browser captures the current route as the old snapshot before
commit()swaps in the new one. Covers clicks,popstate, and programmaticroute().wrapUpdateon<Router>— wraps the post-suspense content commit, so a navigation to an async route can resolve its transition once the content lands:Used together:
wrapNavigationstarts the transition and runs the route change; for a suspending route the consumer awaitswrapUpdatebefore 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'selse RESOLVED.then(update)path is unchanged.Tests
test/router.test.js, full suite green (41 router + node + lazy):wrapUpdate, and content swaps in when the commit runs; omitting it still commits;wrapNavigationwith the old route still mounted when it's called (the capture window), andpreventDefaultstill 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.wrapNavigationis 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/wrapUpdatevs. alternatives) — these are the smallest hooks that unblock animating navigations.