fix(desktop): stop local-runtime handoff reload loop (rapid "Starting Fusion" flashing)

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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-02 18:45:20 -07:00
parent bd24bd4c82
commit 04fd91f66b
2 changed files with 111 additions and 3 deletions

View File

@@ -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…") });

View File

@@ -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<typeof vi.fn>; reload: ReturnType<typeof vi.fn> };
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(
<DesktopLaunchGate>
<div data-testid="app-loaded">app</div>
</DesktopLaunchGate>,
);
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(
<DesktopLaunchGate>
<div data-testid="app-loaded">app</div>
</DesktopLaunchGate>,
);
await waitFor(() => expect(location.replace).toHaveBeenCalledTimes(1));
expect(location.replace.mock.calls[0][0]).toContain("serverBaseUrl=http%3A%2F%2F127.0.0.1%3A50123");
});
});