From 04fd91f66bb2433285a98eef5d62a3522dc56318 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 18:45:20 -0700 Subject: [PATCH] fix(desktop): stop local-runtime handoff reload loop (rapid "Starting Fusion" flashing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the runtime starts, DesktopLaunchGate calls applyServerBaseUrl() which reloads the page with ?serverBaseUrl=… so the shell-host bootstrap can route API calls to the embedded server. But main.tsx runs bootstrapShellHostContext() at module load — BEFORE the gate's effect — and it strips every shell query param from the URL via history.replaceState. The gate then checked window.location.search for serverBaseUrl (after an await), always missed it, and re-ran the handoff → window.location.replace → reload → strip → an infinite reload loop that renders as rapid "Starting local Fusion runtime…" flashing that never connects. This was latent until the split-brain fix (78f0bc31) let the runtime actually start and reach the handoff. Fix: detect the completed handoff from the CACHED shell-host context (getShellHostContext().serverUrl), which the bootstrap preserves, instead of the stripped URL (URL param kept only as a fallback). Adds a DesktopLaunchGate regression test: with serverUrl present in the cached context but stripped from the URL, the gate renders children and does NOT reload; on first load it performs the handoff exactly once. Verified the test fails against the pre-fix gate. Co-Authored-By: Claude Opus 4.8 --- .../app/components/DesktopLaunchGate.tsx | 21 ++++- .../__tests__/DesktopLaunchGate.test.tsx | 93 +++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx diff --git a/packages/dashboard/app/components/DesktopLaunchGate.tsx b/packages/dashboard/app/components/DesktopLaunchGate.tsx index 5d6d8faa88..c5890b6285 100644 --- a/packages/dashboard/app/components/DesktopLaunchGate.tsx +++ b/packages/dashboard/app/components/DesktopLaunchGate.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type PropsWithChildren } from "react"; import { useTranslation } from "react-i18next"; import type { ShellConnectionState } from "../types/native-shell"; +import { getShellHostContext } from "../shell-host"; import "./DesktopLaunchGate.css"; type Phase = @@ -92,9 +93,23 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) { // current page URL; if remote, App handles the redirect to the // active profile already. if (state.desktopMode === "local") { - const params = new URLSearchParams(window.location.search); - if (params.has("serverBaseUrl")) { - setPhase({ kind: "ready", serverBaseUrl: params.get("serverBaseUrl") ?? undefined }); + /* + * FNXC:DesktopLaunchGate 2026-07-03-01:05: + * Detect a completed local handoff via the CACHED shell-host context, NOT the raw URL. + * applyServerBaseUrl() reloads the page with ?serverBaseUrl=…, but main.tsx calls + * bootstrapShellHostContext() at module load (before this effect runs), which STRIPS every + * shell query param from the URL via history.replaceState. So reading window.location.search + * here always misses serverBaseUrl → we would re-run the handoff → window.location.replace → + * reload → strip → an INFINITE reload loop, seen as rapid "Starting local Fusion runtime…" + * flashing that never connects. The bootstrap preserves the captured value in + * getShellHostContext().serverUrl, so read that (fall back to any not-yet-stripped URL param). + */ + const shellHost = getShellHostContext(); + const handoffBaseUrl = shellHost.kind === "desktop-shell" ? shellHost.serverUrl : undefined; + const urlParamBaseUrl = new URLSearchParams(window.location.search).get("serverBaseUrl") ?? undefined; + const existingBaseUrl = handoffBaseUrl ?? urlParamBaseUrl; + if (existingBaseUrl) { + setPhase({ kind: "ready", serverBaseUrl: existingBaseUrl }); return; } setPhase({ kind: "starting-local", message: t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") }); diff --git a/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx b/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx new file mode 100644 index 0000000000..79d959584c --- /dev/null +++ b/packages/dashboard/app/components/__tests__/DesktopLaunchGate.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// t() returns the provided fallback so we assert on stable English text. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }), +})); + +const getShellHostContext = vi.fn(); +vi.mock("../../shell-host", () => ({ getShellHostContext: () => getShellHostContext() })); + +import { DesktopLaunchGate } from "../DesktopLaunchGate"; + +type LocationStub = { href: string; search: string; replace: ReturnType; reload: ReturnType }; + +function stubLocation(search: string): LocationStub { + const loc: LocationStub = { + href: `file:///C:/app/index.html${search}`, + search, + replace: vi.fn(), + reload: vi.fn(), + }; + Object.defineProperty(window, "location", { value: loc, writable: true, configurable: true }); + return loc; +} + +function stubShell(state: unknown) { + const shell = { + getState: vi.fn(async () => state), + setDesktopMode: vi.fn(async () => state), + onResetDesktopModeRequest: vi.fn(() => () => undefined), + resetDesktopMode: vi.fn(async () => undefined), + }; + (window as unknown as { fusionShell: unknown }).fusionShell = shell; + return shell; +} + +describe("DesktopLaunchGate — local handoff", () => { + beforeEach(() => { + getShellHostContext.mockReset(); + }); + afterEach(() => { + delete (window as unknown as { fusionShell?: unknown }).fusionShell; + }); + + /* + * Regression: after applyServerBaseUrl() reloads with ?serverBaseUrl=…, main.tsx's + * bootstrapShellHostContext() strips that param from the URL before this gate runs. The gate + * MUST recognize the completed handoff from the cached shell-host context (serverUrl), not the + * stripped URL — otherwise it re-triggers the handoff → window.location.replace → reload loop + * ("rapid Starting local Fusion runtime flashing that never connects"). + */ + it("renders children (no reload) when the handoff is present in the cached context but stripped from the URL", async () => { + const location = stubLocation(""); // URL already stripped by bootstrap + getShellHostContext.mockReturnValue({ kind: "desktop-shell", mode: "local", serverUrl: "http://127.0.0.1:50123" }); + stubShell({ + host: "desktop-shell", + desktopMode: "local", + desktopModeState: { isFirstRun: false, desktopMode: "local" }, + localRuntime: { source: "embedded-local", state: "running", port: 50123, baseUrl: "http://127.0.0.1:50123" }, + }); + + render( + +
app
+
, + ); + + await waitFor(() => expect(screen.getByTestId("app-loaded")).toBeTruthy()); + // The bug was an infinite reload; assert we never reload. + expect(location.replace).not.toHaveBeenCalled(); + }); + + it("performs the handoff exactly once on first load (no cached serverUrl yet)", async () => { + const location = stubLocation(""); // fresh launch, no shell params + getShellHostContext.mockReturnValue({ kind: "desktop-shell" }); // bootstrap saw no serverUrl + stubShell({ + host: "desktop-shell", + desktopMode: "local", + desktopModeState: { isFirstRun: false, desktopMode: "local" }, + localRuntime: { source: "embedded-local", state: "running", port: 50123, baseUrl: "http://127.0.0.1:50123" }, + }); + + render( + +
app
+
, + ); + + await waitFor(() => expect(location.replace).toHaveBeenCalledTimes(1)); + expect(location.replace.mock.calls[0][0]).toContain("serverBaseUrl=http%3A%2F%2F127.0.0.1%3A50123"); + }); +});