From a953e351786d105762fe964de1fa1a22fb23b85b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:42:15 +0100 Subject: [PATCH] feat(learner): hold the lesson until the next one loads, swap with a view transition The sidebar highlight moves at once; the lesson column keeps the current lesson until the next has loaded, then swaps inside a view transition, and the route scroll (top, or the saved position on back) is applied then. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/motion.e2e.test.ts | 49 ++++++++++++++++++- .../feature-learner/src/web/course/pages.tsx | 27 ++++++---- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index 8eafe6c..c49151a 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -2,7 +2,7 @@ // production bundle) in a subprocess. A probe in the page counts document.startViewTransition calls and records // animationstart / transitionstart events, so each test can say exactly what moved. Skipped without Chrome. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { appendFileSync, cpSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Chrome, chromePath } from "../../../packages/feature-learner/test/chrome"; @@ -21,6 +21,14 @@ const PROBE = ` return cls + (e.pseudoElement || ""); }; document.addEventListener("animationstart", (e) => window.__m.anims.push(e.animationName + "@" + who(e)), true); + // window.__delay (ms) holds lesson requests back, to see what the page shows while the next lesson loads. + const f = window.fetch; + window.fetch = function (input, init) { + const u = String((input && input.url) || input); + const d = window.__delay || 0; + if (d && u.includes("/lessons/")) return new Promise((r) => setTimeout(r, d)).then(() => f.call(this, input, init)); + return f.call(this, input, init); + }; document.addEventListener("transitionstart", (e) => window.__m.trans.push(e.propertyName + "@" + who(e)), true); `; @@ -68,12 +76,17 @@ run("navigation motion (headless Chrome)", () => { beforeAll(async () => { const port = await freePort(); dataDir = mkdtempSync(join(tmpdir(), "stc-motion-")); + // The sample course, with lessons long enough to scroll. + const courses = join(dataDir, "courses"); + cpSync(join(ROOT, "fixtures/courses/sample"), join(courses, "sample"), { recursive: true }); + const filler = Array.from({ length: 40 }, (_, i) => `Paragraph ${i + 1}. ${"A line of reading text that wraps across the column. ".repeat(4)}`).join("\n\n"); + for (const f of ["01-intro.md", "03-code.md"]) appendFileSync(join(courses, "sample/lessons", f), `\n\n${filler}\n`); base = `http://localhost:${port}`; server = Bun.spawn([process.execPath, "apps/server/src/main.ts"], { cwd: ROOT, stdout: "pipe", stderr: "pipe", env: { ...process.env, NODE_ENV: "production", STC_PORT: String(port), STC_SECRET: "motion-secret-0123456789abcdef0123456789", - STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: join(ROOT, "fixtures/courses"), + STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: courses, STC_PUBLIC_URL: base, }, }); @@ -105,4 +118,36 @@ run("navigation motion (headless Chrome)", () => { expect(r.anims).toContain("stc-enter@col"); expect(r.anims.some((a) => a.startsWith("stc-fade-out@") && a.includes("::view-transition-old(root)"))).toBe(true); }, 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"); + await chrome.eval("window.scrollTo(0, 900)"); + await Bun.sleep(150); + expect(await chrome.eval("scrollY")).toBe(900); + await chrome.eval("window.__delay = 500"); + await reset(); + await click(".stc-dock .stc-pill--primary"); + expect(await chrome.eval("location.pathname")).toBe("/c/sample/m1-video"); + await Bun.sleep(150); + // The sidebar answers the click at once; the lesson column keeps the old lesson, where it was, until the next is in. + expect(await text('.lr-les[aria-current="page"]')).toContain("Watch the clip"); + expect(await text(".lr-main .lr-lesson h1.title")).toBe("Introduction"); + expect(await chrome.eval("scrollY")).toBe(900); + await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Watch the clip", "next lesson"); + await chrome.eval("window.__delay = 0"); + await Bun.sleep(300); + expect(await chrome.eval("scrollY")).toBe(0); + const r = await m(); + expect(r.vt).toBe(1); + expect(r.anims).toContain("stc-enter@lr lr-lesson"); + expect(await rect(".lr-side")).toBe(side); + expect(await rect(".stc-top")).toBe(top); + + // Back: the intro returns at the position it was left. + await chrome.eval("history.back()"); + await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Introduction", "previous lesson"); + await until(async () => (await chrome.eval("scrollY")) === 900, "restored scroll", 2000); + expect(await rect(".lr-side")).toBe(side); + }, 30_000); }); diff --git a/packages/feature-learner/src/web/course/pages.tsx b/packages/feature-learner/src/web/course/pages.tsx index 39eb4c9..9e2dbfb 100644 --- a/packages/feature-learner/src/web/course/pages.tsx +++ b/packages/feature-learner/src/web/course/pages.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, type CSSProperties } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, type CSSProperties } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; -import { CourseCover, FloatingActionBar, Pill, ProgressBar } from "@stc/ui"; +import { CourseCover, FloatingActionBar, Pill, ProgressBar, routeScroll, useHeldValue } from "@stc/ui"; import type { CoursePayload, LessonPayload, Note, ProgressState } from "../../shared/types"; import { api, ensureStyles, fmtMinutes, fmtTime, lessonHref, refreshLibrary, toast, useApi, useCourse, useLook, useMe, useTrack } from "../api"; import { Check, Next, Prev } from "../icons"; @@ -106,26 +106,34 @@ export function LessonPage() { const navigate = useNavigate(); const { course, error: courseError, reload } = useCourse(courseId); const lessonApi = useApi(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`); - const lesson = lessonApi.data; + const loaded = lessonApi.data && lessonApi.data.lesson.id === lessonId && lessonApi.data.courseId === courseId ? lessonApi.data : null; + // Lesson to lesson: the current lesson stays on screen while the next loads, then the two swap in a view + // transition, so the column never blanks. Only within one course; the sidebar highlight moves at once. + const held = useHeldValue(loaded, (l) => l.lesson.id, (l) => l.courseId === courseId && !lessonApi.error); + const lesson = held.value; const { user } = useMe(); const [look] = useLook(); const [track, setTrack] = useTrack(courseId, course?.tracks ?? lesson?.tracks ?? []); const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]); const initialTime = q.has("t") ? Number(q.get("t")) : null; const initialSlide = q.has("slide") && q.has("deck") ? { deckId: q.get("deck")!, page: Number(q.get("slide")) } : null; - const progress = course?.progress[lessonId] ?? null; + const shownId = lesson?.lesson.id ?? lessonId; + const progress = course?.progress[shownId] ?? null; const signedIn = !!user; useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]); - useEffect(() => { window.scrollTo(0, 0); }, [lessonId]); + // While the old lesson is held, the shell's scroll for the new entry waits (this runs before the shell's + // layout effect) and is applied once the new lesson is on screen, inside the same transition. + useLayoutEffect(() => { if (held.stale) routeScroll().defer(); }, [loc.key]); + useLayoutEffect(() => { if (!held.stale || lessonApi.error) routeScroll().settle(); }, [lesson, held.stale, lessonApi.error]); const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => { if (!signedIn) return; try { - await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(lessonId)}`, { method: "PUT", body: { state, positionSeconds } }); + await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(shownId)}`, { method: "PUT", body: { state, positionSeconds } }); if (state === "done" || progress?.state !== state) { await reload(); void refreshLibrary(); } } catch (e) { toast(`Progress was not saved: ${(e as Error).message}`); } - }, [courseId, lessonId, signedIn, progress?.state, reload]); + }, [courseId, shownId, signedIn, progress?.state, reload]); if (courseError && !course) return ; if (lessonApi.error && course) { @@ -135,7 +143,7 @@ export function LessonPage() { ); } - if (!course || !lesson || lesson.lesson.id !== lessonId) { + if (!course || !lesson) { return course ?
: null; } const isDone = progress?.state === "done"; @@ -144,6 +152,7 @@ export function LessonPage() { return ( { void reload(); void refreshLibrary(); }} initialTime={initialTime} initialSlide={initialSlide} @@ -153,7 +162,7 @@ export function LessonPage() { lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}> Previous lesson - void setState(isDone ? "not-started" : "done")}> {isDone ? "Done" : "Mark done"}