Skip to content

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

Merged
abernier merged 3 commits into
fix/set-state-in-effectfrom
fix/filter-flash
Aug 9, 2026
Merged

Keep the list back until the filter has been applied#161
abernier merged 3 commits into
fix/set-state-in-effectfrom
fix/filter-flash

Conversation

@abernier

@abernier abernier commented Aug 9, 2026

Copy link
Copy Markdown
Member

Stacked on #160.

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. Bad trade for this rail.

What this does instead

The boot script already reads the URL (for ?nav=), so it also marks a filtered arrival on <html>; globals.css holds #example-list back 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, so the cards keep the layout the list windowing measures.

Two things came along for free:

  • The script is a real function now. 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 can't live in Nav.tsx: that's a "use client" module, so a server component importing from it gets a client reference, not the function.)
  • A filter beats a stored collapse in the script too, matching what Nav already did on the client — the rail no longer paints shut and swings open on a shared filter link.

Verified

pnpm lint / tsc --noEmit / next build clean; the minified inline script in the built HTML is self-contained. In the browser: ?q=fire and ?library=Drei 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 a filter link opens the rail without touching the stored preference.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y9J9K5TobdJo49EU4Z1Fp4

abernier and others added 3 commits August 9, 2026 12:33
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
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
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
@abernier
abernier merged commit ac243a0 into fix/set-state-in-effect Aug 9, 2026
2 checks passed
abernier added a commit that referenced this pull request Aug 9, 2026
* 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.


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.


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.


Claude-Session: https://claude.ai/code/session_01Y9J9K5TobdJo49EU4Z1Fp4

---------

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