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 { useEffect, useState, type PropsWithChildren } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ShellConnectionState } from "../types/native-shell";
|
import type { ShellConnectionState } from "../types/native-shell";
|
||||||
|
import { getShellHostContext } from "../shell-host";
|
||||||
import "./DesktopLaunchGate.css";
|
import "./DesktopLaunchGate.css";
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
@@ -92,9 +93,23 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
|||||||
// current page URL; if remote, App handles the redirect to the
|
// current page URL; if remote, App handles the redirect to the
|
||||||
// active profile already.
|
// active profile already.
|
||||||
if (state.desktopMode === "local") {
|
if (state.desktopMode === "local") {
|
||||||
const params = new URLSearchParams(window.location.search);
|
/*
|
||||||
if (params.has("serverBaseUrl")) {
|
* FNXC:DesktopLaunchGate 2026-07-03-01:05:
|
||||||
setPhase({ kind: "ready", serverBaseUrl: params.get("serverBaseUrl") ?? undefined });
|
* 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;
|
return;
|
||||||
}
|
}
|
||||||
setPhase({ kind: "starting-local", message: t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") });
|
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