From 89ff72cc284318a54fade912db3186c2941fa691 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:24:43 +0100 Subject: [PATCH] fix(web): keep the sidebar through route transitions; end them within 120 ms Router updates now run in a React transition, so a route whose code is still loading keeps the current page up and the view transition captures the new page with its sidebar (it used to drop out and pop back on the first course-to-lesson visit). The root group's 250 ms UA morph is off: pointer input goes to the document while a transition runs, so it stays as short as the old page's 120 ms fade. The pointer-events rule had no effect. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/main.tsx | 7 +++++-- apps/web/src/motion.e2e.test.ts | 28 +++++++++++++++++++++++++++- packages/ui/src/tokens.css | 5 +++-- packages/ui/test/motion-css.test.ts | 5 +++-- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 684ef00..eb78b13 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -70,12 +70,15 @@ const router = createBrowserRouter([ // Navigating to another kind of page (library, course, lesson, account, studio…) runs as a view transition: the old // page dissolves while the new one's content rises in (styles in @stc/ui tokens.css). Steps within a page (lesson // to lesson) are animated by the page itself. Reduced motion and browsers without the API navigate plainly. +// Router updates run in a React transition (v7_startTransition), so a route whose code is still loading keeps the +// current page up instead of falling back to nothing; the view transition captures the new page once it renders +// (with the course sidebar still in place). const routePatterns = featureRoutes.map((r) => ({ path: r.path })); const plainNavigate = router.navigate.bind(router); router.navigate = ((to: Parameters[0], opts?: Parameters[1]) => { const animate = typeof to !== "number" && to !== null && "startViewTransition" in document && !reducedMotion() && changesPage(routePatterns, router.state.location.pathname, typeof to === "string" ? to : createPath(to)); - return plainNavigate(to as never, animate ? { ...opts, viewTransition: opts?.viewTransition ?? true, flushSync: opts?.flushSync ?? true } : opts); + return plainNavigate(to as never, animate ? { ...opts, viewTransition: opts?.viewTransition ?? true } : opts); }) as typeof router.navigate; -createRoot(document.getElementById("root")!).render(); +createRoot(document.getElementById("root")!).render(); diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index 7c0ed4a..1a95c1a 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -44,7 +44,8 @@ async function freePort(): Promise { export async function until(f: () => Promise, what: string, ms = 8000): Promise> { const end = Date.now() + ms; for (;;) { - const v = await f().catch(() => null); + // A stalled DevTools call (a heavily loaded machine) counts as "not yet" instead of hanging the test. + const v = await Promise.race([f(), Bun.sleep(3000).then(() => null)]).catch(() => null); if (v) return v as NonNullable; if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); await Bun.sleep(25); @@ -123,6 +124,31 @@ run("navigation motion (headless Chrome)", () => { expect(r.anims.some((a) => a.startsWith("stc-fade-out@") && a.includes("::view-transition-old(root)"))).toBe(true); }, 30_000); + test("course to lesson: the sidebar stays through the transition, which is over within 220 ms", async () => { + // A fresh page, so the lesson route's code is not loaded yet (the case where the sidebar used to drop out). + await open("/c/sample", ".lr-main > article h1"); + const side = await rect(".lr-side"); + await click('.lr-les[href="/c/sample/m1-intro"]'); + await until(() => exists(".lr-main .lr-lesson h1.title"), "lesson"); + await Bun.sleep(300); + const r = await m(); + expect(r.vt).toBe(1); + expect(r.anims.filter((a) => a.includes("(lr-side)") || a.includes("(stc-top)"))).toEqual([]); + expect(await rect(".lr-side")).toBe(side); + // Every view-transition animation ends by 220 ms (no 250 ms UA default left over). + await open("/c/sample", ".lr-main > article h1"); + const ends = await chrome.eval(`(() => { + document.querySelector('.lr-les[href="/c/sample/m1-code"]').click(); + const ends = [], t0 = performance.now(); + return new Promise((r) => { const f = () => { + for (const a of document.getAnimations()) if (a.effect.pseudoElement?.startsWith("::view-transition")) ends.push(a.effect.getComputedTiming().endTime); + if (performance.now() - t0 < 500) requestAnimationFrame(f); else r(ends); + }; f(); }); + })()`); + expect(ends.length).toBeGreaterThan(0); + expect(Math.max(...ends)).toBeLessThanOrEqual(220); + }, 30_000); + test("lesson to lesson: the old lesson stays until the next is ready, then swaps; sidebar and header do not move", async () => { await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title"); const side = await rect(".lr-side"), top = await rect(".stc-top"); diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 2ef20d3..137ea82 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -82,8 +82,9 @@ code, pre { font-family: var(--mono); } /* View transitions (route changes, lesson swaps). The old page dissolves over the live new one, whose content plays its own enter animation; the header and the course sidebar are their own groups and do not move. - The overlay never takes pointer input, so a click during a transition reaches the new page. */ -::view-transition { pointer-events: none; } + While a transition runs the browser sends pointer input to the document, so it is kept to --t-exit: the root + group's default 250 ms morph is off (old and new root are both the viewport). Keyboard input is unaffected. */ +::view-transition-group(root) { animation: none; } ::view-transition-old(root), ::view-transition-new(root) { mix-blend-mode: normal; } ::view-transition-old(root) { z-index: 1; animation: stc-fade-out var(--t-exit) var(--ease-out) both; } ::view-transition-new(root) { animation: none; } diff --git a/packages/ui/test/motion-css.test.ts b/packages/ui/test/motion-css.test.ts index 07eefc5..387c3e3 100644 --- a/packages/ui/test/motion-css.test.ts +++ b/packages/ui/test/motion-css.test.ts @@ -92,9 +92,10 @@ test("prefers-reduced-motion turns off every animation, transition and view tran expect(block).toMatch(/\*::details-content\s*\{\s*transition:\s*none !important/); }); -test("view transitions never take pointer input and keep the header and sidebar still", () => { +test("view transitions are short and keep the header and sidebar still", () => { const css = strip(tokensCss); - expect(css).toMatch(/::view-transition\s*\{[^}]*pointer-events:\s*none/); + // the UA default (a 250 ms root morph) would outlast the 220 ms ceiling + expect(css).toMatch(/::view-transition-group\(root\)\s*\{\s*animation:\s*none/); expect(css).toMatch(/\.stc-top\s*\{[^}]*view-transition-name:\s*stc-top/); expect(strip(learnerCss)).toMatch(/\.lr-side\s*\{[^}]*view-transition-name:\s*lr-side/); expect(css).toMatch(/::view-transition-group\(stc-top\)[^{]*\{[^}]*animation:\s*none/);