Repository navigation
fix(web): write URL state with window.history so reloads keep it - #722
Conversation
…eeps them useUrlState's setter wrote the URL with router.replace, which commits the address bar only once the route's server render returns. On PR #718's preview that render took 4.3 s, and a reload inside that window brought the Explorer back to Newest (aw-verify F-1). The setter now writes with window.history.replaceState (pushState in push mode, deduped like router.push). Next.js syncs both into useSearchParams without a server render, so the URL holds the choice as soon as setState returns. The write builds from the live location, so two keys set in one tick both land. The clean-up effects keep using the router. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Interaction story on the Explorer: choosing Recent read must put sort=%22last_opened_at%22 in window.location in the same step, and Newest must remove it. Fails against the router.replace setter (sort reads null), passes with the window.history write. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
FilterBar's Clear all sets filters and then retention, both push-mode, in one click. Writing each with pushState left two Back steps, the first landing on a state the reader never saw. Within one task only the first URL-changing push-mode write pushes; later ones replace that entry, as the router used to collapse same-tick navigations. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
…ines they explain Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
…e write path url-state-optimistic's header and the two MemorySidebarProvider guards explained a router round trip and a same-tick clobber that the window.history write from the live URL no longer has. The guards stay; their reasons now read in the past tense. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Next.js installs its window.history patch in an App Router effect. On a hard load a child's mount effect in the same commit runs first, so a setter called there (OrganizationManager clearing a stale ?org=) would hit the raw History API and Next's router would never see the write. React runs a commit's mount effects in one synchronous flush, so one microtask later the patch is in; still before any paint or reload. Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
|
The dashboard preview for this PR — redeployed on each push that changes the web app.
Preview always points at this PR's latest commit; Deployment is the immutable build of |
Co-authored-by: dash0-dev[bot] <257284812+dash0-dev[bot]@users.noreply.github.com>
Verify (preview) —
|
| PR | mthines/lorekit#722 · no ticket |
| Commit verified | f5b20e1 (branch fix/url-state-history-replace) |
| Environment | preview · https://lorekit-pr-722-mads-thines-projects.vercel.app · web preview → production Supabase api (web-only diff) |
| Telemetry | dash0-mcp · dataset default · 0 loop(s) |
| Run | 2026-10-22T05:39Z → 2026-10-22T06:15Z |
Acceptance criteria
| ID | Criterion | Checks | Browser | Telemetry | Evidence |
|---|---|---|---|---|---|
| AC-1 | Replace mode writes with replaceState, never pushState |
pass (CI) | pass | seen | history.length 2 → 2 after a sort change; S-5 |
| AC-2 | Recent read on /lore writes sort=%22last_opened_at%22, which a reload reads back |
pass (CI) | pass | seen | URL read 0 ms after the select; reload GET /lore?sort=%22last_opened_at%22 200, control "Recent read"; S-1 |
| AC-3 | Push mode adds a history entry when the URL changes | pass (CI) | pass | seen | remove filter: history.length 2 → 3, one Back restores the exact start URL; S-3 |
| AC-4 | The default removes the param; params already in the URL are kept | pass (CI) | pass | seen | scope kept beside sort; Newest restores the exact start URL; S-5 |
| AC-5 | The setter does not navigate through the router | pass (CI) | pass | confirms | no /lore request after a sort change; 0 RSC GET /lore?sort= spans (main: 19 in 7 days) |
| AC-6 | Web unit tests, typecheck and lint pass | pass | — | — | CI on f5b20e1: Typecheck, Test & Lint ✓, Storybook interaction + visual ✓, Playwright e2e ✓ |
Guardrails: exported signature unchanged (no useUrlState / UseUrlStateOptions line in the diff; CI typecheck green) · llms.txt not in the diff · no .skip / .only added.
Edge cases
| ID | Probe | Result | Evidence |
|---|---|---|---|
| EC-1 | Sort change, navigate to Insights, Back | pass | Back → /lore?sort=%22last_opened_at%22 (+198 ms), "Recent read", Explorer list; S-7 |
| EC-2 | "Clear all" over a filter + a retention condition (two push keys, one click) | pass | history.length 2 → 3; one Back → exact start URL, 2 pills; S-8 |
| EC-3 | Hard load /settings/organization?org=<unknown>, leave, Back |
pass | org cleaned ~100 ms after load; Back → /settings/organization, "Your organizations", 0 page errors; S-9 |
| EC-4 | List request → 500 on Recent read | pass | URL carries sort 0 ms after the select; "Failed to load memories. Please refresh."; reload keeps Recent read; S-10 |
| EC-5 | Offline sort change | pass | URL carries sort=%22opened_count%22 0 ms after the select while offline; 0 page errors; S-11 |
| EC-6 | Five sort picks in 929 ms | pass | URL is the last pick at once; history.length unchanged; last 200 list call last_opened_at; S-12 |
Not applicable (7)
- Concurrency (record creation): URL state only, no record is created.
- Failure per guardrail: the guardrails are code-level; checked from CI and the diff.
- Content: the read path (
deserialise) is unchanged; PR feat(explorer): add a Recent read sort — last deliberate agent read first #718's run covered hand-edited values. - Viewport / keyboard:
SortSelect.tsxis unchanged and both variants call the same setter; PR feat(explorer): add a Recent read sort — last deliberate agent read first #718's run covered both. - Permissions: no authorization code touched.
- Numbers & time: no numeric or time input.
- API probes: no endpoint changed.
Findings
| ID | Severity | Kind | What happened | Status |
|---|---|---|---|---|
| T-1 | medium | performance | pre-existing: 10 of 57 /lore server renders took over 1 s (max 4.4 s), each held by one production Supabase call during render (memories/scopes, a tag-count HEAD) |
open |
| T-2 | medium | performance | pre-existing: 18 of 417 production api spans took over 1 s (max 6.3 s) in two bursts, almost all IO wait (auth, then the list query) | open |
Fixes
No code changes.
Screenshots
not attached (every capture shows the test account's email in the sidebar). 20 images are kept locally: S-1, S-2 Spec 1 · S-3, S-4 Spec 2 · S-5, S-6 Spec 3 · S-7 EC-1 · S-8 EC-2 · S-9 EC-3 · S-10 EC-4 · S-11 EC-5 · S-12 EC-6, plus each spec's other step shots.
Under the hood
- AC-1 seen —
lore.sort_changedlast_opened_at on/lore?scope=…→ URL becomes?scope=…&sort=%22last_opened_at%22in the same page load → POST /functions/v1/memories/list 200 (749 ms) → api lorekit.memories 200 (213 ms) →lorekit_memory_list(135 ms) - AC-2 seen —
lore.sort_changed→ list call already sent from/lore?sort=%22last_opened_at%2220 ms later → reload → webGET /lore?sort=%22last_opened_at%22200 (779 ms) → list call still on that sort, 200 - AC-3 seen —
lore.filter_changedremove → list call on/lore→ Back → list call on/lore?filters=[kind in lesson]200, one page load, no document request - AC-4 seen —
scopekept besidesort; picking the default removedsort - AC-5 confirms — 0 RSC
GET /lore?sort=spans across both windows; the 6 documentGET /lore?sort=spans follow the probes' own full reloads [receipt] verdict: confirms
Open and escalated
- T-1 / T-2 — production Supabase latency (pre-existing, not this PR). The
/loreserver render blocks onmemories/scopesand a tag-count query, and the api spends seconds in IO wait during bursts. Since fix(web): write URL state with window.history so reloads keep it #722, the sort/filter URL no longer waits on this; the first page load still does. Options: stop the/lorerender blocking on those reads (stream them, or fetch client-side), or investigate the burst latency in Supabase. Recommendation: track it separately; it does not block this PR.
Lessons
- Written:
verify-lessons::agent0-reset-wipes-agent-dir-restore-brief-from-pr(new);verify-lessons::url-state-write-commits-after-rsc(corrected for thewindow.historywrite). - Promotion-eligible: none at seen_count ≥ 3 seen by this run.
Generated by /aw-verify · full artifacts in .agent/fix/url-state-history-replace/verify/
|
Darkplane Automerge did not approve this pull request. It scored 55.73 of 100, and 75 was required.
The other criterion
An AI evaluator produced these scores from the diff, the repository history, and the pull request text. The pull request text is author-controlled, so treat the scores as signals rather than proof. Reviewed for commit f5b20e1. |
|
@dash0 > /review-loop |
|
⚡ Running Agent0 | Execute… |
✅ No issues foundMoves useUrlState's URL write from router.replace to a microtask window.history call. The write path, push dedupe and Next 15.3 history sync check out; the deferred write has no hook-level test. 1 advisory finding below the confidence bar — see Less certain below. Less certain (1) — advisory, below the confidence bar
Review details
Nothing to report — severity, 0 files skipped.
|
|
review-loop on #722 converged at iteration 1 of 5. No commit was pushed, because nothing needed changing.
Two advisory notes from the reviewer, below the posting threshold (not blocking):
Per the PR description, the last commit's microtask deferral has had no independent review before this pass. |
Why
A sort chosen on
/lorereached the URL only afterrouter.replace's server render returned — 4.3 s on #718's preview — so a reload inside that window brought the Explorer back to Newest.What changed
useUrlState's setter writes the address bar withwindow.history.replaceState(pushStatein push mode), built from the live URL; Next syncs it intouseSearchParamswithout a server render.setState, so on a hard load it lands after Next has patchedwindow.history.How to verify
cd packages/web && npx vitest run src/lib/hooks/useUrlState.spec.ts, then on the preview pick Recent read and reload at once — it stays Recent read.Notes for reviewers
The microtask deferral (last commit) came after the final local review pass, so it has had no independent review. The
cleanOnPathname/cleanOnUnmountclean-ups still go through the router.🧪 UI verification spec — run against the preview deployment
Target: preview
Format: intent
Spec 1: R2 — A sort choice survives a reload made right after it
Changed: the URL write behind the Lore Explorer's Sort memories control (
useUrlStatenow writeswindow.history)Start: /lore
Preconditions:
Steps:
Expected:
sort=%22last_opened_at%22.sortparameter.Hints:
writeParamToHistory)Out of scope:
Spec 2: R4 — Removing a filter is one Back step, and Back restores it
Changed: the push-mode URL write behind the Explorer filter bar (
filters,navigationMode: 'push')Start: /lore?filters=%5B%7B%22field%22%3A%22kind%22%2C%22operator%22%3A%22in%22%2C%22values%22%3A%5B%22lesson%22%5D%7D%5D
Preconditions:
Steps:
Expected:
%22kind%22.filtersvalue contains%22kind%22).Hints:
filtersis push mode — packages/web/src/components/lore/LoreExplorer.tsx:478