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
9 changes: 9 additions & 0 deletions .changeset/viewer-boot-no-default-surface-url.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 19 additions & 7 deletions e2e/url-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>hi</p>", 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 }) => {
Expand Down Expand Up @@ -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/);
});

Expand Down
14 changes: 14 additions & 0 deletions viewer/src/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading