From 3b0e8e46b65e973fe5f1833ba670df0b9345cb37 Mon Sep 17 00:00:00 2001 From: pbroom <116581966+pbroom@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:12:10 -0400 Subject: [PATCH 1/2] fix(react): recover after remounting the last preview Remounting the only preview could leave the provider idle even after a new client was created, which caused later edits and refreshes to stop updating. Restart Sandpack when a new bundler registers from idle and keep the provider status running once a client is recreated. Made-with: Cursor --- .../src/contexts/utils/useClient.test.ts | 41 +++++++++++++++++++ .../src/contexts/utils/useClient.ts | 8 +++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/sandpack-react/src/contexts/utils/useClient.test.ts b/sandpack-react/src/contexts/utils/useClient.test.ts index 06e90ac9..88fcf910 100644 --- a/sandpack-react/src/contexts/utils/useClient.test.ts +++ b/sandpack-react/src/contexts/utils/useClient.test.ts @@ -358,6 +358,47 @@ describe(useClient, () => { expect(getAmountOfListener(operations, "client-1")).toBe(3); expect(operations.clients["client-2"]).toBe(undefined); }); + + it("restarts sandpack when a new bundler registers after the last client unmounts", async () => { + mockedLoadSandpackClient.mockImplementation(async (iframeSelector) => { + return createMockClient(iframeSelector as HTMLIFrameElement); + }); + + const { result } = renderHook(() => + useClient({}, getSandpackStateFromProps({})) + ); + const operations = result.current[1]; + + await act(async () => { + await operations.registerBundler( + document.createElement("iframe"), + "client-1" + ); + await operations.runSandpack(); + }); + + expect(result.current[0].status).toBe("running"); + expect(operations.clients["client-1"]).toBeDefined(); + + act(() => { + operations.unregisterBundler("client-1"); + }); + + expect(result.current[0].status).toBe("idle"); + expect(operations.clients["client-1"]).toBe(undefined); + + const restartedOperations = result.current[1]; + await act(async () => { + await restartedOperations.registerBundler( + document.createElement("iframe"), + "client-2" + ); + }); + + expect(result.current[0].status).toBe("running"); + expect(Object.keys(restartedOperations.clients)).toEqual(["client-2"]); + expect(mockedLoadSandpackClient).toHaveBeenCalledTimes(2); + }); }); describe("status", () => { diff --git a/sandpack-react/src/contexts/utils/useClient.ts b/sandpack-react/src/contexts/utils/useClient.ts index 13c70272..799bfb05 100644 --- a/sandpack-react/src/contexts/utils/useClient.ts +++ b/sandpack-react/src/contexts/utils/useClient.ts @@ -237,6 +237,7 @@ export const useClient: UseClient = ( }); clients.current[clientId] = client; + setState((prev) => ({ ...prev, status: "running" })); }, [filesState.environment, filesState.files, state.reactDevTools] ); @@ -336,9 +337,14 @@ export const useClient: UseClient = ( if (state.status === "running") { await createClient(iframe, clientId, clientPropsOverride); + return; + } + + if ((options?.autorun ?? true) && state.status === "idle") { + await runSandpack(); } }, - [createClient, state.status] + [createClient, options?.autorun, runSandpack, state.status] ); const unregisterBundler = (clientId: string): void => { From 4273cab0edbfafce4c25e9c5a278e9e5b6d07f3c Mon Sep 17 00:00:00 2001 From: pbroom <116581966+pbroom@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:36:53 -0400 Subject: [PATCH 2/2] fix(react): preserve updates across preview remounts Queue edits until replacement clients finish initializing and discard stale overlapping client loads. This keeps preview remounts from dropping the next update under React StrictMode. Made-with: Cursor --- .../src/contexts/utils/useClient.test.ts | 186 +++++++++++++++++ .../src/contexts/utils/useClient.ts | 187 ++++++++++++++++-- 2 files changed, 356 insertions(+), 17 deletions(-) diff --git a/sandpack-react/src/contexts/utils/useClient.test.ts b/sandpack-react/src/contexts/utils/useClient.test.ts index 88fcf910..c2e81299 100644 --- a/sandpack-react/src/contexts/utils/useClient.test.ts +++ b/sandpack-react/src/contexts/utils/useClient.test.ts @@ -2,6 +2,7 @@ * @jest-environment jsdom */ +import * as sandpackClient from "@codesandbox/sandpack-client"; import { renderHook, act } from "@testing-library/react"; import { getSandpackStateFromProps } from "../../utils/sandpackUtils"; @@ -9,6 +10,65 @@ import { getSandpackStateFromProps } from "../../utils/sandpackUtils"; import { useClient } from "./useClient"; import type { UseClientOperations } from "./useClient"; +const actualSandpackClient = jest.requireActual("@codesandbox/sandpack-client"); + +jest.mock("@codesandbox/sandpack-client", () => { + const actual = jest.requireActual("@codesandbox/sandpack-client"); + + return { + ...actual, + loadSandpackClient: jest.fn((...args) => + actual.loadSandpackClient(...args) + ), + }; +}); + +const mockedLoadSandpackClient = + sandpackClient.loadSandpackClient as jest.MockedFunction< + typeof sandpackClient.loadSandpackClient + >; + +const createMockClient = (iframe: HTMLIFrameElement) => { + return createMockClientController(iframe).client; +}; + +const createMockClientController = (iframe: HTMLIFrameElement) => { + const listeners: sandpackClient.ListenerFunction[] = []; + + return { + client: { + status: "initializing", + iframe, + listen: jest.fn((listener: sandpackClient.ListenerFunction) => { + listeners.push(listener); + + return jest.fn(() => { + const listenerIndex = listeners.indexOf(listener); + + if (listenerIndex >= 0) { + listeners.splice(listenerIndex, 1); + } + }); + }), + dispatch: jest.fn(), + updateSandbox: jest.fn(), + destroy: jest.fn(), + } as unknown as InstanceType, + emit(message: sandpackClient.SandpackMessage) { + listeners.forEach((listener) => { + listener(message); + }); + }, + }; +}; + +beforeEach(() => { + mockedLoadSandpackClient.mockReset(); + mockedLoadSandpackClient.mockImplementation((...args) => + actualSandpackClient.loadSandpackClient(...args) + ); +}); + const getAmountOfListener = ( instance: UseClientOperations, name = "client-id", @@ -399,6 +459,132 @@ describe(useClient, () => { expect(Object.keys(restartedOperations.clients)).toEqual(["client-2"]); expect(mockedLoadSandpackClient).toHaveBeenCalledTimes(2); }); + + it("replays the latest file update after a client finishes initializing", async () => { + const iframe = document.createElement("iframe"); + const clientController = createMockClientController(iframe); + const props = { + options: { + recompileMode: "immediate" as const, + }, + }; + let filesState = getSandpackStateFromProps(props); + + mockedLoadSandpackClient.mockResolvedValue(clientController.client); + + const { result, rerender } = renderHook(() => + useClient(props, filesState) + ); + const operations = result.current[1]; + + await act(async () => { + await operations.registerBundler(iframe, "client-1"); + await operations.runSandpack(); + }); + + const appPath = + Object.keys(filesState.files).find((path) => path.includes("App")) ?? + Object.keys(filesState.files)[0]; + const existingFile = filesState.files[appPath]; + const nextFiles = { + ...filesState.files, + [appPath]: + typeof existingFile === "string" + ? { code: `${existingFile}\n// queued update` } + : { + ...existingFile, + code: `${existingFile.code}\n// queued update`, + }, + }; + + filesState = { + ...filesState, + files: nextFiles, + shouldUpdatePreview: true, + }; + + await act(async () => { + rerender(); + }); + + expect(clientController.client.updateSandbox).not.toHaveBeenCalled(); + + act(() => { + clientController.emit({ + type: "done", + compilatonError: false, + } as sandpackClient.SandpackMessage); + }); + + expect(clientController.client.updateSandbox).toHaveBeenCalledTimes(1); + expect(clientController.client.updateSandbox).toHaveBeenCalledWith({ + files: nextFiles, + template: filesState.environment, + }); + }); + + it("keeps only the latest client when the same client id reloads before the first load resolves", async () => { + const initialIframe = document.createElement("iframe"); + const staleIframe = document.createElement("iframe"); + const freshIframe = document.createElement("iframe"); + const initialController = createMockClientController(initialIframe); + const staleController = createMockClientController(staleIframe); + const freshController = createMockClientController(freshIframe); + + let releaseStaleLoad!: () => void; + const staleLoadBlocked = new Promise((resolve) => { + releaseStaleLoad = resolve; + }); + + mockedLoadSandpackClient.mockImplementation(async (iframeSelector) => { + const callCount = mockedLoadSandpackClient.mock.calls.length; + + if (callCount === 1) { + return initialController.client; + } + + if (callCount === 2) { + await staleLoadBlocked; + return staleController.client; + } + + return freshController.client; + }); + + const { result } = renderHook(() => + useClient({}, getSandpackStateFromProps({})) + ); + let operations = result.current[1]; + + await act(async () => { + await operations.registerBundler(initialIframe, "client-1"); + await operations.runSandpack(); + }); + + operations = result.current[1]; + let staleRegister!: Promise; + await act(async () => { + staleRegister = operations.registerBundler(staleIframe, "client-1"); + await Promise.resolve(); + }); + + operations = result.current[1]; + let freshRegister!: Promise; + await act(async () => { + freshRegister = operations.registerBundler(freshIframe, "client-1"); + await Promise.resolve(); + }); + + releaseStaleLoad(); + + await act(async () => { + await Promise.all([staleRegister, freshRegister]); + }); + + expect(mockedLoadSandpackClient).toHaveBeenCalledTimes(3); + expect(operations.clients["client-1"]).toBe(freshController.client); + expect(operations.clients["client-1"].iframe).toBe(freshIframe); + }); }); describe("status", () => { diff --git a/sandpack-react/src/contexts/utils/useClient.ts b/sandpack-react/src/contexts/utils/useClient.ts index 799bfb05..cbf59819 100644 --- a/sandpack-react/src/contexts/utils/useClient.ts +++ b/sandpack-react/src/contexts/utils/useClient.ts @@ -36,6 +36,11 @@ interface SandpackConfigState { status: SandpackStatus; } +interface PendingClientUpdate { + files: FilesState["files"]; + template: FilesState["environment"]; +} + export interface ClientPropsOverride { startRoute?: string; } @@ -116,9 +121,121 @@ export const useClient: UseClient = ( >({ global: {} }); const debounceHook = useRef(); const prevEnvironment = useRef(filesState.environment); + const pendingClientUpdates = useRef>({}); + const lastSentClientUpdates = useRef>({}); + const unsubscribePendingClientUpdates = useRef< + Record + >({}); + const clientLoadVersion = useRef>({}); const asyncSandpackId = useAsyncSandpackId(filesState.files); + const clearPendingClientUpdateListener = useCallback( + (clientId: string): void => { + if ( + typeof unsubscribePendingClientUpdates.current[clientId] !== "function" + ) { + return; + } + + unsubscribePendingClientUpdates.current[clientId](); + delete unsubscribePendingClientUpdates.current[clientId]; + }, + [] + ); + + const clearPendingClientUpdate = useCallback( + (clientId: string): void => { + clearPendingClientUpdateListener(clientId); + delete pendingClientUpdates.current[clientId]; + }, + [clearPendingClientUpdateListener] + ); + + const applyClientUpdate = useCallback( + ( + clientId: string, + client: SandpackClientType, + update: PendingClientUpdate + ): void => { + clearPendingClientUpdate(clientId); + lastSentClientUpdates.current[clientId] = update; + client.updateSandbox(update); + }, + [clearPendingClientUpdate] + ); + + const ensurePendingClientUpdateListener = useCallback( + (clientId: string, client: SandpackClientType): void => { + const pendingUpdate = pendingClientUpdates.current[clientId]; + const lastSentUpdate = lastSentClientUpdates.current[clientId]; + + if ( + pendingUpdate && + lastSentUpdate && + pendingUpdate.files === lastSentUpdate.files && + pendingUpdate.template === lastSentUpdate.template + ) { + clearPendingClientUpdate(clientId); + return; + } + + if ( + typeof unsubscribePendingClientUpdates.current[clientId] === "function" + ) { + return; + } + + unsubscribePendingClientUpdates.current[clientId] = client.listen( + (message: SandpackMessage) => { + if (message.type !== "done" || message.compilatonError) { + return; + } + + const pendingUpdate = pendingClientUpdates.current[clientId]; + + if (!pendingUpdate) { + clearPendingClientUpdateListener(clientId); + return; + } + + applyClientUpdate(clientId, client, pendingUpdate); + } + ) as UnsubscribeFunction; + }, + [ + applyClientUpdate, + clearPendingClientUpdate, + clearPendingClientUpdateListener, + ] + ); + + const queuePendingClientUpdate = useCallback( + (clientId: string, client: SandpackClientType): void => { + const nextUpdate = { + files: filesState.files, + template: filesState.environment, + }; + const lastSentUpdate = lastSentClientUpdates.current[clientId]; + + if ( + lastSentUpdate && + lastSentUpdate.files === nextUpdate.files && + lastSentUpdate.template === nextUpdate.template + ) { + return; + } + + pendingClientUpdates.current[clientId] = nextUpdate; + ensurePendingClientUpdateListener(clientId, client); + }, + [ + ensurePendingClientUpdateListener, + filesState.environment, + filesState.files, + ] + ); + /** * Callbacks */ @@ -128,9 +245,13 @@ export const useClient: UseClient = ( clientId: string, clientPropsOverride?: ClientPropsOverride ): Promise => { + const nextLoadVersion = (clientLoadVersion.current[clientId] ?? 0) + 1; + clientLoadVersion.current[clientId] = nextLoadVersion; + // Clean up any existing clients that // have been created with the given id if (clients.current[clientId]) { + clearPendingClientUpdateListener(clientId); clients.current[clientId].destroy(); } @@ -199,6 +320,13 @@ export const useClient: UseClient = ( } ); + if (clientLoadVersion.current[clientId] !== nextLoadVersion) { + client.destroy(); + client.iframe.contentWindow?.location.replace("about:blank"); + client.iframe.removeAttribute("src"); + return; + } + if (typeof unsubscribe.current !== "function") { unsubscribe.current = client.listen(handleMessage); } @@ -236,10 +364,24 @@ export const useClient: UseClient = ( */ }); + lastSentClientUpdates.current[clientId] = { + files: filesState.files, + template: filesState.environment, + }; clients.current[clientId] = client; setState((prev) => ({ ...prev, status: "running" })); + + if (pendingClientUpdates.current[clientId]) { + ensurePendingClientUpdateListener(clientId, client); + } }, - [filesState.environment, filesState.files, state.reactDevTools] + [ + clearPendingClientUpdateListener, + ensurePendingClientUpdateListener, + filesState.environment, + filesState.files, + state.reactDevTools, + ] ); const unregisterAllClients = useCallback((): void => { @@ -348,28 +490,31 @@ export const useClient: UseClient = ( ); const unregisterBundler = (clientId: string): void => { + clientLoadVersion.current[clientId] = + (clientLoadVersion.current[clientId] ?? 0) + 1; const client = clients.current[clientId]; if (client) { client.destroy(); client.iframe.contentWindow?.location.replace("about:blank"); client.iframe.removeAttribute("src"); delete clients.current[clientId]; - } else { - delete registeredIframes.current[clientId]; } + delete registeredIframes.current[clientId]; + clearPendingClientUpdate(clientId); + delete lastSentClientUpdates.current[clientId]; + if (timeoutHook.current) { clearTimeout(timeoutHook.current); } const unsubscribeQueuedClients = Object.values( unsubscribeClientListeners.current[clientId] ?? {} - ); + ) as UnsubscribeFunction[]; // Unsubscribing all listener registered - unsubscribeQueuedClients.forEach((listenerOfClient) => { - const listenerFunctions = Object.values(listenerOfClient); - listenerFunctions.forEach((unsubscribe) => unsubscribe()); + unsubscribeQueuedClients.forEach((unsubscribe) => { + unsubscribe(); }); // Keep running if it still have clients @@ -531,15 +676,18 @@ export const useClient: UseClient = ( } if (recompileMode === "immediate") { - Object.values(clients.current).forEach((client) => { + Object.entries(clients.current).forEach(([clientId, client]) => { + const nextUpdate = { + files: filesState.files, + template: filesState.environment, + }; /** * Avoid concurrency */ if (client.status === "done") { - client.updateSandbox({ - files: filesState.files, - template: filesState.environment, - }); + applyClientUpdate(clientId, client, nextUpdate); + } else { + queuePendingClientUpdate(clientId, client); } }); } @@ -549,15 +697,18 @@ export const useClient: UseClient = ( window.clearTimeout(debounceHook.current); debounceHook.current = window.setTimeout(() => { - Object.values(clients.current).forEach((client) => { + Object.entries(clients.current).forEach(([clientId, client]) => { + const nextUpdate = { + files: filesState.files, + template: filesState.environment, + }; /** * Avoid concurrency */ if (client.status === "done") { - client.updateSandbox({ - files: filesState.files, - template: filesState.environment, - }); + applyClientUpdate(clientId, client, nextUpdate); + } else { + queuePendingClientUpdate(clientId, client); } }); }, recompileDelay); @@ -574,6 +725,8 @@ export const useClient: UseClient = ( recompileDelay, recompileMode, registerBundler, + applyClientUpdate, + queuePendingClientUpdate, state.status, ] );