Skip to content

fix(viewer): don't pin the default surface in the URL on session open#170

Merged
benvinegar merged 1 commit into
mainfrom
fix/viewer-boot-default-surface-url
Jun 27, 2026
Merged

fix(viewer): don't pin the default surface in the URL on session open#170
benvinegar merged 1 commit into
mainfrom
fix/viewer-boot-default-surface-url

Conversation

@benvinegar

Copy link
Copy Markdown
Member

Problem

When the viewer opens a session that has posts, it first reflects the session in the URL (good): `/session/:id`. Then it also auto-focuses the topmost/default surface and writes that into the URL: `/session/:id/s/:surfaceId`. Landing at the top of a session feed pinned the topmost post's surface in the URL — an unnecessary second history write that read like a redirect chain in the address bar (both the default self-hosted host and the Cloud embed saw it as two router navigations on boot).

```
router.navigate({ sessionId }, { replace: true }) // session auto-select — KEEP
router.navigate({ sessionId, surfaceId }, { replace: true }) // default surface auto-focus — DROP
```

Root cause

On boot, `select()` writes `/session/:id` (good). Then each `Card` mounts an `IntersectionObserver` (`viewer/src/Card.tsx`). The topmost card is already in view, so its IO fires on mount and calls `focusPost(topmostId)` → `router.navigate({ sessionId, surfaceId })` → the unwanted second URL write.

Fix

The surface should only appear in the route when the user means it. The IO's first fire always reports the card's mount-time position — a card already in view is an auto-focus, not a user choice, so 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.
  • 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 is a per-card `let initialFire = true` gate in the IO callback — no module state, timers, or host changes, so every host (self-hosted + Cloud embed) benefits uniformly. It lives in the engine's navigation emission, not a host, per the architecture contract.

Behavior after the fix

  • Boot / auto-selecting a session → route is `{ sessionId }` only (no `surfaceId`).
  • Auto-focusing the default (topmost) surface → still focuses internally, but does not request a URL change.
  • User opens/navigates to a specific surface → `{ sessionId, surfaceId }` in the route (deep-links + sharing still work).
  • Restoring an explicit incoming deep-link (`/session/:id/s/:surfaceId`) → focuses that surface and keeps the URL intact.

Invariant preserved: a real surface deep-link (loaded from the URL, or opened by the user) is always honored and preserved.

Tests

In `e2e/url-routing.spec.ts`:

  • New: auto-selecting a session on boot keeps the URL at `/session/:id$` (no `/s/`).
  • Tightened: "clicking a session" and "/" localStorage-redirect tests now assert `$` (were lenient `(\\b|/)` matchers that documented the old race).
  • Existing deep-link test (load `/session/:id/s/:id` → focuses + keeps URL) and scroll test (scroll → writes `/s/:id`) still pass — covering the explicit-open and deep-link-restore scenarios.

Validation

  • `npm run typecheck` (3 tsc programs) — pass
  • `npm run lint` / `npm run format:check` — pass
  • `npm test` — 382/382 unit tests pass
  • `npm run build` — pass
  • `npm run test:e2e` — 72/72 chromium pass (url-routing, viewer, isolation, theme, uploads, embed, kits, public-read)
  • webkit: blocked in this environment by missing system GTK/WebKit shared libs (`browserType.launch` can't start the browser — pre-existing, no sudo). The change uses standard IntersectionObserver first-fire semantics identical across engines.

Changeset

`patch` — see `.changeset/viewer-boot-no-default-surface-url.md`.

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.
@benvinegar benvinegar merged commit b04251f into main Jun 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant