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:
@@ -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…") });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user