Skip to content

Commit 057b0f4

Browse files
authored
fix(viewer): defer preview font stylesheets (#7134)
1 parent ec10e66 commit 057b0f4

4 files changed

Lines changed: 109 additions & 5 deletions

File tree

apps/web/src/components/FileViewer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10372,6 +10372,7 @@ function HtmlViewer({
1037210372
paletteBridge: false,
1037310373
previewFocusGuard: true,
1037410374
previewObservability: true,
10375+
deferFontStylesheets: true,
1037510376
// Embed the reload counter so the srcdoc string differs across reloads
1037610377
// even when the fetched HTML bytes are identical (issue #4650).
1037710378
reloadKey,

apps/web/src/runtime/deck-thumbnail-parser.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const FONT_HOSTS = new Set([
7777
// must be an https URL whose HOST is exactly an approved font CDN — a substring
7878
// match would accept `https://evil.example/fonts.googleapis.com.css` and inject
7979
// arbitrary CSS into the app document.
80-
function isApprovedFontHref(href: string): boolean {
80+
export function isApprovedFontStylesheetHref(href: string): boolean {
8181
// Font-CDN links are always absolute https URLs; a relative href cannot be an
8282
// approved CDN and is correctly treated as an untrusted external stylesheet.
8383
let url: URL;
@@ -127,7 +127,7 @@ export function parseDeckThumbnails(html: string, baseHref?: string): ParsedDeck
127127
if (!/\bstylesheet\b/.test(rel)) continue;
128128
const href = link.getAttribute('href') || '';
129129
if (!href) continue;
130-
if (isApprovedFontHref(href)) {
130+
if (isApprovedFontStylesheetHref(href)) {
131131
if (!fontLinks.includes(href)) fontLinks.push(href);
132132
} else {
133133
return unrenderable('external-stylesheet');
@@ -468,7 +468,7 @@ function extractStylesheetImports(css: string): StylesheetImportExtraction {
468468
const match = CSS_IMPORT_HREF_RE.exec(statement);
469469
const href = match?.slice(1).find((value): value is string => typeof value === 'string')?.trim() ?? '';
470470
const condition = match ? statement.slice(match[0].length, -1).trim() : '';
471-
if (!href || condition || !isApprovedFontHref(href)) unsafe = true;
471+
if (!href || condition || !isApprovedFontStylesheetHref(href)) unsafe = true;
472472
else if (!fontLinks.includes(href)) fontLinks.push(href);
473473

474474
chunks.push(css.slice(chunkStart, i));

apps/web/src/runtime/srcdoc.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
MANUAL_EDIT_DISCOVERY_SELECTOR,
3232
MANUAL_EDIT_SOURCE_PATH_ATTR,
3333
} from '../edit-mode/bridge';
34+
import { isApprovedFontStylesheetHref } from './deck-thumbnail-parser';
3435

3536
export type SrcdocOptions = {
3637
deck?: boolean;
@@ -48,6 +49,9 @@ export type SrcdocOptions = {
4849
/** Install the live-preview error and white-screen reporting bridge. Keep
4950
* this disabled for exports, captures, thumbnails, and historical previews. */
5051
previewObservability?: boolean;
52+
/** Let trusted font-CDN stylesheets load without blocking the live preview's
53+
* first paint. Keep disabled for exports and other capture surfaces. */
54+
deferFontStylesheets?: boolean;
5155
/**
5256
* Force every CSS animation/transition to complete instantly so the
5357
* document settles at its final visual state and stops repainting. Meant
@@ -395,7 +399,10 @@ export function buildSrcdoc(
395399
const withOdIds = annotateMissingOdIds(withSafeTitle);
396400
const withSourcePaths = options.editBridge ? annotateManualEditSourcePaths(withOdIds) : withOdIds;
397401
const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths;
398-
const withShim = injectSandboxShim(withBase);
402+
const withDeferredFonts = options.deferFontStylesheets
403+
? deferTrustedFontStylesheets(withBase)
404+
: withBase;
405+
const withShim = injectSandboxShim(withDeferredFonts);
399406
const blockLoadTimeScriptRedirect = htmlHasLoadTimeLocationNavigation(withBase);
400407
// Always on: a redirect loop can freeze ANY previewed artifact, and the guard
401408
// is inert on documents that never self-redirect. Injected right after the
@@ -1301,6 +1308,62 @@ function injectManualEditBridge(doc: string): string {
13011308
return injectBeforeBodyEnd(withStyle, buildManualEditBridge(false));
13021309
}
13031310

1311+
const DEFERRED_FONT_STYLESHEET_ATTR = 'data-od-deferred-font-stylesheet';
1312+
1313+
function deferTrustedFontStylesheets(doc: string): string {
1314+
if (typeof DOMParser === 'undefined') return doc;
1315+
let parsed: Document;
1316+
try {
1317+
parsed = new DOMParser().parseFromString(doc, 'text/html');
1318+
} catch {
1319+
return doc;
1320+
}
1321+
1322+
let deferred = false;
1323+
parsed.querySelectorAll<HTMLLinkElement>('link[rel~="stylesheet"][href]').forEach((link) => {
1324+
const href = link.getAttribute('href') ?? '';
1325+
if (!isApprovedFontStylesheetHref(href)) return;
1326+
const authoredMedia = link.getAttribute('media')?.trim() ?? '';
1327+
if (authoredMedia.toLowerCase() === 'print') return;
1328+
link.setAttribute(DEFERRED_FONT_STYLESHEET_ATTR, authoredMedia);
1329+
link.setAttribute('media', 'print');
1330+
deferred = true;
1331+
});
1332+
if (!deferred) return doc;
1333+
1334+
const script = `<script data-od-font-stylesheet-loader>(function(){
1335+
var attr = '${DEFERRED_FONT_STYLESHEET_ATTR}';
1336+
var selector = 'link[' + attr + ']';
1337+
function activate(link){
1338+
if (!link || !link.hasAttribute(attr)) return;
1339+
var media = link.getAttribute(attr) || 'all';
1340+
link.setAttribute('media', media);
1341+
link.removeAttribute(attr);
1342+
}
1343+
function watch(link){
1344+
if (!link || link.__odFontStylesheetWatched) return;
1345+
link.__odFontStylesheetWatched = true;
1346+
link.addEventListener('load', function(){ activate(link); }, { once: true });
1347+
try { if (link.sheet) activate(link); } catch (_) {}
1348+
}
1349+
function scan(root){
1350+
if (!root) return;
1351+
if (root.matches && root.matches(selector)) watch(root);
1352+
var links = root.querySelectorAll ? root.querySelectorAll(selector) : [];
1353+
for (var i = 0; i < links.length; i += 1) watch(links[i]);
1354+
}
1355+
var observer = typeof MutationObserver === 'function' ? new MutationObserver(function(records){
1356+
for (var i = 0; i < records.length; i += 1) {
1357+
var added = records[i].addedNodes || [];
1358+
for (var j = 0; j < added.length; j += 1) scan(added[j]);
1359+
}
1360+
}) : null;
1361+
if (observer && document.documentElement) observer.observe(document.documentElement, { childList: true, subtree: true });
1362+
scan(document);
1363+
})();</script>`;
1364+
return injectAfterHeadOpen(serializeHtmlDocument(parsed), script);
1365+
}
1366+
13041367
function injectAfterHeadOpen(doc: string, payload: string): string {
13051368
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (m) => `${m}${payload}`);
13061369
return payload + doc;

apps/web/tests/runtime/srcdoc.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { describe, expect, it, vi } from 'vitest';
22
import { JSDOM } from 'jsdom';
33
import { DECK_STRUCTURED_SLIDE_SELECTOR } from '@open-design/contracts/runtime/deck-stage-fallback';
44
import { buildSrcdoc } from '../../src/runtime/srcdoc';
@@ -108,6 +108,46 @@ describe('buildSrcdoc', () => {
108108
expect(buildSrcdoc(html)).not.toContain('data-od-preview-observability');
109109
});
110110

111+
it('defers trusted font stylesheets without changing authored layout CSS', async () => {
112+
const parserWindow = new JSDOM('').window;
113+
vi.stubGlobal('DOMParser', parserWindow.DOMParser);
114+
const fontHref = 'https://fonts.googleapis.com/css2?family=Inter&display=swap';
115+
const html = `<!doctype html><html><head>
116+
<link href="${fontHref}" rel="stylesheet">
117+
<link href="/layout.css" rel="stylesheet">
118+
</head><body><main>Preview</main></body></html>`;
119+
try {
120+
const srcdoc = buildSrcdoc(html, { deferFontStylesheets: true });
121+
const document = new JSDOM(srcdoc).window.document;
122+
const fontLink = document.querySelector<HTMLLinkElement>('link[href^="https://fonts.googleapis.com/"]');
123+
const layoutLink = document.querySelector<HTMLLinkElement>('link[href="/layout.css"]');
124+
125+
expect(fontLink?.media).toBe('print');
126+
expect(fontLink?.hasAttribute('data-od-deferred-font-stylesheet')).toBe(true);
127+
expect(layoutLink?.media).toBe('');
128+
expect(layoutLink?.hasAttribute('data-od-deferred-font-stylesheet')).toBe(false);
129+
expect(srcdoc.indexOf('data-od-font-stylesheet-loader')).toBeLessThan(
130+
srcdoc.indexOf('fonts.googleapis.com'),
131+
);
132+
133+
const runtime = new JSDOM(srcdoc, { runScripts: 'dangerously' });
134+
await new Promise<void>((resolve) => runtime.window.queueMicrotask(resolve));
135+
const runtimeFontLink = runtime.window.document.querySelector<HTMLLinkElement>(
136+
'link[href^="https://fonts.googleapis.com/"]',
137+
);
138+
runtimeFontLink?.dispatchEvent(new runtime.window.Event('load'));
139+
expect(runtimeFontLink?.media).toBe('all');
140+
expect(runtimeFontLink?.hasAttribute('data-od-deferred-font-stylesheet')).toBe(false);
141+
runtime.window.close();
142+
143+
const unchanged = new JSDOM(buildSrcdoc(html)).window.document;
144+
expect(unchanged.querySelector<HTMLLinkElement>('link[href^="https://fonts.googleapis.com/"]')?.media).toBe('');
145+
} finally {
146+
vi.unstubAllGlobals();
147+
parserWindow.close();
148+
}
149+
});
150+
111151
it('echoes the host challenge token from the srcDoc transport readiness probe', () => {
112152
const srcdoc = buildSrcdoc('<main>Preview</main>', {
113153
transportActivationGeneration: 'generation-42',

0 commit comments

Comments
 (0)