authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-11 16:05:28-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log63d42a5e5811e077965a913192bccac1e458774b
tree284b9c7fce153caf90942b56e1f17cdb40a7042e
parent9b43123ba2a90ecaaf2eac1f75b6aebcf181f253

fix(preview): initialize and open browser reliably

Co-authored-by: codex <codex@users.noreply.github.com>

8 files changed, 116 insertions(+), 6 deletions(-)

apps/desktop/src/ipc/methods/preview.test.ts created+28
......@@ -0,0 +1,28 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const fromPartition = vi.fn(() => {
4 throw new Error("Session can only be received when app is ready");
5});
6
7vi.mock("electron", () => ({
8 BrowserWindow: {
9 getAllWindows: vi.fn(() => []),
10 },
11 session: {
12 fromPartition,
13 },
14 webContents: {
15 fromId: vi.fn(() => null),
16 },
17}));
18
19describe("preview IPC methods", () => {
20 beforeEach(() => {
21 fromPartition.mockClear();
22 });
23
24 it("does not access the Electron session while the module loads", async () => {
25 await expect(import("./preview.ts")).resolves.toBeDefined();
26 expect(fromPartition).not.toHaveBeenCalled();
27 });
28});
apps/desktop/src/ipc/methods/preview.ts-1
......@@ -9,7 +9,6 @@ import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.t
99import * as IpcChannels from "../channels.ts";
1010import type { DesktopIpcMethod } from "../DesktopIpc.ts";
1111
12previewViewManager.getBrowserSession();
1312previewViewManager.onStateChange((tabId, state) => {
1413 for (const window of BrowserWindow.getAllWindows()) {
1514 if (!window.isDestroyed()) {
apps/desktop/src/window/DesktopWindow.test.ts+11
......@@ -8,6 +8,17 @@ import * as Ref from "effect/Ref";
88import type * as Electron from "electron";
99import { vi } from "vite-plus/test";
1010
11vi.mock("electron", async (importOriginal) => ({
12 ...(await importOriginal<typeof import("electron")>()),
13 session: {
14 fromPartition: vi.fn(() => ({
15 getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"),
16 setPermissionRequestHandler: vi.fn(),
17 setUserAgent: vi.fn(),
18 })),
19 },
20}));
21
1122import * as DesktopAssets from "../app/DesktopAssets.ts";
1223import * as DesktopConfig from "../app/DesktopConfig.ts";
1324import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
apps/desktop/src/window/DesktopWindow.ts+1
......@@ -171,6 +171,7 @@ const make = Effect.gen(function* () {
171171 const createWindow = Effect.fn("desktop.window.createWindow")(function* (
172172 backendHttpUrl: URL,
173173 ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> {
174 previewViewManager.getBrowserSession();
174175 const applicationUrl = environment.isDevelopment
175176 ? yield* resolveDesktopDevServerUrl(environment)
176177 : backendHttpUrl.href;
apps/web/src/components/preview/PreviewView.tsx+10-4
......@@ -12,6 +12,7 @@ import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateSt
1212
1313import { previewBridge } from "./previewBridge";
1414import { subscribePreviewAction } from "./previewActionBus";
15import { openPreviewSession } from "./openPreviewSession";
1516import { PreviewChromeRow } from "./PreviewChromeRow";
1617import { PreviewEmptyState } from "./PreviewEmptyState";
1718import { PreviewMoreMenu } from "./PreviewMoreMenu";
......@@ -41,6 +42,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
4142 const previewState = usePreviewStateStore((state) =>
4243 selectThreadPreviewState(state.byThreadKey, threadRef),
4344 );
45 const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot);
4446 const rememberUrl = usePreviewStateStore((state) => state.rememberUrl);
4547 const addElementContext = useComposerDraftStore((store) => store.addElementContext);
4648
......@@ -74,15 +76,19 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
7476 await previewBridge.navigate(tabId, next);
7577 rememberUrl(threadRef, next);
7678 } else {
77 const resolved = await api.preview.open({ threadId: threadRef.threadId, url: next });
78 const resolvedUrl = resolved.navStatus._tag === "Idle" ? next : resolved.navStatus.url;
79 rememberUrl(threadRef, resolvedUrl);
79 await openPreviewSession({
80 previewApi: api.preview,
81 threadRef,
82 url: next,
83 applyServerSnapshot,
84 rememberUrl,
85 });
8086 }
8187 } catch {
8288 // Server-side `failed` event renders the unreachable view.
8389 }
8490 },
85 [rememberUrl, tabId, threadRef],
91 [applyServerSnapshot, rememberUrl, tabId, threadRef],
8692 );
8793
8894 const handleRefresh = useCallback(() => {
apps/web/src/components/preview/openPreviewSession.test.ts created+42
......@@ -0,0 +1,42 @@
1import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import { openPreviewSession } from "./openPreviewSession";
5
6const threadRef = {
7 environmentId: "local" as ScopedThreadRef["environmentId"],
8 threadId: "thread-1" as ScopedThreadRef["threadId"],
9};
10
11const snapshot: PreviewSessionSnapshot = {
12 threadId: threadRef.threadId,
13 tabId: "tab-1",
14 navStatus: {
15 _tag: "Loading",
16 url: "https://t3.chat/",
17 title: "",
18 },
19 canGoBack: false,
20 canGoForward: false,
21 updatedAt: "2026-06-11T23:00:00.000Z",
22};
23
24describe("openPreviewSession", () => {
25 it("applies the RPC response without waiting for a preview event", async () => {
26 const open = vi.fn(async () => snapshot);
27 const applyServerSnapshot = vi.fn();
28 const rememberUrl = vi.fn();
29
30 await openPreviewSession({
31 previewApi: { open } as Pick<EnvironmentApi["preview"], "open">,
32 threadRef,
33 url: "t3.chat",
34 applyServerSnapshot,
35 rememberUrl,
36 });
37
38 expect(open).toHaveBeenCalledWith({ threadId: "thread-1", url: "t3.chat" });
39 expect(applyServerSnapshot).toHaveBeenCalledWith(threadRef, snapshot);
40 expect(rememberUrl).toHaveBeenCalledWith(threadRef, "https://t3.chat/");
41 });
42});
apps/web/src/components/preview/openPreviewSession.ts created+23
......@@ -0,0 +1,23 @@
1import type { EnvironmentApi, ScopedThreadRef } from "@t3tools/contracts";
2
3import type { PreviewStateStoreState } from "~/previewStateStore";
4
5interface OpenPreviewSessionInput {
6 previewApi: Pick<EnvironmentApi["preview"], "open">;
7 threadRef: ScopedThreadRef;
8 url: string;
9 applyServerSnapshot: PreviewStateStoreState["applyServerSnapshot"];
10 rememberUrl: PreviewStateStoreState["rememberUrl"];
11}
12
13export async function openPreviewSession(input: OpenPreviewSessionInput): Promise<void> {
14 const snapshot = await input.previewApi.open({
15 threadId: input.threadRef.threadId,
16 url: input.url,
17 });
18 input.applyServerSnapshot(input.threadRef, snapshot);
19 input.rememberUrl(
20 input.threadRef,
21 snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url,
22 );
23}
apps/web/src/previewStateStore.ts+1-1
......@@ -42,7 +42,7 @@ const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
4242 recentlySeenUrls: [] as string[],
4343});
4444
45interface PreviewStateStoreState {
45export interface PreviewStateStoreState {
4646 byThreadKey: Record<string, ThreadPreviewState>;
4747 applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void;
4848 applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void;