Skip to content

Commit 30c0b0b

Browse files
fix(preview): keep load failures tied to the current page
1 parent e750fdb commit 30c0b0b

5 files changed

Lines changed: 71 additions & 51 deletions

File tree

‎apps/desktop/src/preview/DesktopBrowserHost.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DesktopBrowserCommand,
88
DesktopBrowserEvent,
99
type DesktopBrowserEvent as DesktopBrowserEventType,
10+
type PreviewAutomationLoadError,
1011
} from "@t3tools/contracts";
1112
import * as Context from "effect/Context";
1213
import * as NodePath from "node:path";
@@ -61,11 +62,7 @@ export class DesktopBrowserHost extends Context.Service<
6162
readonly handleCommandLine: (line: string) => Effect.Effect<void>;
6263
readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;
6364
readonly detach: (key: DesktopBrowserTabKey) => void;
64-
/** Tells the server an attached tab's page failed to load; ignored for other tabs. */
65-
readonly loadFailed: (
66-
key: DesktopBrowserTabKey,
67-
failure: { readonly url: string; readonly code: number; readonly description: string },
68-
) => void;
65+
readonly loadFailed: (key: DesktopBrowserTabKey, failure: PreviewAutomationLoadError) => void;
6966
readonly humanStartedDownload: (source: Electron.WebContents) => boolean;
7067
readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;
7168
readonly pointers: Stream.Stream<{

‎apps/desktop/src/preview/Manager.test.ts‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3071,22 +3071,31 @@ describe("PreviewManager", () => {
30713071
},
30723072
},
30733073
);
3074+
const emit = (event: string, ...args: unknown[]) => {
3075+
for (const listener of listeners.get(event) ?? []) listener(...args);
3076+
};
30743077
return {
30753078
wc,
3076-
fail: (url: string) => {
3077-
for (const listener of listeners.get("did-fail-load") ?? []) {
3078-
listener({}, -202, "ERR_CERT_AUTHORITY_INVALID", url, true);
3079-
}
3080-
},
3079+
emit,
3080+
fail: (url: string) =>
3081+
emit("did-fail-load", {}, -202, "ERR_CERT_AUTHORITY_INVALID", url, true),
30813082
};
30823083
};
30833084
const initial = makeGuest(42);
30843085
const replacement = makeGuest(43);
30853086
const manager = yield* PreviewManager.PreviewManager;
3087+
const statuses: PreviewManager.PreviewNavStatus[] = [];
3088+
yield* manager.subscribeStateChanges((_tabId, state) =>
3089+
Effect.sync(() => {
3090+
statuses.push(state.navStatus);
3091+
}),
3092+
);
30863093
yield* manager.createTab("tab_1", { serverTab });
30873094
yield* manager.prepareWebview(initial.wc);
30883095
yield* manager.prepareWebview(replacement.wc);
30893096
initial.fail("https://first.local/");
3097+
initial.emit("did-start-navigation", { isMainFrame: false, isSameDocument: false });
3098+
initial.emit("did-start-loading");
30903099
replacement.fail("https://second.local/");
30913100
const failure = (url: string) => ({
30923101
type: "loadFailed",
@@ -3111,6 +3120,17 @@ describe("PreviewManager", () => {
31113120
initial.fail("https://stale.local/");
31123121
replacement.fail("https://current.local/");
31133122
expect(JSON.parse(yield* Queue.take(lines))).toEqual(failure("https://current.local/"));
3123+
expect(statuses).not.toContainEqual(expect.objectContaining({ url: "https://stale.local/" }));
3124+
3125+
const retried = makeGuest(44);
3126+
yield* manager.prepareWebview(retried.wc);
3127+
retried.fail("https://retry.local/");
3128+
retried.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
3129+
fromId.mockReturnValue(retried.wc);
3130+
yield* manager.registerWebview("tab_1", 44);
3131+
expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "detached", ...serverTab });
3132+
expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "attached", ...serverTab });
3133+
expect(statuses.at(-1)?.kind).toBe("Success");
31143134
}).pipe(Effect.provide(managerLayer()), Effect.scoped),
31153135
);
31163136

