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); }