fix(studio): a stale project id in the URL no longer blocks editing for the tab (#4188)

The project id in the location hash is user-supplied and outlives the project
it names: rename the folder, or open a bookmark from a project that is gone,
and it points at nothing. useServerConnection trusted it unconditionally, so
every later /api/projects/<id>/... request 404'd for the life of the tab —
including the composition read that opens the SDK session. Studio then fell
back to the server path for every edit, and the resolver shadow never ran
either, so nothing recorded why.

Telemetry, 24h after the read-reason split shipped in v0.8.51: 120
`stage: read` / `http_error` 404 reads across 5 users, about 24 each, none
recovering. On this route a 404 has exactly one origin — resolveProject
returned null — since the other failure modes answer 403 and the GET does not
set mustExist.

Validates through /api/projects/:id, which calls the same
adapter.resolveProject the file routes use. Matching against the
/api/projects list instead would be wrong twice over: that list omits session
ids, which resolve fine, and skips project dirs without an index.html.

The check is tri-state on purpose. Only a definite 404 counts as missing; a
rejected request or a 5xx keeps the hash, so one network blip cannot rewrite
a valid deep link out from under the user.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-09-19 14:33:54 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent f9fc552188
commit 3685b76a07
2 changed files with 137 additions and 10 deletions
@@ -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<void> {
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/<id>/... 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(<Probe />));
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());
});
});
@@ -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<string | null>(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/<id>/... 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();