Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
81 changes: 73 additions & 8 deletions apps/web/src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ import { deployErrorCode } from '../analytics/deploy-error-code';
import { publishErrorCode } from '../analytics/publish-error-code';
import {
reportPreviewIframeMessage,
reportPreviewTransportRecovery,
subscribePreviewIframeMessages,
trackIframeLoad,
type PreviewTransportDocumentState,
type PreviewTransportRecoverySignal,
} from '../observability/iframe-error';
import { notifyExportSucceeded } from './experience-survey-trigger';
import {
Expand Down Expand Up @@ -10312,7 +10315,11 @@ function HtmlViewer({
const [srcDocShellReady, setSrcDocShellReady] = useState(false);
const srcDocRecoveryAttemptedGenerationRef = useRef<string | null>(null);
const [srcDocRecoveryGeneration, setSrcDocRecoveryGeneration] = useState<string | null>(null);
const recoverUnacknowledgedSrcDocTransport = useCallback((generation: string) => {
const recoverUnacknowledgedSrcDocTransport = useCallback((
generation: string,
signal: PreviewTransportRecoverySignal,
documentState?: PreviewTransportDocumentState,
) => {
if (
!workspaceActiveRef.current
|| expectedSrcDocTransportGenerationRef.current !== generation
Expand All @@ -10324,14 +10331,31 @@ function HtmlViewer({
if (frame && verified?.frame === frame && verified.generation === generation) return;
if (srcDocRecoveryAttemptedGenerationRef.current === generation) return;
srcDocRecoveryAttemptedGenerationRef.current = generation;
const ready = readySrcDocTransportRef.current;
reportPreviewTransportRecovery({
surface: 'artifact_preview',
renderMode: 'srcdoc',
artifactId: anonymizeArtifactId({ projectId, fileName: file.name }),
artifactKind:
handoffArtifactKind
?? artifactKindToTracking({ fileKind: file.kind ?? null }),
projectId,
signal,
activationAcknowledged:
ready?.frame === frame && ready.generation === generation,
documentState,
viewportWidth: frame?.clientWidth,
viewportHeight: frame?.clientHeight,
timeoutMs: signal === 'probe_timeout' ? SRC_DOC_READY_PROBE_TIMEOUT_MS : undefined,
});
pendingSrcDocTransportProbeRef.current = null;
verifiedSrcDocTransportRef.current = null;
readySrcDocTransportRef.current = null;
activatedSrcDocTransportHtmlRef.current = null;
setSrcDocShellReady(false);
setSrcDocRecoveryGeneration(generation);
setSrcDocTransportResetKey((key) => key + 1);
}, []);
}, [file.kind, file.name, handoffArtifactKind, projectId]);
const probeSrcDocTransport = useCallback((
generation: string,
recoverOnFailure: boolean,
Expand Down Expand Up @@ -10363,8 +10387,9 @@ function HtmlViewer({
};
// An eager acknowledgement from the injected head bridge is provisional:
// Chromium can still abort the about:srcdoc navigation after it was sent.
// Only this exact challenge response proves the current browsing context is
// alive after the navigation had a chance to commit.
// Only this exact challenge response, together with the body-end witness,
// proves the current browsing context is alive and fully parsed after the
// navigation had a chance to commit.
verifiedSrcDocTransportRef.current = null;
frame.contentWindow?.postMessage({
type: 'od:srcdoc-transport-ready-probe',
Expand All @@ -10382,7 +10407,9 @@ function HtmlViewer({
return;
}
pendingSrcDocTransportProbeRef.current = null;
if (pending.recoverOnFailure) recoverUnacknowledgedSrcDocTransport(generation);
if (pending.recoverOnFailure) {
recoverUnacknowledgedSrcDocTransport(generation, 'probe_timeout');
}
}, SRC_DOC_READY_PROBE_TIMEOUT_MS);
}, [recoverUnacknowledgedSrcDocTransport]);
// Sticky once the srcDoc iframe has materialized the real artifact for the
Expand Down Expand Up @@ -10443,6 +10470,11 @@ function HtmlViewer({
type?: unknown;
generation?: unknown;
probeId?: unknown;
bodyComplete?: unknown;
documentReadyState?: unknown;
bodyPresent?: unknown;
bodyChildCount?: unknown;
documentElementChildCount?: unknown;
} | null;
const pending = pendingSrcDocTransportProbeRef.current;
if (
Expand All @@ -10459,21 +10491,54 @@ function HtmlViewer({
&& pending.frame === frame
&& pending.generation === data.generation
&& pending.probeId === data.probeId
&& data.bodyComplete === true
) {
pendingSrcDocTransportProbeRef.current = null;
verifiedSrcDocTransportRef.current = { frame, generation: data.generation };
} else if (
typeof data.probeId === 'string'
&& pending
&& pending.frame === frame
&& pending.generation === data.generation
&& pending.probeId === data.probeId
&& pending.recoverOnFailure
) {
// The exact challenged head bridge answered, but it could not observe
// the inert marker placed after all authored body content. This is the
// characteristic half-document state from an aborted about:srcdoc;
// recover immediately instead of waiting for the probe timeout.
recoverUnacknowledgedSrcDocTransport(data.generation, 'body_incomplete', {
Comment thread
lefarcen marked this conversation as resolved.
readyState:
typeof data.documentReadyState === 'string'
? data.documentReadyState
: undefined,
bodyPresent:
typeof data.bodyPresent === 'boolean'
? data.bodyPresent
: undefined,
bodyChildCount:
typeof data.bodyChildCount === 'number'
? data.bodyChildCount
: undefined,
documentElementChildCount:
typeof data.documentElementChildCount === 'number'
? data.documentElementChildCount
: undefined,
});
return;
}
if (frame === iframeRef.current) replayPreviewBridgeModes(frame);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [replayPreviewBridgeModes, workspaceActive]);
}, [recoverUnacknowledgedSrcDocTransport, replayPreviewBridgeModes, workspaceActive]);
// React can commit a fresh `srcdoc` attribute while Chromium aborts the
// corresponding about:srcdoc navigation. The injected head bridge may run
// and announce eagerly before that abort, so a plain generation ACK is not a
// committed-document witness. Challenge the current browsing context after
// the navigation had time to settle and require the exact probe token back;
// otherwise retry through the small lazy shell automatically. Chromium can
// the navigation had time to settle and require both the exact probe token
// and an inert body-end marker; otherwise retry through the small lazy shell
// automatically. Chromium can
// commit that shell even when it aborts a large direct srcDoc navigation,
// after which the existing ready handshake safely document.write's the
// latest HTML. One fallback per generation avoids a loop when an authored
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/observability/iframe-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ export interface PreviewIframeReportOptions {
projectId?: string;
}

export type PreviewTransportRecoverySignal =
| 'body_incomplete'
| 'probe_timeout';

export interface PreviewTransportDocumentState {
readyState?: string;
bodyPresent?: boolean;
bodyChildCount?: number;
documentElementChildCount?: number;
}

export interface PreviewTransportRecoveryOptions extends PreviewIframeReportOptions {
signal: PreviewTransportRecoverySignal;
activationAcknowledged: boolean;
documentState?: PreviewTransportDocumentState;
viewportWidth?: number;
viewportHeight?: number;
timeoutMs?: number;
}

interface BufferedPreviewMessage {
source: MessageEventSource | null;
data: PreviewObservabilityMessage;
Expand Down Expand Up @@ -170,6 +190,52 @@ export function reportPreviewIframeMessage(
return true;
}

/**
* Report a host-observed blank preview that the iframe-local paint detector
* cannot reliably see. In particular, Chromium may execute the injected head
* bridge and then abort the rest of an about:srcdoc navigation. Recovery
* replaces that half-document before its five-second white-screen timer can
* fire, so the host records the transport witness that caused the remount.
*
* This deliberately reuses client_preview_white_screen: it is operational
* safety telemetry, not a new product analytics event. Only bounded state is
* attached; no authored DOM text or source content leaves the client.
*/
export function reportPreviewTransportRecovery(
options: PreviewTransportRecoveryOptions,
): void {
const transportStage = options.signal === 'body_incomplete'
? 'head_bridge_alive_body_tail_missing'
: options.activationAcknowledged
? 'head_bridge_lost_after_eager_ack'
: 'no_head_bridge_ack';
reportSafetyEvent('client_preview_white_screen', {
surface: options.surface,
render_mode: options.renderMode,
artifact_id: options.artifactId,
artifact_kind: options.artifactKind,
project_id: options.projectId,
reason: 'srcdoc_transport_unverified',
transport_signal: options.signal,
transport_stage: transportStage,
activation_acknowledged: options.activationAcknowledged,
body_complete: options.signal === 'body_incomplete' ? false : undefined,
frame_ready_state: boundedText(options.documentState?.readyState, 32),
frame_body_present: options.documentState?.bodyPresent,
frame_body_child_count: boundedNumber(options.documentState?.bodyChildCount),
frame_document_element_child_count: boundedNumber(
options.documentState?.documentElementChildCount,
),
recovery_attempted: true,
recovery_path: 'lazy_shell_remount',
host_visibility_state:
typeof document === 'undefined' ? undefined : document.visibilityState,
viewport_width: boundedNumber(options.viewportWidth),
viewport_height: boundedNumber(options.viewportHeight),
timeout_ms: boundedNumber(options.timeoutMs),
});
}

function boundedText(value: unknown, limit: number): string | undefined {
if (typeof value !== 'string') return undefined;
const next = value.trim();
Expand Down
67 changes: 63 additions & 4 deletions apps/web/src/runtime/deck-thumbnail-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
//
// It is intentionally pure and synchronous (DOMParser only) so it memoizes on
// the source string and is unit-testable. Decks it cannot faithfully render
// statically (external layout CSS, viewport-unit slides, script-built content)
// report `renderable: false` with a reason, and the caller keeps the old
// iframe thumbnail for that deck.
// statically (external layout CSS or script-built content) report
// `renderable: false` with a reason, and the caller keeps the old iframe
// thumbnail for that deck.

import DOMPurify from 'dompurify';

Expand Down Expand Up @@ -143,11 +143,24 @@ export function parseDeckThumbnails(html: string, baseHref?: string): ParsedDeck
// and each `var(--slide-bg)` resolves to transparent, painting nothing over
// the near-black thumbnail host (black thumbnails). Comments are inert, so
// removing them changes only which selectors the rewrites can see.
const rawStyle = stripCssComments(
const styleWithImports = stripCssComments(
Array.from(doc.querySelectorAll('style'))
.map((el) => el.textContent || '')
.join('\n'),
);
if (!styleWithImports.trim()) return unrenderable('no-styles');

// Constructable stylesheets ignore @import, so leaving an approved webfont
// import in styleText silently changes typography and line wrapping in the
// shadow thumbnail. Lift approved font imports into the host alongside
// <link> fonts; any other import may contain layout CSS we cannot reproduce
// safely, so use the isolated iframe fallback instead.
const imported = extractStylesheetImports(styleWithImports);
if (imported.unsafe) return unrenderable('external-stylesheet');
for (const href of imported.fontLinks) {
if (!fontLinks.includes(href)) fontLinks.push(href);
}
const rawStyle = imported.css;
if (!rawStyle.trim()) return unrenderable('no-styles');

const designSize = resolveDesignSize(doc, rawStyle);
Expand Down Expand Up @@ -311,6 +324,52 @@ function stripCssComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, '');
}

interface StylesheetImportExtraction {
css: string;
fontLinks: string[];
unsafe: boolean;
}

// CSS imports may contain semicolons inside a quoted URL (Google Fonts uses
// this for axis tuples), so the URL alternatives consume their quoted or
// parenthesized payload before matching the statement terminator.
const CSS_IMPORT_RE = /@import\s+(?:url\(\s*(?:"([^"]*)"|'([^']*)'|([^'"\s][^)]*))\s*\)|"([^"]*)"|'([^']*)')\s*[^;]*;/gi;
Comment thread
lefarcen marked this conversation as resolved.
Outdated

function extractStylesheetImports(css: string): StylesheetImportExtraction {
const fontLinks: string[] = [];
let unsafe = false;
const stripped = css.replace(
CSS_IMPORT_RE,
(
_statement,
doubleQuotedUrl?: string,
singleQuotedUrl?: string,
bareUrl?: string,
doubleQuotedHref?: string,
singleQuotedHref?: string,
) => {
const href = [
doubleQuotedUrl,
singleQuotedUrl,
bareUrl,
doubleQuotedHref,
singleQuotedHref,
].find((value): value is string => typeof value === 'string')?.trim() ?? '';
if (!href || !isApprovedFontHref(href)) {
unsafe = true;
return '';
}
if (!fontLinks.includes(href)) fontLinks.push(href);
return '';
},
);
// A malformed or unsupported @import form must not leak into the app-origin
// shadow stylesheet. Falling back is safer and more faithful than pretending
// the imported layout rules do not exist.
if (/@import\b/i.test(stripped)) unsafe = true;
return { css: stripped, fontLinks, unsafe };
}

// Rewrite `:root`/`html` to `:host`, so document-level variables inherit into
// the reconstructed slide. Body rules belong on the design canvas itself: host
// page styles intentionally own the shadow host's dark thumbnail frame and win
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/runtime/srcdoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,12 @@ function injectSrcdocTransportActivationBridge(doc: string, generation: string):
if (window.parent && window.parent !== window) {
var message = { type: 'od:srcdoc-transport-activated', generation: generation };
if (typeof probeId === 'string' && probeId) message.probeId = probeId;
var bodyComplete = !!document.querySelector('template[data-od-srcdoc-transport-body-complete]');
Comment thread
lefarcen marked this conversation as resolved.
Outdated
message.bodyComplete = bodyComplete;
message.documentReadyState = document.readyState;
message.bodyPresent = !!document.body;
message.bodyChildCount = document.body ? document.body.children.length : 0;
message.documentElementChildCount = document.documentElement ? document.documentElement.children.length : 0;
window.parent.postMessage(message, '*');
}
} catch (_) { /* sandboxed parent */ }
Expand All @@ -567,7 +573,13 @@ function injectSrcdocTransportActivationBridge(doc: string, generation: string):
// missing-ACK recovery indistinguishable from a genuinely aborted
// `about:srcdoc` navigation. Placing the bridge first also runs it before an
// authored meta CSP can disable later inline scripts.
return injectAfterHeadOpen(doc, script);
// The inert tail marker distinguishes a fully parsed artifact from the
// half-document Chromium can leave behind after aborting about:srcdoc. The
// head bridge remains alive in that state and can answer a host probe, but
// it cannot see a marker the parser never reached. A template is used so the
// witness executes no script and remains compatible with authored CSPs.
const bodyCompleteMarker = '<template data-od-srcdoc-transport-body-complete></template>';
return injectBeforeBodyEnd(injectAfterHeadOpen(doc, script), bodyCompleteMarker);
}

function injectSnapshotBridge(doc: string): string {
Expand Down
Loading
Loading