Relationships
#2427 Lab tab title goes stale on client-side navigation between issues
Opened by skunk-ape · 9/23/2026· Shipped 9/23/2026
The browser tab title never changes while navigating inside the Lab. Open the
issue list, click an issue, click another: the <title> keeps whatever the
server set when the document was first served. Reloading shows the correct
title for the issue in the URL.
Steps to reproduce
- Load
/lab— tab reads "Lab - Swamp Club". - Click any issue. URL becomes
/lab/{n}, the detail pane renders, tab title is unchanged. - Click a second issue. Same.
- Reload. Tab now reads
Lab #{n}: {title}— correct.
Cause
/lab and /lab/{n} are the same island, islands/lab/LabShell.tsx, which is
a hand-rolled client router. Clicking an issue never reaches the server, and the
only writer of the tab title is server-side:
routes/_app.tsx:58renders<title>{title}</title>fromctx.state.title, once per document response.routes/lab/[slug].tsx:185setsLab #{number}: {title}for a detail URL.routes/lab/[slug].tsx:302/routes/lab/index.tsx:54set the list titles.
document.title appears nowhere in the repo, and there is no <Head> usage —
Fresh 2 has no client-side head management. So after hydration nothing can
change the title.
The island intercepts the navigation instead:
islands/lab/LabShell.tsx:510handleIssueClickcallse.preventDefault()on the issue anchor for any plain left-click (anchors at:880board,:1555list).islands/lab/LabShell.tsx:493navigateTosets the selection, callshistory.pushState, and callsloadDetail. It never touches the title.islands/lab/LabShell.tsx:406loadDetailfetches/api/v1/lab/issues/{n}as JSON. JSON has no head.
A reload fixes it because that is a real document request to
routes/lab/[slug].tsx, whose handler sets ctx.state.title for the URL the
island's pushState already corrected. The server has always been right; it is
just never consulted on the fast path.
Also affected, same cause
Every pushState in the island: closing the detail (navigateTo(null),
:602/:2070) leaves an issue title on a list view; toggleFilter (:479),
clearFilters (:487) and setViewMode (:852) change the filter URL without
the matching Lab — {filter} title; the popstate handler (:552) restores
selection on back/forward but sets no title. Because loadDetail re-runs off
the SSE stream when the open issue changes (:689), a rename also leaves the
old title up.
Suggested fix
One effect in LabShell, keyed on the selected number and activeDetail —
written in an effect, not render scope, since the island SSRs before it
hydrates. activeDetail.value.issue already carries number and title
(LabShellDetail, :58), so no extra fetch is needed.
The one real cost: the list-side vocabulary (filterTitle and its
STATUS_LABEL / TYPE_LABEL maps) is route-local at
routes/lab/[slug].tsx:95-111, and an island cannot import from routes/. It
needs extracting into the shared kernel (lib/*.ts) with the route re-pointed
at it, or the client and server titles drift the next time a status label
changes. ctx.state.description / ogImage can stay server-only — crawlers get
a real document response.
Scope elsewhere in the app
islands/ExtensionRegistry.tsx:471, islands/FeedFilterBar.tsx:81 and
islands/LeaderboardBrowser.tsx:76 do the same pushState-without-title, so
the gap is app-wide. Their host routes set static titles
(routes/extensions/index.tsx:50, routes/feed/index.tsx:51), so there is
nothing per-URL to go stale and no visible defect there today. Lab is the only
place where a server title varies per URL behind a client router.
Environment
swamp-club web app, main at ec005fc0. Reproduced by reading the code; the
mechanism is confirmed against the files and lines above.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.