Skip to content

fix(web): let Charcuterie own scroll memory, and delete ours - #305

Merged
Sawtaytoes merged 1 commit into
mainfrom
feat/scroll-memory-adapter
Sep 2, 2026
Merged

fix(web): let Charcuterie own scroll memory, and delete ours#305
Sawtaytoes merged 1 commit into
mainfrom
feat/scroll-memory-adapter

Conversation

@Sawtaytoes

@Sawtaytoes Sawtaytoes commented Sep 2, 2026

Copy link
Copy Markdown
Owner

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:

  1. AppFrame scrolled main back to 0 on every route change, in a layout effect. That
    effect sits above the page, so it ran after any restore below it and overwrote it.
  2. QueuesView kept a homeScroll module 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.0 makes scroll memory automatic. Main remembers where each history
entry 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 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 loses its blanket reset. 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 and state/ui.ts lose homeScroll.
  • main.test.ts 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.

Evidence

Driven against the synthetic e2e fixtures (e2e/fixtures/), never household data. Both
runs are identical up to the click: /q/bob in a 1400x600 window, list 977 px in a 506 px
scrollport, scrolled to the bottom at 471 px, out to Queues by the nav link, then Back.

main this branch
scrollTop before leaving 471 471
scrollTop after Back 0 471

The offset was sampled every 500 ms for eight seconds after Back. It held at 471 for all
sixteen samples.

Before leaving — both builds, identical

The queue scrolled to the bottom

After Back — main starts the queue again at the top

The queue is back at entry 1

After Back — this branch keeps the reader's place

The queue is still at entry 7 through 11

The SHA-pinned image links are re-pinned to the squashed commit after the merge.

Gates

yarn typecheck, yarn test (23 + 29 files, 324 + 371 tests), biome check (0 errors,
74 pre-existing warnings), yarn build — all pass.

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
Sawtaytoes merged commit 630701f into main Sep 2, 2026
2 checks passed
@Sawtaytoes
Sawtaytoes deleted the feat/scroll-memory-adapter branch September 2, 2026 07:39
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>
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