Keep the list back until the filter has been applied - #161
Merged
Conversation
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
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>
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.
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 auseSearchParamsconsumer (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.cssholds#example-listback untilNavtakes the mark over, in the same handoff asdata-nav-collapsed. Only filtered arrivals pay anything; everyone else keeps the prerendered list painting exactly as before.visibility, notdisplay, so the cards keep the layout the list windowing measures.Two things came along for free:
String(bootNav)is what lands in the page, so the logic is typed, formatted and linted like the rest oflayout.tsxinstead 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 inNav.tsx: that's a"use client"module, so a server component importing from it gets a client reference, not the function.)Navalready did on the client — the rail no longer paints shut and swings open on a shared filter link.Verified
pnpm lint/tsc --noEmit/next buildclean; the minified inline script in the built HTML is self-contained. In the browser:?q=fireand?library=Dreinever 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