Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions packages/studio/src/hooks/useServerConnection.hashProject.test.tsx
Original file line number Diff line number Diff line change
@@ -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());
});
});
53 changes: 43 additions & 10 deletions packages/studio/src/hooks/useServerConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down
Loading