Open History to see the last commit
1import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2import type { PreviewViewportSetting, ScopedThreadRef } from "@t3tools/contracts";
3import * as Schema from "effect/Schema";
4
5import { toastManager } from "~/components/ui/toast";
6import { updatePreviewServerSnapshot } from "~/previewStateStore";
7import { appAtomRegistry } from "~/rpc/atomRegistry";
8import { previewEnvironment } from "~/state/preview";
9
10/** One browser tab as both the server (thread + tab id) and this window's runtime know it. */
11export interface BrowserViewportTarget {
12 readonly threadRef: ScopedThreadRef;
13 readonly tabId: string;
14 readonly runtimeTabId: string;
15}
16
17const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000;
18
19class BrowserViewportCommitTimeoutError extends Schema.TaggedError<BrowserViewportCommitTimeoutError>()(
20 "BrowserViewportCommitTimeoutError",
21 { tabId: Schema.String },
22) {
23 override get message(): string {
24 return `Timed out committing the browser viewport for tab ${this.tabId}`;
25 }
26}
27
28const commitTails = new Map<string, Promise<void>>();
29
30/** Serializes UI and automation mutations per tab so rollback cannot overtake a newer resize. */
31const queueBrowserViewportMutation = <A>(
32 tabId: string,
33 start: () => Promise<A>,
34): {
35 readonly started: Promise<{ readonly operation: Promise<A> }>;
36 readonly execution: Promise<A>;
37} => {
38 const previous = commitTails.get(tabId) ?? Promise.resolve();
39 const started = previous
40 .catch(() => undefined)
41 .then(() => ({
42 operation: Promise.resolve().then(start),
43 }));
44 const execution = started.then(({ operation }) => operation);
45 const tail = execution.then(() => undefined);
46 commitTails.set(tabId, tail);
47 const clear = () => {
48 if (commitTails.get(tabId) === tail) commitTails.delete(tabId);
49 };
50 void tail.then(clear, clear);
51 return { started, execution };
52};
53
54const runHandlerWithTimeout = (tabId: string, operation: Promise<void>): Promise<void> => {
55 let timeoutId: ReturnType<typeof setTimeout> | undefined;
56 const timeout = new Promise<never>((_resolve, reject) => {
57 timeoutId = setTimeout(
58 () => reject(new BrowserViewportCommitTimeoutError({ tabId })),
59 BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS,
60 );
61 });
62 return Promise.race([operation, timeout]).finally(() => {
63 if (timeoutId !== undefined) clearTimeout(timeoutId);
64 });
65};
66
67async function resizeBrowserViewport(
68 target: BrowserViewportTarget,
69 viewport: PreviewViewportSetting,
70): Promise<void> {
71 const result = await runAtomCommand(
72 appAtomRegistry,
73 previewEnvironment.resize,
74 {
75 environmentId: target.threadRef.environmentId,
76 input: { threadId: target.threadRef.threadId, tabId: target.tabId, viewport },
77 },
78 { label: "preview viewport resize" },
79 );
80 if (result._tag === "Failure") {
81 const error = squashAtomCommandFailure(result);
82 toastManager.add({
83 type: "error",
84 title: "Unable to resize browser viewport",
85 description: error instanceof Error ? error.message : "An error occurred.",
86 });
87 throw error;
88 }
89 updatePreviewServerSnapshot(target.threadRef, result.value);
90}
91
92/**
93 * Runs `operation` after every earlier commit for the runtime tab. The
94 * caller-facing timeout starts only once it reaches the front of the queue,
95 * while the queue itself waits for the real operation.
96 */
97export function queueBrowserViewportCommit(
98 runtimeTabId: string,
99 operation: () => Promise<void>,
100): Promise<void> {
101 const { started } = queueBrowserViewportMutation(runtimeTabId, operation);
102 return started.then(({ operation: running }) => runHandlerWithTimeout(runtimeTabId, running));
103}
104
105export function commitBrowserViewportChange(
106 target: BrowserViewportTarget,
107 setting: PreviewViewportSetting,
108): Promise<void> {
109 return queueBrowserViewportCommit(target.runtimeTabId, () =>
110 resizeBrowserViewport(target, setting),
111 );
112}