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
17 changes: 16 additions & 1 deletion components/presently/Presently/SlideRendering.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
};
Expand Down Expand Up @@ -108,6 +119,10 @@ export class SlideRendering {
delete document.documentElement.dataset.transition;
}

this.#disposeSlides();
}

#disposeSlides() {
this.#slides.forEach(slide => slide.dispose());
this.#slides = [];
}
Expand Down
179 changes: 179 additions & 0 deletions components/presently/test/SlideRendering.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions public/_components/.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -126,5 +126,5 @@
}
}
},
"digest": "e5f3142dd613283c1f28bf452c8bd6af6d8e756da224ca33374f6d7b01785d97"
"digest": "b12ebf93a8c386fdd2b7861c43c602a1313b48decab5521775abc03c6e8ff2a4"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
};
Expand Down Expand Up @@ -108,6 +119,10 @@ export class SlideRendering {
delete document.documentElement.dataset.transition;
}

this.#disposeSlides();
}

#disposeSlides() {
this.#slides.forEach(slide => slide.dispose());
this.#slides = [];
}
Expand Down
4 changes: 1 addition & 3 deletions public/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion public/playback.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ function stopCurrent() {
currentRendering?.dispose();
currentRendering = null;

stopAudio();
}

function stopAudio() {
if (currentAudio) {
currentAudio.pause();
currentAudio.removeEventListener('ended', handleEnded);
Expand All @@ -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;

Expand All @@ -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});
Expand Down
Loading