diff --git a/packages/studio/src/hooks/useServerConnection.hashProject.test.tsx b/packages/studio/src/hooks/useServerConnection.hashProject.test.tsx new file mode 100644 index 0000000000..7b99f31279 --- /dev/null +++ b/packages/studio/src/hooks/useServerConnection.hashProject.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useServerConnection } from "./useServerConnection"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const captured: { projectId: string | null } = { projectId: null }; + +function Probe() { + captured.projectId = useServerConnection().projectId; + return null; +} + +async function flush(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +/** + * The hash project id outlives the project it names — a renamed folder, or a + * bookmark from a project that is gone. It used to be trusted unconditionally, + * so every later /api/projects//... request 404'd for the life of the tab, + * including the composition read that opens the SDK session. Telemetry after + * the read-reason split: 120 http_error/404 reads across 5 users in 24h. + */ +function stubFetch(projectRoute: { status: number } | "reject") { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "/api/projects") { + return { ok: true, json: async () => ({ projects: [{ id: "real-project" }] }) } as Response; + } + if (projectRoute === "reject") throw new TypeError("Failed to fetch"); + return { ok: projectRoute.status === 200, status: projectRoute.status } as Response; + }), + ); +} + +async function renderWithHash(hash: string) { + window.location.hash = hash; + const root = createRoot(document.createElement("div")); + await act(async () => root.render()); + await flush(); + return root; +} + +describe("useServerConnection hash project id", () => { + beforeEach(() => { + captured.projectId = null; + window.location.hash = ""; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps a hash id the server can resolve", async () => { + stubFetch({ status: 200 }); + const root = await renderWithHash("#project/stale-or-not"); + + expect(captured.projectId).toBe("stale-or-not"); + await act(async () => root.unmount()); + }); + + it("falls back to the first project when the hash id is gone", async () => { + stubFetch({ status: 404 }); + const root = await renderWithHash("#project/deleted-project"); + + expect(captured.projectId).toBe("real-project"); + expect(window.location.hash).toContain("real-project"); + await act(async () => root.unmount()); + }); + + // The important one: a blip must not rewrite the user's hash out from under a + // project that is actually fine. Only a definite 404 is "missing". + it("keeps the hash id when the check itself fails", async () => { + stubFetch("reject"); + const root = await renderWithHash("#project/unreachable-check"); + + expect(captured.projectId).toBe("unreachable-check"); + await act(async () => root.unmount()); + }); + + it("keeps the hash id on a non-404 error status", async () => { + stubFetch({ status: 500 }); + const root = await renderWithHash("#project/server-erroring"); + + expect(captured.projectId).toBe("server-erroring"); + await act(async () => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useServerConnection.ts b/packages/studio/src/hooks/useServerConnection.ts index acf4898648..71abdfce7d 100644 --- a/packages/studio/src/hooks/useServerConnection.ts +++ b/packages/studio/src/hooks/useServerConnection.ts @@ -18,6 +18,26 @@ interface ServerConnectionState { * Polls every 2 s until the server responds, then transitions automatically. * Cleans up pending timers on unmount so it is safe under React StrictMode. */ +/** + * Whether a hash-supplied project id still names something the server can + * resolve. Three answers, not two: a failed request must NOT be read as + * "missing", or one network blip would discard a perfectly good deep link. + * + * Resolved through `/api/projects/:id`, which calls the same + * `adapter.resolveProject` the file routes use. A match against the + * `/api/projects` list would be wrong twice over: that list omits session ids + * (which resolve fine) and skips project dirs without an `index.html`. + */ +async function resolveHashProject(id: string): Promise<"ok" | "missing" | "unknown"> { + try { + const res = await fetch(`/api/projects/${encodeURIComponent(id)}`); + if (res.ok) return "ok"; + return res.status === 404 ? "missing" : "unknown"; + } catch { + return "unknown"; + } +} + export function useServerConnection(): ServerConnectionState { const [projectId, setProjectId] = useState(null); const [resolving, setResolving] = useState(true); @@ -39,21 +59,34 @@ export function useServerConnection(): ServerConnectionState { function tryConnect() { fetch("/api/projects") .then((r) => r.json()) - .then((data) => { + .then(async (data) => { if (cancelled) return; + // A hash project id outlives the project it names — a renamed folder, + // or a bookmark from a project that is gone. Trusting it blindly made + // every later /api/projects//... request 404 for the life of the + // tab, including the composition read that opens the SDK session, so + // every edit fell back to the server path with nothing to show why. + // Telemetry after the read-reason split: 120 http_error/404 reads + // across 5 users in 24h, ~24 each, never recovering. if (hashProjectId) { - setProjectId(hashProjectId); - setWaitingForServer(false); - } else { - const first = (data.projects ?? [])[0]; - if (first) { - setProjectId(first.id); + const state = await resolveHashProject(hashProjectId); + if (cancelled) return; + // "unknown" keeps the old behaviour: a transient failure must not + // rewrite the user's hash out from under a valid project. + if (state !== "missing") { + setProjectId(hashProjectId); setWaitingForServer(false); - window.location.hash = buildProjectHash(first.id); - } else { - scheduleRetry(); + return; } } + const first = (data.projects ?? [])[0]; + if (first) { + setProjectId(first.id); + setWaitingForServer(false); + window.location.hash = buildProjectHash(first.id); + } else { + scheduleRetry(); + } }) .catch(() => { if (!cancelled) scheduleRetry();