Skip to content

Commit 999a7dd

Browse files
veeceeymartrappclaude
authored
fix: preserve inline styles and font preloads during ClientRouter head swap (#15514)
* fix: preserve inline styles and font preloads during ClientRouter head swap During client-side navigation with ClientRouter, swapHeadElements() only preserved elements with data-astro-transition-persist and stylesheet links. Inline <style> tags (including @font-face declarations from <Font>) were removed and re-appended on every navigation, forcing the browser to re-evaluate them and triggering a visible font flash (FOUT). This extends persistedHeadElement() to also match: - Inline <style> elements by their text content - Font preload links (link[rel=preload][as=font]) by href When an identical element exists in both the old and new document head, it stays in the DOM instead of being removed and re-inserted. Fixes #15465 * add changeset * Add e2e test for inline style and font preload persistence Add a test that verifies inline <style> elements (e.g. @font-face declarations) and font preload links survive head swaps during client-side navigation, preventing FOUT (Flash of Unstyled Text). The test navigates between two pages sharing identical inline styles and font preloads, and asserts that the original DOM nodes persist rather than being removed and re-inserted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve lint errors and flaky e2e test for font persistence Remove unused variables (styleIdBefore, preloadIdBefore) to fix biome lint errors. Replace fragile exact style count comparison with greaterThan(0) checks, since Astro may inject additional style elements during client-side navigation. Use data-attribute selectors to directly query the marked DOM nodes instead of relying on querySelector order. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Make the test a bit more concise * Update fix-font-head-swap.md --------- Co-authored-by: Martin Trapp <94928215+martrapp@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b30a2c4 commit 999a7dd

5 files changed

Lines changed: 84 additions & 0 deletions

File tree

.changeset/fix-font-head-swap.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes font flash (FOUT) during ClientRouter navigation by preserving inline `<style>` elements and font preload links in the head during page transitions.
6+
7+
Previously, `@font-face` declarations from the `<Font>` component were removed and re-inserted on every client-side navigation, causing the browser to re-evaluate them.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
import { ClientRouter } from 'astro:transitions';
3+
---
4+
<html>
5+
<head>
6+
<title>Font Page 1</title>
7+
<style is:inline id="style">
8+
@font-face {
9+
font-family: 'TestFont';
10+
src: url('/fonts/test.woff2') format('woff2');
11+
font-display: swap;
12+
}
13+
body { font-family: 'TestFont', sans-serif; }
14+
</style>
15+
<link id="preload" rel="preload" href="/fonts/test.woff2" as="font" type="font/woff2" crossorigin />
16+
<ClientRouter />
17+
</head>
18+
<body>
19+
<p id="font-page-one">Font Page 1</p>
20+
<a id="click-font-two" href="/font-page-two">go to font page 2</a>
21+
</body>
22+
</html>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
import { ClientRouter } from 'astro:transitions';
3+
---
4+
<html>
5+
<head>
6+
<title>Font Page 2</title>
7+
<style is:inline>
8+
@font-face {
9+
font-family: 'TestFont';
10+
src: url('/fonts/test.woff2') format('woff2');
11+
font-display: swap;
12+
}
13+
body { font-family: 'TestFont', sans-serif; }
14+
</style>
15+
<link rel="preload" href="/fonts/test.woff2" as="font" type="font/woff2" crossorigin />
16+
<ClientRouter />
17+
</head>
18+
<body>
19+
<p id="font-page-two">Font Page 2</p>
20+
<a id="click-font-one" href="/font-page-one">go to font page 1</a>
21+
</body>
22+
</html>

packages/astro/e2e/view-transitions.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,4 +1759,20 @@ test.describe('View Transitions', () => {
17591759
await new Promise((resolve) => setTimeout(resolve, 1000));
17601760
expect(lines.join('')).toBe('');
17611761
});
1762+
1763+
test('Inline styles and font preloads persist through head swap', async ({ page, astro }) => {
1764+
// Go to font page 1
1765+
await page.goto(astro.resolveUrl('/font-page-one'));
1766+
let p = page.locator('#font-page-one');
1767+
await expect(p, 'should have content').toHaveText('Font Page 1');
1768+
1769+
// Navigate to font page 2 (same inline styles and font preloads)
1770+
await page.click('#click-font-two');
1771+
p = page.locator('#font-page-two');
1772+
await expect(p, 'should have content').toHaveText('Font Page 2');
1773+
1774+
// Verify original inline styles and font preloads are still present after navigation
1775+
await expect(page.locator('#style')).toHaveCount(1);
1776+
await expect(page.locator('#preload')).toHaveCount(1);
1777+
});
17621778
});

packages/astro/src/transitions/swap-functions.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,23 @@ const persistedHeadElement = (el: HTMLElement, newDoc: Document): Element | null
161161
const href = el.getAttribute('href');
162162
return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`);
163163
}
164+
// Preserve inline <style> elements with identical content across navigations.
165+
// This prevents unnecessary removal and re-insertion of styles (e.g. @font-face
166+
// declarations from <Font>), which would cause the browser to re-evaluate them
167+
// and trigger a flash of unstyled text (FOUT).
168+
if (el.tagName === 'STYLE' && el.textContent) {
169+
const styles = newDoc.head.querySelectorAll('style');
170+
for (const s of styles) {
171+
if (s.textContent === el.textContent) {
172+
return s;
173+
}
174+
}
175+
}
176+
// Preserve font preload links across navigations to avoid re-fetching cached fonts.
177+
if (el.matches('link[rel=preload][as=font]')) {
178+
const href = el.getAttribute('href');
179+
return newDoc.head.querySelector(`link[rel=preload][as=font][href="${href}"]`);
180+
}
164181
return null;
165182
};
166183

0 commit comments

Comments
 (0)