From f3d793eb9be125cdda26c52b9c0ece18732cadd1 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 17:17:08 +1200 Subject: [PATCH] Preserve outgoing slide state until transition capture --- .../presently/Presently/SlideRendering.js | 17 +- components/presently/test/SlideRendering.js | 179 ++++++++++++++++++ public/_components/.manifest.json | 4 +- .../presently/Presently/SlideRendering.js | 17 +- public/application.js | 4 +- public/playback.js | 7 +- 6 files changed, 220 insertions(+), 8 deletions(-) diff --git a/components/presently/Presently/SlideRendering.js b/components/presently/Presently/SlideRendering.js index a003aa3..71b62a3 100644 --- a/components/presently/Presently/SlideRendering.js +++ b/components/presently/Presently/SlideRendering.js @@ -17,9 +17,17 @@ export class SlideRendering { #slides = []; #disposed = false; - constructor(view, {transition = null} = {}) { + constructor(view, {transition = null, previous = null} = {}) { this.#view = view; this.#transitionName = transition; + + if (previous) { + // Cancel stale rendering work immediately, but retain the visible slides + // until the browser has captured the outgoing transition snapshot. + this.#slides = previous.#slides; + previous.#slides = []; + previous.dispose(); + } } // Initialize the slides already rendered within the view. @@ -50,6 +58,9 @@ export class SlideRendering { const render = async () => { if (this.#disposed) return; + // View transitions invoke this callback after capturing the old state. + // Revert outgoing animations before updating potentially reused DOM nodes. + this.#disposeSlides(); update(this.#view); initialized = await this.initialize(); }; @@ -108,6 +119,10 @@ export class SlideRendering { delete document.documentElement.dataset.transition; } + this.#disposeSlides(); + } + + #disposeSlides() { this.#slides.forEach(slide => slide.dispose()); this.#slides = []; } diff --git a/components/presently/test/SlideRendering.js b/components/presently/test/SlideRendering.js index e1f7899..650decf 100644 --- a/components/presently/test/SlideRendering.js +++ b/components/presently/test/SlideRendering.js @@ -37,6 +37,185 @@ class View extends EventTarget { } } +function animatedSlide() { + const slide = new SlideElement(` + slide.anime(({utils}) => utils.set(slide.element.camera, {zoom: 3})); + slide.defer(() => slide.element.disposals += 1); + `); + slide.body.camera = {zoom: 1}; + slide.body.disposals = 0; + return slide; +} + +// The browser captures the outgoing slide before invoking the update callback. +// Control that boundary separately from the end of the visual transition. +function pendingTransition(callback) { + let update; + let finish; + const updateCallbackDone = new Promise(resolve => update = () => resolve(callback())); + const animation = new Promise(resolve => finish = resolve); + + return { + update, + finish, + updateCallbackDone, + ready: updateCallbackDone, + finished: updateCallbackDone.then(() => animation), + skipped: false, + skipTransition() { + this.skipped = true; + finish(); + }, + }; +} + +for (const mode of ['fade', 'no transition', 'hidden', 'unsupported']) { + test(`replacement preserves animated state until the DOM update (${mode})`, async () => { + const originalHighlight = Syntax.highlight; + Syntax.highlight = async () => {}; + globalThis.document = { + hidden: mode === 'hidden', + documentElement: {dataset: {}}, + querySelectorAll: () => [], + }; + + try { + const outgoing = animatedSlide(); + const incoming = animatedSlide(); + const view = new View('current', [outgoing]); + const previous = new SlideRendering(view); + await previous.initialize(); + let transition; + if (mode !== 'unsupported') { + document.startViewTransition = callback => transition = pendingTransition(callback); + } + + const rendering = new SlideRendering(view, { + transition: mode === 'no transition' ? null : 'fade', + previous, + }); + assert.equal(outgoing.body.camera.zoom, 3); + + const rendered = rendering.render(() => { + assert.equal(outgoing.body.camera.zoom, 1); + assert.equal(outgoing.body.disposals, 1); + view.slides = [incoming]; + }); + + if (mode === 'fade') { + // This is the state the browser captures for the outgoing snapshot. + assert.equal(outgoing.body.camera.zoom, 3); + assert.equal(outgoing.body.disposals, 0); + transition.update(); + assert.equal(incoming.body.camera.zoom, 3); + await transition.updateCallbackDone; + assert.equal(document.documentElement.dataset.transition, 'fade'); + transition.finish(); + } else { + assert.equal(transition, undefined); + assert.equal(incoming.body.camera.zoom, 3); + } + + assert.equal(await rendered, true); + assert.equal(document.documentElement.dataset.transition, undefined); + previous.dispose(); + rendering.dispose(); + rendering.dispose(); + assert.equal(outgoing.body.disposals, 1); + assert.equal(incoming.body.disposals, 1); + } finally { + Syntax.highlight = originalHighlight; + delete globalThis.document; + } + }); +} + +for (const updated of [false, true]) { + test(`rapid navigation preserves the visible slide (${updated ? 'after' : 'before'} the pending DOM update)`, async () => { + const originalHighlight = Syntax.highlight; + Syntax.highlight = async () => {}; + const transitions = []; + globalThis.document = { + documentElement: {dataset: {}}, + querySelectorAll: () => [], + startViewTransition(callback) { + const transition = pendingTransition(callback); + transitions.push(transition); + return transition; + }, + }; + + try { + const firstSlide = animatedSlide(); + const secondSlide = animatedSlide(); + const finalSlide = animatedSlide(); + const view = new View('current', [firstSlide]); + let changes = 0; + view.addEventListener('presently:slide:change', () => changes += 1); + const first = new SlideRendering(view); + await first.initialize(); + + const second = new SlideRendering(view, {transition: 'fade', previous: first}); + let secondUpdates = 0; + const secondResult = second.render(() => { + secondUpdates += 1; + view.slides = [secondSlide]; + }); + if (updated) { + transitions[0].update(); + await transitions[0].updateCallbackDone; + } + + const final = new SlideRendering(view, {transition: 'slide-left', previous: second}); + const finalResult = final.render(() => view.slides = [finalSlide]); + assert.equal(transitions[0].skipped, true); + assert.equal((updated ? secondSlide : firstSlide).body.camera.zoom, 3); + assert.equal((updated ? secondSlide : firstSlide).body.disposals, 0); + + if (!updated) transitions[0].update(); + assert.equal(await secondResult, false); + assert.equal(secondUpdates, updated ? 1 : 0); + assert.equal(document.documentElement.dataset.transition, 'slide-left'); + assert.equal(changes, 0); + + transitions[1].update(); + transitions[1].finish(); + assert.equal(await finalResult, true); + assert.equal(changes, 1); + assert.equal(view.slides[0], finalSlide); + final.dispose(); + assert.equal(firstSlide.body.disposals, 1); + assert.equal(secondSlide.body.disposals, updated ? 1 : 0); + assert.equal(finalSlide.body.disposals, 1); + } finally { + Syntax.highlight = originalHighlight; + delete globalThis.document; + } + }); +} + +test('disposing a replacement before its update releases the outgoing slide', async () => { + const originalHighlight = Syntax.highlight; + Syntax.highlight = async () => {}; + globalThis.document = {querySelectorAll: () => []}; + + try { + const outgoing = animatedSlide(); + const view = new View('current', [outgoing]); + const previous = new SlideRendering(view); + await previous.initialize(); + const rendering = new SlideRendering(view, {previous}); + rendering.dispose(); + assert.equal(outgoing.body.camera.zoom, 1); + assert.equal(outgoing.body.disposals, 1); + previous.dispose(); + assert.equal(await rendering.render(() => assert.fail('Disposed rendering updated the DOM')), false); + } finally { + Syntax.highlight = originalHighlight; + delete globalThis.document; + } +}); + test('slide scripts establish initial state before asynchronous preparation', async () => { const originalHighlight = Syntax.highlight; let releaseHighlight; diff --git a/public/_components/.manifest.json b/public/_components/.manifest.json index 43f7f08..f1eb798 100644 --- a/public/_components/.manifest.json +++ b/public/_components/.manifest.json @@ -15,7 +15,7 @@ "Presently/CodeFocus.js": "05fb95308f012cab92655671050279149fc4aa683450932e27fc77bd51f8ce29", "Presently/Scripts.js": "c2a005e2dc7e938a5ca32b7a2177331ad0cea6aa9e7496ffe2810b9561291726", "Presently/Slide.js": "0d67e83eff558aee8ae829a2e526dc4d583f9c0753db156c531b57e32d271ead", - "Presently/SlideRendering.js": "64f37ceb0edea843d6084d1d58feb8701376eaaa5909bad9d3bc99301e590e9b" + "Presently/SlideRendering.js": "935ce8ceb65800146cfdc734d93d764268d6077821199721b6d1bcd31b596113" } }, "@socketry/syntax": { @@ -126,5 +126,5 @@ } } }, - "digest": "e5f3142dd613283c1f28bf452c8bd6af6d8e756da224ca33374f6d7b01785d97" + "digest": "b12ebf93a8c386fdd2b7861c43c602a1313b48decab5521775abc03c6e8ff2a4" } diff --git a/public/_components/@socketry/presently/Presently/SlideRendering.js b/public/_components/@socketry/presently/Presently/SlideRendering.js index a003aa3..71b62a3 100644 --- a/public/_components/@socketry/presently/Presently/SlideRendering.js +++ b/public/_components/@socketry/presently/Presently/SlideRendering.js @@ -17,9 +17,17 @@ export class SlideRendering { #slides = []; #disposed = false; - constructor(view, {transition = null} = {}) { + constructor(view, {transition = null, previous = null} = {}) { this.#view = view; this.#transitionName = transition; + + if (previous) { + // Cancel stale rendering work immediately, but retain the visible slides + // until the browser has captured the outgoing transition snapshot. + this.#slides = previous.#slides; + previous.#slides = []; + previous.dispose(); + } } // Initialize the slides already rendered within the view. @@ -50,6 +58,9 @@ export class SlideRendering { const render = async () => { if (this.#disposed) return; + // View transitions invoke this callback after capturing the old state. + // Revert outgoing animations before updating potentially reused DOM nodes. + this.#disposeSlides(); update(this.#view); initialized = await this.initialize(); }; @@ -108,6 +119,10 @@ export class SlideRendering { delete document.documentElement.dataset.transition; } + this.#disposeSlides(); + } + + #disposeSlides() { this.#slides.forEach(slide => slide.dispose()); this.#slides = []; } diff --git a/public/application.js b/public/application.js index a2d77b7..a2550a4 100644 --- a/public/application.js +++ b/public/application.js @@ -10,10 +10,8 @@ let live = null; let activeRendering = null; function activateRendering(view, {transition = null} = {}) { - const rendering = new SlideRendering(view, {transition}); - const previousRendering = activeRendering; + const rendering = new SlideRendering(view, {transition, previous: activeRendering}); activeRendering = rendering; - previousRendering?.dispose(); return rendering; } diff --git a/public/playback.js b/public/playback.js index eac7e9e..847e8c4 100644 --- a/public/playback.js +++ b/public/playback.js @@ -36,6 +36,10 @@ function stopCurrent() { currentRendering?.dispose(); currentRendering = null; + stopAudio(); +} + +function stopAudio() { if (currentAudio) { currentAudio.pause(); currentAudio.removeEventListener('ended', handleEnded); @@ -55,6 +59,7 @@ async function activateFrame(index, {transition = true} = {}) { const rendering = new SlideRendering(frame, { transition: transition ? slideTemplate.dataset.transition : null, + previous: currentRendering, }); currentRendering = rendering; @@ -70,7 +75,7 @@ async function show(index, {transition = true} = {}) { if (transitioning || index < 0 || index >= slideTemplates.length) return false; transitioning = true; - stopCurrent(); + stopAudio(); try { return await activateFrame(index, {transition});