Skip to content

Commit 20d3ecc

Browse files
committed
Fix stale viewport update after timeout by passing AbortSignal to handler
When runHandlerWithTimeout times out, the underlying handler continues executing. If a newer commit completes first, the timed-out handler's eventual resolution would call updatePreviewServerSnapshot with stale dimensions, overwriting the correct newer state. Fix: Pass an AbortSignal from runHandlerWithTimeout to the handler. The signal is aborted on timeout, and handleViewportChange checks signal.aborted before applying side effects.
1 parent f0c571c commit 20d3ecc

3 files changed

Lines changed: 26 additions & 9 deletions

File tree

apps/web/src/browser/browserViewportActions.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ describe("browserViewportActions", () => {
2121
height: 700,
2222
});
2323
expect(first).not.toHaveBeenCalled();
24-
expect(second).toHaveBeenCalledWith({ _tag: "freeform", width: 900, height: 700 });
24+
expect(second).toHaveBeenCalledWith(
25+
{ _tag: "freeform", width: 900, height: 700 },
26+
expect.any(AbortSignal),
27+
);
2528

2629
unsubscribeSecond();
2730
await expect(
@@ -75,7 +78,9 @@ describe("browserViewportActions", () => {
7578
vi.useFakeTimers();
7679
try {
7780
const never = new Promise<void>(() => undefined);
78-
const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise<void> => undefined);
81+
const handler = vi.fn(
82+
async (_setting: PreviewViewportSetting, _signal: AbortSignal): Promise<void> => undefined,
83+
);
7984
handler.mockImplementationOnce(() => never).mockResolvedValueOnce(undefined);
8085
const unsubscribe = subscribeBrowserViewportChange("tab-timeout", handler);
8186
const first = commitBrowserViewportChange("tab-timeout", {
@@ -97,7 +102,11 @@ describe("browserViewportActions", () => {
97102
await second;
98103

99104
expect(handler).toHaveBeenCalledTimes(2);
105+
expect(handler.mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal);
106+
expect((handler.mock.calls[0]?.[1] as AbortSignal).aborted).toBe(true);
100107
expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 });
108+
expect(handler.mock.calls[1]?.[1]).toBeInstanceOf(AbortSignal);
109+
expect((handler.mock.calls[1]?.[1] as AbortSignal).aborted).toBe(false);
101110
unsubscribe();
102111
} finally {
103112
vi.useRealTimers();

apps/web/src/browser/browserViewportActions.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { PreviewViewportSetting } from "@t3tools/contracts";
22

3-
type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise<void>;
3+
type BrowserViewportHandler = (
4+
setting: PreviewViewportSetting,
5+
signal: AbortSignal,
6+
) => Promise<void>;
47

58
export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000;
69

@@ -20,14 +23,18 @@ const runHandlerWithTimeout = (
2023
handler: BrowserViewportHandler,
2124
setting: PreviewViewportSetting,
2225
): Promise<void> => {
26+
const controller = new AbortController();
2327
let timeoutId: ReturnType<typeof setTimeout> | undefined;
2428
const timeout = new Promise<never>((_resolve, reject) => {
25-
timeoutId = setTimeout(
26-
() => reject(new BrowserViewportCommitTimeoutError(tabId)),
27-
BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS,
28-
);
29+
timeoutId = setTimeout(() => {
30+
controller.abort();
31+
reject(new BrowserViewportCommitTimeoutError(tabId));
32+
}, BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS);
2933
});
30-
return Promise.race([Promise.resolve().then(() => handler(setting)), timeout]).finally(() => {
34+
return Promise.race([
35+
Promise.resolve().then(() => handler(setting, controller.signal)),
36+
timeout,
37+
]).finally(() => {
3138
if (timeoutId !== undefined) clearTimeout(timeoutId);
3239
});
3340
};

apps/web/src/components/preview/PreviewView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
152152
}, [tabId]);
153153

154154
const handleViewportChange = useCallback(
155-
async (nextViewport: PreviewViewportSetting) => {
155+
async (nextViewport: PreviewViewportSetting, signal: AbortSignal) => {
156156
if (!tabId) return;
157157
const result = await resize({
158158
environmentId: threadRef.environmentId,
@@ -162,6 +162,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
162162
viewport: nextViewport,
163163
},
164164
});
165+
if (signal.aborted) return;
165166
if (result._tag === "Failure") {
166167
const error = squashAtomCommandFailure(result);
167168
toastManager.add({

0 commit comments

Comments
 (0)