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/);