fix(web): let Charcuterie own scroll memory, and delete ours - #305
Merged
Conversation
Scroll a queue, follow a link, then press Back, and QueuePilot started the queue again at the top. It had two hand-rolled pieces fighting each other, and neither answered the general case. `@charcuterie/ui@4.0.0` makes scroll memory automatic. `Main` remembers where each history entry was scrolled to, restores it as the content arrives, and stops the moment the reader scrolls. No browser does this for an inner scrollport, and `Shell` makes `<main>` this app's one vertical scroll region. - `main.tsx` wraps the app in `<ReactRouterAdapter>` inside `<BrowserRouter>`. One component now supplies both router seams: the link seam it already had, and the scroll entry. - `AppFrame` no longer scrolls every route change back to `0`. That layout effect sits ABOVE the page, so it ran AFTER the library's restore and overwrote it every time. The library answers the same question better: a new path starts at the top, a search-param change is left alone, and a remembered entry is put back. - `QueuesView` loses its `homeScroll` save and restore, and `state/ui.ts` loses the module global behind them. That version was one-way and one-route: it only worked coming back to Queues, and only when the queue had been opened by its title. - Add `main.test.ts`, which reads `main.tsx` as source and asserts the adapter is present and sits inside the router. A root wiring mistake has no unit-test surface otherwise. Verified against the synthetic e2e fixtures, not household data: the queue scrolled to 471 px, out to Queues, then Back. Before: 0. After: 471, and stable for eight seconds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sawtaytoes
added a commit
to Sawtaytoes/mux-magic
that referenced
this pull request
Sep 2, 2026
## What changed `@charcuterie/ui@4.0.0` makes scroll memory automatic and moves both router seams behind one root component. `<ReactRouterAdapter>` supplies the link seam and the scroll entry together, and `Main` reads what it needs from context. - `AppRouter` wraps the route table in `<ReactRouterAdapter>` — inside `<BrowserRouter>` because the adapter reads `useLocation()`, and above `Suspense` so a lazy page that is still loading does not unmount the providers. - `AppRouter.test.ts` reads `AppRouter.tsx` as source and asserts the adapter is present and sits inside the router. A root wiring mistake has no unit-test surface otherwise. ##⚠️ Neither seam does anything in this app yet This is worth stating plainly rather than implying a fix that has not happened. mux-magic has **no client-side navigation at all**. Every in-app link is a hand-written raw anchor — ten of them — and the app imports no Charcuterie link component and no react-router `Link` or `useNavigate`: | File | Destinations | | --- | --- | | `packages/web/src/components/PageHeader/PageHeader.tsx` | `/`, `/jobs` | | `packages/web/src/pages/HomePage/HomePage.tsx` (through `ToolCard`) | `/builder`, `/jobs` | | `packages/web/src/pages/JobsPage/JobsPage.tsx` | `/`, `/builder`, `/errors` | | `packages/web/src/pages/JobsList/JobsList.tsx` | `/builder` | | `packages/web/src/pages/ErrorsPage/ErrorsPage.tsx` | `/jobs`, `/builder` | A raw anchor reloads the document. The SPA boots again, every lazy chunk is downloaded a second time, and the history entry the scroll memory keyed on is gone — so there is no client-side navigation for either seam to observe. **That is why this PR carries no before/after screenshot.** There is no behaviour change to photograph. The seams themselves are proven with fixture data in the sibling PRs ([Docket #126](https://forgejo.octen.dev/sawtaytoes/docket/pulls/126), [Folio #7](https://forgejo.octen.dev/sawtaytoes/folio/pulls/7), [QueuePilot #305](Sawtaytoes/queuepilot#305)), where the app already navigates client-side. This change is the prerequisite. Converting the ten anchors is the follow-up, and it carries a design question that belongs to the library rather than to this app: each anchor keeps its own Tailwind classes, so what is wanted is an **unstyled anchor that routes when a router is present**, not `TextLink` or `ButtonLink`, which bring their own look. `@charcuterie/ui` exports the parts (`useRouterLink`, `AnchorLink`, `getIsRoutedHref`) but no such component. `ToolCard` also renders in Storybook with no router above it, which is exactly the case the Charcuterie seam handles and a bare react-router link does not. ## Gates `yarn vitest run` (457 files passed, 55 skipped, 3673 tests), `yarn typecheck`, `yarn lint:biome`, `yarn lint:eslint`, `yarn generate:schemas-ci` (which builds the web bundle) — all pass. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
Scroll a queue, follow a link, then press Back, and the queue started again at the top.
QueuePilot had two hand-rolled pieces fighting each other:
AppFramescrolledmainback to0on every route change, in a layout effect. Thateffect sits above the page, so it ran after any restore below it and overwrote it.
QueuesViewkept ahomeScrollmodule global. That version was one-way and one-route:it only worked coming back to Queues, and only when the queue had been opened by its
title.
What changed
@charcuterie/ui@4.0.0makes scroll memory automatic.Mainremembers where each historyentry was scrolled to, re-applies it as the content arrives, and stops the moment the reader
scrolls. No browser does this for an inner scrollport, and
Shellmakes<main>this app'sone vertical scroll region.
main.tsxwraps the app in<ReactRouterAdapter>inside<BrowserRouter>. One componentnow supplies both router seams: the link seam it already had, and the scroll entry.
AppFrameloses its blanket reset. The library answers the same question better: a newpath starts at the top, a search-param change is left alone, and a remembered entry is put
back.
QueuesViewandstate/ui.tslosehomeScroll.main.test.tsreadsmain.tsxas source and asserts the adapter is present and sitsinside the router. A root wiring mistake has no unit-test surface otherwise.
Evidence
Driven against the synthetic e2e fixtures (
e2e/fixtures/), never household data. Bothruns are identical up to the click:
/q/bobin a 1400x600 window, list 977 px in a 506 pxscrollport, scrolled to the bottom at 471 px, out to Queues by the nav link, then Back.
mainscrollTopbefore leavingscrollTopafter BackThe offset was sampled every 500 ms for eight seconds after Back. It held at 471 for all
sixteen samples.
Before leaving — both builds, identical
After Back —
mainstarts the queue again at the topAfter Back — this branch keeps the reader's place
Gates
yarn typecheck,yarn test(23 + 29 files, 324 + 371 tests),biome check(0 errors,74 pre-existing warnings),
yarn build— all pass.