Skip to content

Commit 4178477

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/restore-last-workspace
2 parents 1dd12f2 + 42faa84 commit 4178477

13 files changed

Lines changed: 1273 additions & 45 deletions

File tree

apps/web/src/components/FileViewer.tsx

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ import { deployErrorCode } from '../analytics/deploy-error-code';
4040
import { publishErrorCode } from '../analytics/publish-error-code';
4141
import {
4242
reportPreviewIframeMessage,
43+
reportPreviewTransportRecovery,
4344
subscribePreviewIframeMessages,
4445
trackIframeLoad,
46+
type PreviewTransportDocumentState,
47+
type PreviewTransportRecoverySignal,
4548
} from '../observability/iframe-error';
4649
import { notifyExportSucceeded } from './experience-survey-trigger';
4750
import {
@@ -621,6 +624,8 @@ const MAX_CACHED_PREVIEW_CONTENT_WIDTHS = 128;
621624
const PREVIEW_CONTENT_WIDTH_CACHE_VERSION = 2;
622625
const SRC_DOC_ACTIVATION_RECOVERY_TIMEOUT_MS = 1500;
623626
const SRC_DOC_READY_PROBE_TIMEOUT_MS = 1500;
627+
const SRC_DOC_PARSING_RECHECK_MS = 1500;
628+
const SRC_DOC_PARSING_COMPLETION_TIMEOUT_MS = 10_000;
624629
let previewContentMeasurementDocumentEpochSequence = 0;
625630
let previewContentMeasurementHostInstanceSequence = 0;
626631
let previewTransportGenerationSequence = 0;
@@ -10212,6 +10217,10 @@ function HtmlViewer({
1021210217
probeId: string;
1021310218
recoverOnFailure: boolean;
1021410219
} | null>(null);
10220+
const srcDocParsingGraceRef = useRef<{
10221+
generation: string;
10222+
deadline: number;
10223+
} | null>(null);
1021510224
const replayPreviewBridgeModes = useCallback((target: HTMLIFrameElement | null) => {
1021610225
if (!workspaceActive) return;
1021710226
const win = target?.contentWindow;
@@ -10312,7 +10321,11 @@ function HtmlViewer({
1031210321
const [srcDocShellReady, setSrcDocShellReady] = useState(false);
1031310322
const srcDocRecoveryAttemptedGenerationRef = useRef<string | null>(null);
1031410323
const [srcDocRecoveryGeneration, setSrcDocRecoveryGeneration] = useState<string | null>(null);
10315-
const recoverUnacknowledgedSrcDocTransport = useCallback((generation: string) => {
10324+
const recoverUnacknowledgedSrcDocTransport = useCallback((
10325+
generation: string,
10326+
signal: PreviewTransportRecoverySignal,
10327+
documentState?: PreviewTransportDocumentState,
10328+
) => {
1031610329
if (
1031710330
!workspaceActiveRef.current
1031810331
|| expectedSrcDocTransportGenerationRef.current !== generation
@@ -10324,14 +10337,32 @@ function HtmlViewer({
1032410337
if (frame && verified?.frame === frame && verified.generation === generation) return;
1032510338
if (srcDocRecoveryAttemptedGenerationRef.current === generation) return;
1032610339
srcDocRecoveryAttemptedGenerationRef.current = generation;
10340+
const ready = readySrcDocTransportRef.current;
10341+
reportPreviewTransportRecovery({
10342+
surface: 'artifact_preview',
10343+
renderMode: 'srcdoc',
10344+
artifactId: anonymizeArtifactId({ projectId, fileName: file.name }),
10345+
artifactKind:
10346+
handoffArtifactKind
10347+
?? artifactKindToTracking({ fileKind: file.kind ?? null }),
10348+
projectId,
10349+
signal,
10350+
activationAcknowledged:
10351+
ready?.frame === frame && ready.generation === generation,
10352+
documentState,
10353+
viewportWidth: frame?.clientWidth,
10354+
viewportHeight: frame?.clientHeight,
10355+
timeoutMs: signal === 'probe_timeout' ? SRC_DOC_READY_PROBE_TIMEOUT_MS : undefined,
10356+
});
1032710357
pendingSrcDocTransportProbeRef.current = null;
10358+
srcDocParsingGraceRef.current = null;
1032810359
verifiedSrcDocTransportRef.current = null;
1032910360
readySrcDocTransportRef.current = null;
1033010361
activatedSrcDocTransportHtmlRef.current = null;
1033110362
setSrcDocShellReady(false);
1033210363
setSrcDocRecoveryGeneration(generation);
1033310364
setSrcDocTransportResetKey((key) => key + 1);
10334-
}, []);
10365+
}, [file.kind, file.name, handoffArtifactKind, projectId]);
1033510366
const probeSrcDocTransport = useCallback((
1033610367
generation: string,
1033710368
recoverOnFailure: boolean,
@@ -10363,8 +10394,9 @@ function HtmlViewer({
1036310394
};
1036410395
// An eager acknowledgement from the injected head bridge is provisional:
1036510396
// Chromium can still abort the about:srcdoc navigation after it was sent.
10366-
// Only this exact challenge response proves the current browsing context is
10367-
// alive after the navigation had a chance to commit.
10397+
// Only this exact challenge response, together with the body-end witness,
10398+
// proves the current browsing context is alive and fully parsed after the
10399+
// navigation had a chance to commit.
1036810400
verifiedSrcDocTransportRef.current = null;
1036910401
frame.contentWindow?.postMessage({
1037010402
type: 'od:srcdoc-transport-ready-probe',
@@ -10382,7 +10414,9 @@ function HtmlViewer({
1038210414
return;
1038310415
}
1038410416
pendingSrcDocTransportProbeRef.current = null;
10385-
if (pending.recoverOnFailure) recoverUnacknowledgedSrcDocTransport(generation);
10417+
if (pending.recoverOnFailure) {
10418+
recoverUnacknowledgedSrcDocTransport(generation, 'probe_timeout');
10419+
}
1038610420
}, SRC_DOC_READY_PROBE_TIMEOUT_MS);
1038710421
}, [recoverUnacknowledgedSrcDocTransport]);
1038810422
// Sticky once the srcDoc iframe has materialized the real artifact for the
@@ -10443,6 +10477,11 @@ function HtmlViewer({
1044310477
type?: unknown;
1044410478
generation?: unknown;
1044510479
probeId?: unknown;
10480+
bodyComplete?: unknown;
10481+
documentReadyState?: unknown;
10482+
bodyPresent?: unknown;
10483+
bodyChildCount?: unknown;
10484+
documentElementChildCount?: unknown;
1044610485
} | null;
1044710486
const pending = pendingSrcDocTransportProbeRef.current;
1044810487
if (
@@ -10459,21 +10498,96 @@ function HtmlViewer({
1045910498
&& pending.frame === frame
1046010499
&& pending.generation === data.generation
1046110500
&& pending.probeId === data.probeId
10501+
&& data.bodyComplete === true
1046210502
) {
1046310503
pendingSrcDocTransportProbeRef.current = null;
10504+
srcDocParsingGraceRef.current = null;
1046410505
verifiedSrcDocTransportRef.current = { frame, generation: data.generation };
10506+
} else if (
10507+
typeof data.probeId === 'string'
10508+
&& pending
10509+
&& pending.frame === frame
10510+
&& pending.generation === data.generation
10511+
&& pending.probeId === data.probeId
10512+
&& pending.recoverOnFailure
10513+
) {
10514+
if (data.documentReadyState === 'loading') {
10515+
// A healthy parser can remain in `loading` while it waits on an
10516+
// authored parser-blocking resource. The head bridge is responsive,
10517+
// so keep challenging this generation without remounting and risk
10518+
// running earlier authored side effects twice. A bounded grace
10519+
// period still recovers Chromium's permanently-aborted half-document.
10520+
pendingSrcDocTransportProbeRef.current = null;
10521+
const now = Date.now();
10522+
const currentGrace = srcDocParsingGraceRef.current;
10523+
const grace = currentGrace?.generation === data.generation
10524+
? currentGrace
10525+
: {
10526+
generation: data.generation,
10527+
deadline: now + SRC_DOC_PARSING_COMPLETION_TIMEOUT_MS,
10528+
};
10529+
srcDocParsingGraceRef.current = grace;
10530+
if (now >= grace.deadline) {
10531+
recoverUnacknowledgedSrcDocTransport(data.generation, 'body_incomplete', {
10532+
readyState: 'loading',
10533+
bodyPresent: typeof data.bodyPresent === 'boolean' ? data.bodyPresent : undefined,
10534+
bodyChildCount: typeof data.bodyChildCount === 'number' ? data.bodyChildCount : undefined,
10535+
documentElementChildCount:
10536+
typeof data.documentElementChildCount === 'number'
10537+
? data.documentElementChildCount
10538+
: undefined,
10539+
});
10540+
return;
10541+
}
10542+
window.setTimeout(() => {
10543+
if (expectedSrcDocTransportGenerationRef.current !== data.generation) return;
10544+
const verified = verifiedSrcDocTransportRef.current;
10545+
if (verified?.frame === frame && verified.generation === data.generation) return;
10546+
probeSrcDocTransport(data.generation, true);
10547+
}, Math.min(SRC_DOC_PARSING_RECHECK_MS, grace.deadline - now));
10548+
return;
10549+
}
10550+
// The exact challenged head bridge answered, but it could not observe
10551+
// the inert marker placed after all authored body content. This is the
10552+
// characteristic half-document state from an aborted about:srcdoc;
10553+
// recover immediately instead of waiting for the probe timeout.
10554+
recoverUnacknowledgedSrcDocTransport(data.generation, 'body_incomplete', {
10555+
readyState:
10556+
typeof data.documentReadyState === 'string'
10557+
? data.documentReadyState
10558+
: undefined,
10559+
bodyPresent:
10560+
typeof data.bodyPresent === 'boolean'
10561+
? data.bodyPresent
10562+
: undefined,
10563+
bodyChildCount:
10564+
typeof data.bodyChildCount === 'number'
10565+
? data.bodyChildCount
10566+
: undefined,
10567+
documentElementChildCount:
10568+
typeof data.documentElementChildCount === 'number'
10569+
? data.documentElementChildCount
10570+
: undefined,
10571+
});
10572+
return;
1046510573
}
1046610574
if (frame === iframeRef.current) replayPreviewBridgeModes(frame);
1046710575
}
1046810576
window.addEventListener('message', onMessage);
1046910577
return () => window.removeEventListener('message', onMessage);
10470-
}, [replayPreviewBridgeModes, workspaceActive]);
10578+
}, [
10579+
probeSrcDocTransport,
10580+
recoverUnacknowledgedSrcDocTransport,
10581+
replayPreviewBridgeModes,
10582+
workspaceActive,
10583+
]);
1047110584
// React can commit a fresh `srcdoc` attribute while Chromium aborts the
1047210585
// corresponding about:srcdoc navigation. The injected head bridge may run
1047310586
// and announce eagerly before that abort, so a plain generation ACK is not a
1047410587
// committed-document witness. Challenge the current browsing context after
10475-
// the navigation had time to settle and require the exact probe token back;
10476-
// otherwise retry through the small lazy shell automatically. Chromium can
10588+
// the navigation had time to settle and require both the exact probe token
10589+
// and an inert body-end marker; otherwise retry through the small lazy shell
10590+
// automatically. Chromium can
1047710591
// commit that shell even when it aborts a large direct srcDoc navigation,
1047810592
// after which the existing ready handshake safely document.write's the
1047910593
// latest HTML. One fallback per generation avoids a loop when an authored

apps/web/src/observability/iframe-error.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ export interface PreviewIframeReportOptions {
3838
projectId?: string;
3939
}
4040

41+
export type PreviewTransportRecoverySignal =
42+
| 'body_incomplete'
43+
| 'probe_timeout';
44+
45+
export interface PreviewTransportDocumentState {
46+
readyState?: string;
47+
bodyPresent?: boolean;
48+
bodyChildCount?: number;
49+
documentElementChildCount?: number;
50+
}
51+
52+
export interface PreviewTransportRecoveryOptions extends PreviewIframeReportOptions {
53+
signal: PreviewTransportRecoverySignal;
54+
activationAcknowledged: boolean;
55+
documentState?: PreviewTransportDocumentState;
56+
viewportWidth?: number;
57+
viewportHeight?: number;
58+
timeoutMs?: number;
59+
}
60+
4161
interface BufferedPreviewMessage {
4262
source: MessageEventSource | null;
4363
data: PreviewObservabilityMessage;
@@ -144,6 +164,8 @@ export function reportPreviewIframeMessage(
144164
visible_element_count: boundedNumber(message.visible_element_count),
145165
viewport_width: boundedNumber(message.viewport_width),
146166
viewport_height: boundedNumber(message.viewport_height),
167+
blank_observation_count: boundedNumber(message.blank_observation_count),
168+
sample_interval_ms: boundedNumber(message.sample_interval_ms),
147169
});
148170
return true;
149171
}
@@ -170,6 +192,52 @@ export function reportPreviewIframeMessage(
170192
return true;
171193
}
172194

195+
/**
196+
* Report a host-observed blank preview that the iframe-local paint detector
197+
* cannot reliably see. In particular, Chromium may execute the injected head
198+
* bridge and then abort the rest of an about:srcdoc navigation. Recovery
199+
* replaces that half-document before its five-second white-screen timer can
200+
* fire, so the host records the transport witness that caused the remount.
201+
*
202+
* This deliberately reuses client_preview_white_screen: it is operational
203+
* safety telemetry, not a new product analytics event. Only bounded state is
204+
* attached; no authored DOM text or source content leaves the client.
205+
*/
206+
export function reportPreviewTransportRecovery(
207+
options: PreviewTransportRecoveryOptions,
208+
): void {
209+
const transportStage = options.signal === 'body_incomplete'
210+
? 'head_bridge_alive_body_tail_missing'
211+
: options.activationAcknowledged
212+
? 'head_bridge_lost_after_eager_ack'
213+
: 'no_head_bridge_ack';
214+
reportSafetyEvent('client_preview_white_screen', {
215+
surface: options.surface,
216+
render_mode: options.renderMode,
217+
artifact_id: options.artifactId,
218+
artifact_kind: options.artifactKind,
219+
project_id: options.projectId,
220+
reason: 'srcdoc_transport_unverified',
221+
transport_signal: options.signal,
222+
transport_stage: transportStage,
223+
activation_acknowledged: options.activationAcknowledged,
224+
body_complete: options.signal === 'body_incomplete' ? false : undefined,
225+
frame_ready_state: boundedText(options.documentState?.readyState, 32),
226+
frame_body_present: options.documentState?.bodyPresent,
227+
frame_body_child_count: boundedNumber(options.documentState?.bodyChildCount),
228+
frame_document_element_child_count: boundedNumber(
229+
options.documentState?.documentElementChildCount,
230+
),
231+
recovery_attempted: true,
232+
recovery_path: 'lazy_shell_remount',
233+
host_visibility_state:
234+
typeof document === 'undefined' ? undefined : document.visibilityState,
235+
viewport_width: boundedNumber(options.viewportWidth),
236+
viewport_height: boundedNumber(options.viewportHeight),
237+
timeout_ms: boundedNumber(options.timeoutMs),
238+
});
239+
}
240+
173241
function boundedText(value: unknown, limit: number): string | undefined {
174242
if (typeof value !== 'string') return undefined;
175243
const next = value.trim();

0 commit comments

Comments
 (0)