Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preload-redraw-after-seek.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions src/scenes/host.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
17 changes: 13 additions & 4 deletions src/scenes/host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ComponentType>()
const pending = new Set<string>()
const pending = new Map<string, Set<() => void>>()
const failed = new Set<string>()
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)
Expand Down
Loading