diff --git a/.changeset/preload-redraw-after-seek.md b/.changeset/preload-redraw-after-seek.md new file mode 100644 index 0000000..ba9de81 --- /dev/null +++ b/.changeset/preload-redraw-after-seek.md @@ -0,0 +1,5 @@ +--- +"@karnstack/kino": patch +--- + +Scenes: a paused seek into a scene that is still preloading now draws it when the load lands. Stage preloads the next scene with no callback, so when a seek (often a `?t=` deep link) landed in that scene before its preload finished, the redraw callback for the current scene was dropped because the id was already pending. Paused, nothing else re-renders the stage, so it stayed empty until the next play or seek. Every caller of a pending scene now joins its waiters and each one runs when the load resolves. The `timeupdate` workaround around `loadScene` can be removed. diff --git a/src/scenes/host.test.tsx b/src/scenes/host.test.tsx index 9c5f53b..3d63db7 100644 --- a/src/scenes/host.test.tsx +++ b/src/scenes/host.test.tsx @@ -643,3 +643,28 @@ test("scenes keep loading while hidden, so the stage is not blank on return", as expect(scenesInDom(h.container)).toEqual(["03"]) act(() => h.host.destroy()) }) + +test("a seek into a scene still preloading draws it once the load lands", async () => { + // Scene 02 resolves only when the test says so, so its preload is still in + // flight when the seek arrives. + let resolveTwo!: (m: { default: typeof SceneTwo }) => void + const two = new Promise<{ default: typeof SceneTwo }>((r) => { + resolveTwo = r + }) + const h = makeHost({ + loadScene: (id) => + id === "01" ? Promise.resolve({ default: SceneOne }) : two, + }) + await flush() + // Scene 01 drew, which started the preload of 02. + expect(scenesInDom(h.container)).toEqual(["01"]) + // Paused seek into 02 while its preload is pending: no clock ticks follow. + command({ type: "kino:seek", time: 7 }) + await flush() + await act(async () => { + resolveTwo({ default: SceneTwo }) + await two + }) + expect(scenesInDom(h.container)).toEqual(["02"]) + act(() => h.host.destroy()) +}) diff --git a/src/scenes/host.tsx b/src/scenes/host.tsx index 7df19c2..19e68c2 100644 --- a/src/scenes/host.tsx +++ b/src/scenes/host.tsx @@ -269,16 +269,25 @@ export function createSceneHost(opts: SceneHostOptions): { destroy(): void } { // cached after first load; the next scene preloads while the current plays. // Failures are memoized too: Stage calls ensureLoaded on every clock tick, // so without this a broken scene would retry and re-post at frame rate. + // A caller that asks for a scene already in flight joins its waiters: a + // preload (no onDone) can still be running when a paused seek lands in that + // scene, and with no clock ticks to follow, the waiter is the only thing + // that draws it when the load lands. const moduleCache = new Map() - const pending = new Set() + const pending = new Map void>>() const failed = new Set() const ensureLoaded = (id: string, onDone?: () => void) => { - if (moduleCache.has(id) || pending.has(id) || failed.has(id)) return - pending.add(id) + if (moduleCache.has(id) || failed.has(id)) return + const waiters = pending.get(id) + if (waiters) { + if (onDone) waiters.add(onDone) + return + } + pending.set(id, new Set(onDone ? [onDone] : [])) loadScene(id) .then((m) => { moduleCache.set(id, m.default) - onDone?.() + for (const done of pending.get(id) ?? []) done() }) .catch(() => { failed.add(id)