Skip to content

Keep the list back until the filter has been applied - #162

Merged
abernier merged 1 commit into
mainfrom
fix/filter-flash-on-main
Aug 9, 2026
Merged

Keep the list back until the filter has been applied#162
abernier merged 1 commit into
mainfrom
fix/filter-flash-on-main

Conversation

@abernier

@abernier abernier commented Aug 9, 2026

Copy link
Copy Markdown
Member

Re-targets #161, which was opened against fix/set-state-in-effect to stack on #160 — but #160 was squashed into main two minutes earlier, so merging #161 landed it on a branch main no longer tracks. Same content, cherry-picked onto main: the three touched files were byte-identical between main and #161's base, so this is the merged commit unchanged.


Arriving on ?q= or ?library= meant watching all ~160 cards paint and then jump to the handful that match.

Why not <Suspense>

The site is output: "export": the list ships rendered whole in the HTML and paints before any JS runs, so nothing React does afterwards can help — a boundary that never suspends changes nothing.

Next does have a native answer: a <Suspense> around a useSearchParams consumer (i.e. nuqs/adapters/next/app) makes the prerender emit the fallback instead of the subtree. But that is decided at build time, not per visitor — it would cost the prerendered list, all ~160 links, for everyone, to spare the flash for the few who arrive with a filter.

What this does instead

bootNav already reads the URL (for ?nav=), so it also marks a filtered arrival on <html>; globals.css holds the list back behind skeletons until Nav takes the mark over, in the same handoff as data-nav-collapsed. Only filtered arrivals pay anything; everyone else keeps the prerendered list painting exactly as before.

visibility, not display: the cards have to keep their boxes, both because the list windowing measures them on that very commit — collapsed to nothing, every card reads as on-screen and all ~160 thumbnails mount at once — and because it is what lets the skeletons sit over the list rather than push it down.

The skeletons carry the tag strip the real cards do, and the filter row gets one too: whichever of its two forms the HTML was built with is the wrong one on a filtered arrival — the dropdown standing where the search field belongs on ?q=, or the dropdown with nothing selected on ?library=.

Along the way

  • The boot script is a real function. String(bootNav) is what lands in the page, so the logic is typed, formatted and linted like the rest of layout.tsx instead of living in a template literal. It has to stay hermetic to survive stringification — hence the storage key as an argument. (It cannot live in Nav.tsx: that is a "use client" module, so a server component importing from it gets a client reference, not the function.)
  • The pill's hide/show no longer arrives late. It was held back until mounted because the collapsed state came from localStorage; it now reaches the pre-paint mark through useSyncExternalStore, so the word is right on the first client render and the pill stops reflowing around it.
  • A filter beats a stored collapse in the script too, matching what Nav already did on the client.

Known

hide then reload on a filtered URL reopens the rail — the "a shared filter link has to be visible" rule, inherited from the one-shot effect that predates this work, acting in both bootNav and Nav. Left as-is deliberately: the two cases it conflates (arriving on someone else's filter link, vs. having just collapsed it yourself on that URL) are indistinguishable from localStorage alone.

Verified

pnpm lint / tsc --noEmit / next build clean; the minified inline script in the built HTML is self-contained. In the browser: ?q= and ?library= never show the unfiltered list, the mark is gone once React owns it, an unfiltered load is untouched (158 cards, list visible, collapse restored), and ?nav= still outranks the stored preference.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y9J9K5TobdJo49EU4Z1Fp4

* Keep the list back until the filter has been applied

Arriving on `?q=` or `?library=` meant watching all ~160 cards paint and
then jump to the handful that match. Nothing React does can help: the
site is `output: "export"`, so the list ships rendered whole in the HTML
and paints before the JS that will narrow it has even loaded.

Next has an answer for this — a `<Suspense>` boundary around a
`useSearchParams` consumer, which drops that subtree from the static HTML
— but it is a build-time decision, so it would cost the prerendered list
for every visitor to spare the flash for the few who arrive filtered.

Instead the boot script, which already reads the URL for `?nav=`, marks a
filtered arrival on <html>; `globals.css` holds the list back until `Nav`
takes the mark over, alongside the collapse one. Only filtered arrivals
pay anything, and the list still ships prerendered for everyone else.
`visibility`, not `display`, so the cards keep the layout the list
windowing measures.

That script is now a real function, serialized with `String(bootNav)`
rather than written as a template literal: typed, formatted and linted
like the rest of the file. It has to stay hermetic to survive
stringification, so the storage key comes in as an argument.

A filter now also beats a stored collapse in the script itself, matching
what `Nav` already did on the client — the rail no longer paints shut and
swings open on a shared filter link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9J9K5TobdJo49EU4Z1Fp4

* Fill the wait with skeletons, and stop the pill's label arriving late

Two leftovers around what the rail looks like before React owns it.

A filtered arrival now waits in front of a handful of skeletons rather
than an empty scroller -- blank reads as "nothing here", a shimmer reads
as "coming". They are laid over the list rather than above it, because
the list has to keep its boxes: `visibility`, not `display`, is what
keeps them, and hiding it outright would collapse every card to nothing
on the very commit the list windowing measures on -- every card would
read as on-screen and all ~160 thumbnails would mount at once.

And the toggle's "hide"/"show" was held back until mounted, on the
grounds that the collapsed state came out of `localStorage` and the word
would otherwise be a coin flip. It no longer does: `shown` reaches the
pre-paint mark through `useSyncExternalStore`, so the word is right on
the first client render, and the pill stops reflowing around a label
that turns up a beat later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9J9K5TobdJo49EU4Z1Fp4

* Skeleton the filter row too, and give the cards their tag strips

The placeholder cards were bare rectangles where the real ones carry a
strip of tag pills over the bottom corner, so the shape shifted under the
visitor at the very moment the list landed. They now carry the strip,
darker than the card the way the real pills are -- at the card's own tone
they read as holes punched in it. Widths are written out rather than
drawn at random, since the markup has to come out the same on both sides
of hydration.

The filter row had the same problem one line up, and worse: whichever of
its two forms the HTML was built with is the wrong one on a filtered
arrival -- the dropdown standing where the search field belongs on `?q=`,
or the dropdown with nothing selected on `?library=`. It gets a
placeholder of its own, with the two forms wrapped in a `display:
contents` element so standing in front of them changes nothing about how
they lay out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9J9K5TobdJo49EU4Z1Fp4

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abernier
abernier merged commit d20d67c into main Aug 9, 2026
2 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