‎apps/desktop/src/preview/Manager.ts‎

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
DesktopPreviewScreenshotArtifact,
2626
DesktopPreviewTabDefaults,
2727
PreviewForwardedShortcut,
28+
PreviewAutomationLoadError,
2829
} from "@t3tools/contracts";
2930
import * as HostProcess from "@t3tools/shared/HostProcess";
3031
import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings";
@@ -603,15 +604,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
603604
{ readonly semaphore: Semaphore.Semaphore; users: number }
604605
>();
605606
const tabLifecycleGenerations = new Map<string, number>();
606-
/**
607-
* The main-frame failure of each guest's current load, by webContents id.
608-
* Watched from attach, because a guest starts loading before registerWebview
609-
* installs its tab's listeners.
610-
*/
611-
const guestLoadFailures = new Map<
612-
number,
613-
{ readonly url: string; readonly code: number; readonly description: string }
614-
>();
607+
// A guest can fail its first load before registerWebview installs its tab listeners.
608+
const guestLoadFailures = new Map<number, PreviewAutomationLoadError>();
615609

616610
const attempt = <A>(errorContext: PreviewOperationContext, evaluate: () => A) =>
617611
Effect.try({
@@ -1460,23 +1454,40 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14601454
validatedUrl: string,
14611455
isMainFrame: boolean,
14621456
): void => {
1463-
if (code === -3 || !isMainFrame) return;
1457+
if (code === -3 || !isMainFrame || wc.isDestroyed()) return;
14641458
const url = validatedUrl || wc.getURL();
14651459
runFork(
1466-
update(tabId, {
1467-
navStatus: { kind: "LoadFailed", url, title: wc.getTitle(), code, description },
1468-
}).pipe(
1469-
Effect.andThen(SynchronizedRef.get(tabsRef)),
1470-
Effect.tap((tabs) =>
1471-
Effect.sync(() => {
1472-
const tab = tabs.get(tabId);
1473-
// A replaced guest's failure is not the current page's.
1474-
if (tab?.serverTab && tab.webContentsId === wc.id) {
1475-
browserHost.loadFailed(tab.serverTab, { url, code, description });
1476-
}
1477-
}),
1478-
),
1479-
),
1460+
Effect.gen(function* () {
1461+
const updatedAt = yield* currentIso;
1462+
const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1463+
const current = tabs.get(tabId);
1464+
if (
1465+
!current ||
1466+
current.webContentsId !== wc.id ||
1467+
wc.isDestroyed() ||
1468+
webContents.fromId(wc.id) !== wc
1469+
) {
1470+
return [Option.none<PreviewTabState>(), tabs] as const;
1471+
}
1472+
const state: PreviewTabState = {
1473+
...current,
1474+
navStatus: { kind: "LoadFailed", url, title: wc.getTitle(), code, description },
1475+
updatedAt,
1476+
};
1477+
return [
1478+
Option.some(state),
1479+
replaceMap(tabs, (copy) => copy.set(tabId, state)),
1480+
] as const;
1481+
});
1482+
if (Option.isNone(next)) return;
1483+
const current = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1484+
if (current?.webContentsId !== wc.id || current.navStatus !== next.value.navStatus)
1485+
return;
1486+
if (current.serverTab) {
1487+
browserHost.loadFailed(current.serverTab, { url, code, description });
1488+
}
1489+
yield* emitIfCurrent(tabId, next.value);
1490+
}),
14801491
);
14811492
};
14821493
const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* (
@@ -2004,7 +2015,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
20042015
) {
20052016
yield* rendererHistory.register(wc, { surface: "preview" });
20062017
const webContentsId = wc.id;
2007-
wc.on("did-start-loading", () => guestLoadFailures.delete(webContentsId));
2018+
wc.on("did-start-navigation", (event) => {
2019+
if (event.isMainFrame && !event.isSameDocument) guestLoadFailures.delete(webContentsId);
2020+
});
20082021
wc.on("did-fail-load", (_event, code, description, validatedUrl, isMainFrame) => {
20092022
if (code === -3 || !isMainFrame) return;
20102023
guestLoadFailures.set(webContentsId, {
@@ -2411,15 +2424,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
24112424
const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
24122425
if (beforeAttach?.webContentsId !== wc.id) return;
24132426
const control = yield* ensureControlSession(wc);
2427+
if (beforeAttach.serverTab) yield* listenForAgentPointers;
24142428
const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
24152429
if (afterAttach?.webContentsId !== wc.id) {
24162430
yield* detachControlSession(wc.id);
24172431
return;
24182432
}
24192433
if (afterAttach.serverTab) {
2420-
yield* listenForAgentPointers;
24212434
browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger });
2422-
// A first load can fail before the attach, while no server is listening.
2435+
// CDP cannot recover a failure that happened before it attached.
24232436
const { navStatus } = afterAttach;
24242437
if (navStatus.kind === "LoadFailed") {
24252438
browserHost.loadFailed(afterAttach.serverTab, {

‎apps/server/src/preview/DesktopBrowserChannel.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ export class DesktopBrowserChannel extends Context.Service<
5353
readonly detached: Stream.Stream<DesktopTabKey>;
5454
readonly attached: Stream.Stream<DesktopTabKey>;
5555
readonly isAttached: (key: DesktopTabKey) => Effect.Effect<boolean>;
56-
/** The latest page-load failure the desktop reported for an attached tab. */
5756
readonly loadFailure: (
5857
key: DesktopTabKey,
5958
) => Effect.Effect<Option.Option<PreviewAutomationLoadError>>;
@@ -126,15 +125,13 @@ const make = Effect.gen(function* () {
126125
return PubSub.publish(changes, { key, attached: true });
127126
case "loadFailed":
128127
loadFailures.set(id, {
129-
// Matches the cap on failures the server observes itself.
130128
url: event.url.slice(0, 2048),
131129
code: event.code,
132130
description: event.description,
133131
});
134132
return Effect.void;
135133
case "detached": {
136134
attachedTabs.delete(id);
137-
// The desktop reports the failure again when the tab reattaches.
138135
loadFailures.delete(id);
139136
const queue = inbound.get(id);
140137
return (queue ? Queue.shutdown(queue) : Effect.void).pipe(

‎apps/server/src/preview/ServerBrowser.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,7 @@ interface ServerTab {
308308
colorScheme: PreviewAppearancePreference;
309309
zoomFactor: number;
310310
loading: boolean;
311-
/**
312-
* Why the latest main-frame navigation this server saw failed, null while it
313-
* has not failed, and undefined until the server sees a navigation at all.
314-
*/
315-
loadError: PreviewAutomationLoadError | null | undefined;
311+
loadError: PreviewAutomationLoadError | undefined;
316312
closing: boolean;
317313
recording: Recording | null;
318314
initialNavigation: Promise<void> | null;
@@ -802,7 +798,7 @@ const make = Effect.gen(function* () {
802798
navigationGenerations.set(request, ++tab.navigationGeneration);
803799
clearAbortedNavigation(tab);
804800
tab.loading = true;
805-
tab.loadError = null;
801+
tab.loadError = undefined;
806802
report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" });
807803
});
808804
page.on("load", () => {
@@ -1346,13 +1342,10 @@ const make = Effect.gen(function* () {
13461342
);
13471343
};
13481344

1349-
/**
1350-
* Why the tab shows the browser's error page. A desktop tab's first load can
1351-
* fail before this server connects, so the desktop's own report fills in.
1352-
*/
13531345
const loadErrorOf = async (tab: ServerTab) => {
13541346
if (!tab.page.url().startsWith("chrome-error://")) return undefined;
1355-
if (tab.loadError !== undefined || !tab.desktop) return tab.loadError ?? undefined;
1347+
// A desktop failure from before CDP attached is superseded by any navigation we observe.
1348+
if (tab.loadError || !tab.desktop || tab.navigationGeneration > 0) return tab.loadError;
13561349
const reported = await Effect.runPromise(
13571350
desktopChannel.loadFailure({ threadId: tab.threadId, tabId: tab.tabId }),
13581351
);

0 commit comments

Comments
 (0)