Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
Loading
Loading