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
5 changes: 5 additions & 0 deletions .changeset/hydrate-session-posts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Hydrate session post streams from the session list endpoint to avoid N+1 post detail requests on open.
58 changes: 52 additions & 6 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,19 @@ test("a surface kind this viewer doesn't know shows a refresh hint, not a broken
// server returns a valid surface, but rewrite the surface kind to one THIS
// viewer build has no Match for. It must degrade to a neutral hint, never
// the diff fallback.
await page.route(/\/api\/posts\/[^/?]+(\?|$)/, async (route) => {
await page.route(/\/api\/(posts\/[^/?]+|sessions\/[^/]+\/posts)(\?|$)/, async (route) => {
const res = await route.fetch();
const surface = await res.json();
if (Array.isArray(surface.surfaces)) {
surface.surfaces = surface.surfaces.map(() => ({ kind: "futurething" }));
}
await route.fulfill({ response: res, json: surface });
const body = await res.json();
const rewrite = (post: any) => {
if (Array.isArray(post.surfaces)) {
post.surfaces = post.surfaces.map(() => ({ kind: "futurething" }));
}
return post;
};
await route.fulfill({
response: res,
json: Array.isArray(body) ? body.map(rewrite) : rewrite(body),
});
});

await page.goto(server.url);
Expand All @@ -94,6 +100,46 @@ test("a surface kind this viewer doesn't know shows a refresh hint, not a broken
await expect(card.locator(".diff-error")).toHaveCount(0);
});

test("opening a session hydrates posts without N+1 post detail fetches", async ({
page,
server,
}) => {
const first = await publish(server.url, {
html: "<p>one</p>",
title: "One",
agent: "e2e",
sessionTitle: "Hydrate",
});
await publish(server.url, {
html: "<p>two</p>",
title: "Two",
agent: "e2e",
session: first.sessionId,
});
await publish(server.url, {
html: "<p>three</p>",
title: "Three",
agent: "e2e",
session: first.sessionId,
});

const postDetailRequests: string[] = [];
const hydratedListRequests: string[] = [];
page.on("request", (req) => {
if (req.method() !== "GET") return;
const url = new URL(req.url());
if (/^\/api\/posts\/[^/]+$/.test(url.pathname)) postDetailRequests.push(req.url());
if (url.pathname === `/api/sessions/${first.sessionId}/posts`) {
hydratedListRequests.push(url.searchParams.get("hydrate") ?? "");
}
});

await page.goto(`${server.url}/session/${first.sessionId}`);
await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(3);
expect(hydratedListRequests).toContain("1");
expect(postDetailRequests).toEqual([]);
});

test("resize bridge grows the iframe beyond its 120px default", async ({ page, server }) => {
const tall = `<div style="height: 600px">tall content</div>`;
await publish(server.url, { html: tall, title: "Tall", agent: "e2e" });
Expand Down
6 changes: 5 additions & 1 deletion server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,7 +1052,11 @@ export function createApp({
const session = await store.getSession(c.req.param("id"));
if (!session) return c.json({ error: "session not found" }, 404);
const posts = await store.listPosts(session.id);
return c.json(posts.map(sessionPostListRowView));
return c.json(
c.req.query("hydrate") === "1"
? posts.map(postDetailView)
: posts.map(sessionPostListRowView),
);
};
app.get("/api/sessions/:id/surfaces", listSessionPosts); // legacy alias
app.get("/api/sessions/:id/posts", listSessionPosts);
Expand Down
24 changes: 24 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2303,6 +2303,30 @@ test("GET /api/sessions/:id/posts lists lean surfaces with ids and omitted html
assert.deepEqual(list[0].parts, list[0].surfaces, "legacy parts aliases surfaces");
});

test("GET /api/sessions/:id/posts?hydrate=1 returns full post details in one response", async () => {
const app = makeApp();
const created = (await (
await app.request(
"/api/posts",
json({ title: "Hydrated", surfaces: [{ kind: "html", html: "<p>heavy</p>" }] }),
)
).json()) as any;
await app.request(`/api/posts/${created.id}`, {
...json({ title: "Hydrated v2", surfaces: [{ kind: "html", html: "<p>new</p>" }] }),
method: "PUT",
});

const list = (await (
await app.request(`/api/sessions/${created.sessionId}/posts?hydrate=1`)
).json()) as any[];
assert.equal(list.length, 1);
assert.equal(list[0].id, created.id);
assert.equal(list[0].surfaces[0].html, "<p>new</p>");
assert.equal(list[0].surfaces[0].index, 0);
assert.equal(list[0].history[0].surfaces[0].html, "<p>heavy</p>");
assert.equal(list[0].history[0].surfaces[0].index, 0);
});

test("read responses expose derived surface indexes and renumber after edits", async () => {
const app = makeApp();
const created = (await (
Expand Down
34 changes: 27 additions & 7 deletions viewer/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,29 @@ export async function refreshSessions(targetPostId?: string | null) {
}
}

function isHydratedPost(value: unknown): value is Post {
return !!value && typeof value === "object" && Array.isArray((value as Post).history);
}

async function fetchSessionPostDetails(id: string): Promise<Post[]> {
const rows = await api<unknown[]>(`/api/sessions/${id}/posts?hydrate=1`).catch(() => []);
const hydrated: Post[] = [];
for (const row of rows) {
if (isHydratedPost(row)) hydrated.push(row);
}
if (hydrated.length === rows.length) return hydrated;
const details = await Promise.all(
rows.map((row) =>
row && typeof row === "object" && typeof (row as { id?: unknown }).id === "string"
? api<Post>(`/api/posts/${encodeURIComponent((row as { id: string }).id)}`).catch(
() => null,
)
: null,
),
);
return details.filter((post): post is Post => post !== null);
}

export async function select(
id: string,
opts?: { fromPopState?: boolean; replace?: boolean; initialPostId?: string },
Expand All @@ -272,10 +295,7 @@ export async function select(
setCommentsInternal([]);
setTraceStepsInternal([]);
void fetchTrace(id);
const metas = await api<{ id: string }[]>(`/api/sessions/${id}/posts`).catch(() => []);
const details = (
await Promise.all(metas.map((m) => api<Post>(`/api/posts/${m.id}`).catch(() => null)))
).filter((s) => s !== null);
const details = await fetchSessionPostDetails(id);
if (selected() !== id) return; // user switched away mid-load
setPostsInternal(reconcile(details, { key: "id" }));
// Scroll to a specific post if requested (deep link).
Expand Down Expand Up @@ -577,16 +597,16 @@ async function resyncSelected() {
await refreshSessions();
if (!before || selected() !== before) return; // select() rebuilt the stream
void fetchTrace(before);
const metas = await api<{ id: string }[]>(`/api/sessions/${before}/posts`).catch(() => []);
const ids = new Set(metas.map((m) => m.id));
const details = await fetchSessionPostDetails(before);
const ids = new Set(details.map((post) => post.id));
setPostsInternal(
produce((arr) => {
for (let i = arr.length - 1; i >= 0; i--) {
if (!ids.has(arr[i].id)) arr.splice(i, 1);
}
}),
);
for (const meta of metas) await upsertPost(meta.id, { scroll: false });
if (selected() === before) setPostsInternal(reconcile(details, { key: "id" }));
const res = await api<{ comments: Comment[] }>(`/api/comments?session=${before}`).catch(
() => null,
);
Expand Down
Loading