diff --git a/.changeset/hydrate-session-posts.md b/.changeset/hydrate-session-posts.md new file mode 100644 index 0000000..a9094b5 --- /dev/null +++ b/.changeset/hydrate-session-posts.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Hydrate session post streams from the session list endpoint to avoid N+1 post detail requests on open. diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index 5c8fa55..d7badf9 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -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); @@ -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: "
one
", + title: "One", + agent: "e2e", + sessionTitle: "Hydrate", + }); + await publish(server.url, { + html: "two
", + title: "Two", + agent: "e2e", + session: first.sessionId, + }); + await publish(server.url, { + html: "three
", + 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 = `heavy
" }] }), + ) + ).json()) as any; + await app.request(`/api/posts/${created.id}`, { + ...json({ title: "Hydrated v2", surfaces: [{ kind: "html", html: "new
" }] }), + 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, "new
"); + assert.equal(list[0].surfaces[0].index, 0); + assert.equal(list[0].history[0].surfaces[0].html, "heavy
"); + 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 ( diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 4492986..fdb0baf 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -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