From 660635078c394141406b3fdef8c74cb5a1bbebfb Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 27 Jun 2026 09:24:28 -0400 Subject: [PATCH] fix(viewer): don't pin the default surface in the URL on session open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session auto-opens, the engine reflected it in the route (/session/:id) and then also auto-focused the topmost surface, writing that into the URL (/session/:id/s/:id) — an unnecessary second history write that read like a redirect chain in the address bar. The surface should only appear in the route when the user means it. The topmost card's IntersectionObserver fires on mount (the card is already in view), so discard that initial fire: it's an auto-focus, not a user choice. A card that mounts off-screen fires isIntersecting:false first (nothing to write), so its first real user-driven intersecting fire still reflects in the route. Deep links are unaffected — they write via select()/pollScrollIntoView and the IO is already suppressed during deepLinkScrolling, so a loaded /session/:id/s/:id stays focused and keeps its URL. The fix lives in the engine's navigation emission (Card.tsx), not a host, so every host (self-hosted + cloud embed) benefits uniformly. --- .../viewer-boot-no-default-surface-url.md | 9 +++++++ e2e/url-routing.spec.ts | 26 ++++++++++++++----- viewer/src/Card.tsx | 14 ++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 .changeset/viewer-boot-no-default-surface-url.md diff --git a/.changeset/viewer-boot-no-default-surface-url.md b/.changeset/viewer-boot-no-default-surface-url.md new file mode 100644 index 0000000..b617ef3 --- /dev/null +++ b/.changeset/viewer-boot-no-default-surface-url.md @@ -0,0 +1,9 @@ +--- +"sideshow": patch +--- + +Stop the viewer engine from pinning the default (topmost) surface in the URL +when a session auto-opens. Landing at the top of a session feed now keeps the +URL at `/session/:id`; only an explicit surface open (a deep link, or scrolling +into a surface) writes `/session/:id/s/:id`. Deep links loaded from the URL are +still honored and preserved. diff --git a/e2e/url-routing.spec.ts b/e2e/url-routing.spec.ts index 986dc4a..d81d159 100644 --- a/e2e/url-routing.spec.ts +++ b/e2e/url-routing.spec.ts @@ -6,16 +6,28 @@ test("clicking a session updates the URL to /session/:id", async ({ page, server await page.goto(server.url); await expect(page.locator("#sessionList .sess")).toHaveCount(2); - // click the second session row. Selecting a session pushes /session/:id, then - // focusSurface immediately replaceState's /session/:id/s/:surfaceId once the - // first card is visible — so match the session segment with a boundary, not a - // `$`, or this races the deep-link suffix (see the back/forward test below). + // Selecting a session pushes /session/:id. The topmost surface auto-focuses + // internally, but the engine no longer pins it in the URL — only an explicit + // surface open (a deep link, or scrolling into one) writes /session/:id/s/:id. await page.locator(`#sessionList .sess[data-id="${s2.sessionId}"]`).click(); - await expect(page).toHaveURL(new RegExp(`/session/${s2.sessionId}(\\b|/)`)); + await expect(page).toHaveURL(new RegExp(`/session/${s2.sessionId}$`)); // click the first session row await page.locator(`#sessionList .sess[data-id="${s1.sessionId}"]`).click(); - await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}(\\b|/)`)); + await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}$`)); +}); + +test("auto-selecting a session on boot does not pin the default surface in the URL", async ({ + page, + server, +}) => { + // A session with a post: landing at root auto-selects it. The topmost surface + // auto-focuses internally, but the URL must stay /session/:id — no /s/:id — + // because the user didn't open a specific surface. + const s = await publish(server.url, { html: "

hi

", title: "Top", agent: "pi" }); + await page.goto(server.url); + await expect(page.locator(`#sessionList .sess[data-id="${s.sessionId}"]`)).toHaveClass(/sel/); + await expect(page).toHaveURL(new RegExp(`/session/${s.sessionId}$`)); }); test("navigating to /session/:id selects that session", async ({ page, server }) => { @@ -169,7 +181,7 @@ test("/ redirects to the last viewed session from localStorage", async ({ page, // now visit root — should redirect to the last session await page.goto(server.url); - await expect(page).toHaveURL(new RegExp(`/session/${s.sessionId}(\\b|/)`)); + await expect(page).toHaveURL(new RegExp(`/session/${s.sessionId}$`)); await expect(page.locator(`#sessionList .sess[data-id="${s.sessionId}"]`)).toHaveClass(/sel/); }); diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index dfa5676..a724c4f 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -168,9 +168,23 @@ export function Card(props: { post: Post; standalone?: boolean }) { scrollIfTarget(); // Update the URL as the user scrolls past posts (replaceState, no // history noise). The first card that crosses the 50% threshold wins. + // The observer's first fire reports the card's position at mount: a card + // already in view (the default/topmost surface when a session opens) is an + // auto-focus, not a user choice, so it must not write the URL — only an + // explicit open (a deep link, or scrolling into a surface) does. Discard + // that initial fire; a card that mounts off-screen fires isIntersecting + // false first (nothing to write either way), so its first real, user-driven + // intersecting fire still reflects in the route. A deep-link scroll is + // already covered by deepLinkScrolling (and writes via pollScrollIntoView), + // so this only gates the scroll-driven reflection. + let initialFire = true; const observer = new IntersectionObserver( (entries) => { if (deepLinkScrolling) return; + if (initialFire) { + initialFire = false; + return; + } for (const entry of entries) { if (entry.isIntersecting) focusPost(props.post.id); }