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/fix-safari-persist-canvas-context-loss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Improves internal state retention for persisted elements during view transitions, especially avoiding WebGL context loss in Safari and resets of CSS transitions and iframes in modern Chromium and Firefox browsers
5 changes: 5 additions & 0 deletions .changeset/wet-animals-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/internal-helpers': minor
---

Adds the new utilities `MANY_LEADING_SLASHES` and `collapseDuplicateLeadingSlashes`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<p id="canvas-one">Canvas Page 1</p>
<a id="click-two" href="/canvas-persist-two">go to 2</a>
<a id="click-no-canvas" href="/one">go to page without canvas</a>
<canvas id="my-canvas" transition:persist="my-canvas" width="200" height="200"></canvas>
</Layout>
<script>
const canvas = document.querySelector<HTMLCanvasElement>('#my-canvas')!;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = 'red';
ctx.fillRect(10, 10, 50, 50);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<p id="canvas-two">Canvas Page 2</p>
<a id="click-one" href="/canvas-persist-one">go to 1</a>
<canvas id="my-canvas" transition:persist="my-canvas" width="200" height="200"></canvas>
</Layout>
46 changes: 46 additions & 0 deletions packages/astro/e2e/view-transitions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,52 @@ test.describe('View Transitions', () => {
expect(text).toBe('true true');
});

test('transition:persist preserves canvas pixel data across navigation', async ({
page,
astro,
}) => {
// Page 1 draws a red rectangle on a persisted canvas
await page.goto(astro.resolveUrl('/canvas-persist-one'));
await expect(page.locator('#canvas-one')).toHaveText('Canvas Page 1');

// Verify the red rectangle was drawn (pixel at 20,20 should be red)
const beforePixel = await page.$eval('#my-canvas', (c) => {
const ctx = c.getContext('2d');
const pixel = ctx.getImageData(20, 20, 1, 1).data;
return [pixel[0], pixel[1], pixel[2], pixel[3]];
});
expect(beforePixel).toEqual([255, 0, 0, 255]);

// Navigate via client-side link (View Transitions swap runs)
await page.click('#click-two');
await expect(page.locator('#canvas-two')).toHaveText('Canvas Page 2');

// The persisted canvas should retain its pixel data
const afterPixel = await page.$eval('#my-canvas', (c) => {
const ctx = c.getContext('2d');
const pixel = ctx.getImageData(20, 20, 1, 1).data;
return [pixel[0], pixel[1], pixel[2], pixel[3]];
});
expect(afterPixel).toEqual([255, 0, 0, 255]);
});

test('transition:persist drops elements without matching target in new page', async ({
page,
astro,
}) => {
// Canvas page has a canvas with transition:persist="my-canvas"
await page.goto(astro.resolveUrl('/canvas-persist-one'));
await expect(page.locator('#canvas-one')).toHaveText('Canvas Page 1');
expect(await page.locator('#my-canvas').count()).toBe(1);

// Navigate via client-side link to a page WITHOUT a matching persist target
await page.click('#click-no-canvas');
await expect(page.locator('#one')).toHaveText('Page 1');

// The persisted canvas should NOT appear on the new page
expect(await page.locator('#my-canvas').count()).toBe(0);
});

test('it should be easy to define a data-theme preserving swap function', async ({
page,
astro,
Expand Down
55 changes: 40 additions & 15 deletions packages/astro/src/transitions/swap-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,25 +71,50 @@ export function swapHeadElements(doc: Document) {
}

export function swapBodyElement(newElement: Element, oldElement: Element) {
// this will reset scroll Position
oldElement.replaceWith(newElement);
// Lift persist elements to <html> before the body swap so they stay in the DOM
// throughout replaceWith(). This prevents Safari from losing WebGL context on
// <canvas> elements due to brief DOM detachment. Uses moveBefore() where available
// (Chrome 133+) for zero-detachment atomic moves.
const persistPairs: { old: Element; newTarget: Element }[] = [];
const docEl = oldElement.ownerDocument.documentElement;

// moveBefore() is not yet in TypeScript's DOM lib, feature-detect and wrap.
const moveBefore: ((parent: Node, node: Node, child: Node | null) => void) | null =
typeof (docEl as any).moveBefore === 'function'
? (parent, node, child) => (parent as any).moveBefore(node, child)
: null;

for (const el of oldElement.querySelectorAll(`[${PERSIST_ATTR}]`)) {
const id = el.getAttribute(PERSIST_ATTR);
const newEl = newElement.querySelector(`[${PERSIST_ATTR}="${id}"]`);
if (newEl) {
// The element exists in the new page, replace it with the element
// from the old page so that state is preserved.
newEl.replaceWith(el);
// For islands, copy over the props to allow them to re-render
if (
newEl.localName === 'astro-island' &&
shouldCopyProps(el as HTMLElement) &&
!isSameProps(el, newEl)
) {
el.setAttribute('ssr', '');
el.setAttribute('props', newEl.getAttribute('props')!);
}
if (!newEl) continue; // no matching target — leave in old body to be discarded
persistPairs.push({ old: el, newTarget: newEl });
if (moveBefore) {
moveBefore(docEl, el, null);
} else {
docEl.appendChild(el);
}
}

// this will reset scroll Position
oldElement.replaceWith(newElement);

// Move persist elements into the new body at the position of their targets
for (const { old: el, newTarget } of persistPairs) {
if (moveBefore) {
moveBefore(newTarget.parentNode!, el, newTarget);
newTarget.remove();
} else {
newTarget.replaceWith(el);
}
// For islands, copy over the props to allow them to re-render
if (
newTarget.localName === 'astro-island' &&
shouldCopyProps(el as HTMLElement) &&
!isSameProps(el, newTarget)
) {
el.setAttribute('ssr', '');
el.setAttribute('props', newTarget.getAttribute('props')!);
}
}

Expand Down
Loading