Keep the list back until the filter has been applied - #162
Merged
Conversation
* 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>
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.
Re-targets #161, which was opened against
fix/set-state-in-effectto stack on #160 — but #160 was squashed intomaintwo minutes earlier, so merging #161 landed it on a branchmainno longer tracks. Same content, cherry-picked ontomain: the three touched files were byte-identical betweenmainand #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 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.What this does instead
bootNavalready reads the URL (for?nav=), so it also marks a filtered arrival on<html>;globals.cssholds the list back behind skeletons 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: 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
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 cannot live inNav.tsx: that is a"use client"module, so a server component importing from it gets a client reference, not the function.)hide/showno longer arrives late. It was held back until mounted because the collapsed state came fromlocalStorage; it now reaches the pre-paint mark throughuseSyncExternalStore, so the word is right on the first client render and the pill stops reflowing around it.Navalready did on the client.Known
hidethen 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 bothbootNavandNav. 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 fromlocalStoragealone.Verified
pnpm lint/tsc --noEmit/next buildclean; 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