Make loading-screen state per-request; and why the testimonial-seed fix was reverted - #238
Conversation
useLoadingScreen kept isLoading in a module-scope ref. On the server that module is created once per worker and shared by every concurrent request, which Nuxt documents as unsafe: useAsyncData awaits during SSR, so two requests interleave and one can resume and render using the other's flag. useState is per-request on the server and serialised into the payload. Behaviour within a request is unchanged, which the tests pin down -- including the quirk that calling with no arguments *writes* false rather than reading, because [].some() is false. The order in app.vue (LoadingScreen before <nuxt-page />) is what keeps that from clobbering the page's value. Not claimed as a fix for the podcast-page hydration mismatch: isLoading is false on both sides in normal operation, and I did not reproduce a live race. This stands on the documented anti-pattern, not on a reproduction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR updates the Nuxt-side loading overlay state to avoid module-scope shared state during SSR by moving the isLoading flag into Nuxt’s per-request useState, and adds unit tests that lock in the current behavior across “requests” and within a single request.
Changes:
- Replace module-scope
ref(false)withuseState('loading-screen')insideuseLoadingScreento prevent cross-request SSR state sharing. - Add Vitest coverage for request isolation, intra-request sharing, and reactive updates of the loading flag.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| nuxt-app/composables/useLoadingScreen.ts | Moves isLoading into Nuxt useState for per-request SSR isolation while preserving existing semantics. |
| nuxt-app/test/useLoadingScreen.test.ts | Adds tests that simulate per-request state via a stubbed useState store and verify behavior. |
Suppressed comments (1)
nuxt-app/composables/useLoadingScreen.ts:22
useLoadingScreen()always writesfalsewhen called with no arguments ([].some(...) === false) and also installs awatch([]); sinceLoadingScreen.vuecalls it with no args just to read the flag, this makes the loading state depend on call order and can accidentally clear a page's loading state. Consider splitting read vs write (e.g., a read-onlyuseLoadingScreenState()plus an explicitdisableLoadingScreen()/setLoadingScreenFromData(...)) so read access cannot mutate state.
isLoading.value = dataList.some((data) => !data.value)
// Change state on update
watch(dataList, () => {
isLoading.value = dataList.some((data) => !data.value)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Initialises the fake useState store eagerly so the harness cannot depend on beforeEach having run. Declines the suggested read/write split, and records why in the composable rather than only in the pull request: 8 of 34 pages never call useLoadingScreen, so the no-argument call in LoadingScreen.vue is what clears the overlay when navigating to one of them. Making read access non-mutating without a route-change reset first would leave a full-screen overlay stuck on those pages. The review bot proposing that removal is itself the argument for the comment being in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf
On the suggested read/write split — declining it, with evidenceThe suppressed comment on The diagnosis is right. The proposed fix would introduce a bug. That no-argument write is what clears the overlay, and it is load-bearing because 8 of 34 pages never call this composable at all: So the failure mode of making read access non-mutating is concrete: page A sets What the real fix looks like, for whenever someone picks this up: reset on route change rather than on component read — or drop this composable entirely in favour of Nuxt's own I've put the reason in the file rather than only here, because this exchange is the argument for it: a reader with good instincts looked at that line, correctly identified it as bad design, and proposed a change that would break 8 pages. That is exactly the "looks removable, is not" case that earns a comment. // Called with no arguments this writes `false` rather than reading, which is how
// `LoadingScreen.vue` clears the overlay. Keep that: 8 of 34 pages never call this composable, so
// without the reset, navigating to one of them from a page that was still loading would leave a
// full-screen overlay stuck. Splitting read from write needs a route-change reset first.Gates re-run after both changes: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
nuxt-app/composables/useLoadingScreen.ts:26
useLoadingScreen()is called with no arguments byLoadingScreen.vue, which means this function currently sets up awatch([] , ...). That watcher can never trigger and is unnecessary work (and can produce confusing warnings in some Vue dev setups). Guard the watcher so it only registers when there is at least one ref to track.
// Change state on update
watch(dataList, () => {
isLoading.value = dataList.some((data) => !data.value)
})
nuxt-app/composables/useLoadingScreen.ts:20
- The comment hard-codes page counts ("8 of 34 pages") which is already out of date and will keep drifting as routes change. Reword to describe the intent without relying on a specific count.
// Called with no arguments this writes `false` rather than reading, which is how
// `LoadingScreen.vue` clears the overlay. Keep that: 8 of 34 pages never call this composable, so
// without the reset, navigating to one of them from a page that was still loading would leave a
// full-screen overlay stuck. Splitting read from write needs a route-change reset first.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
nuxt-app/composables/useLoadingScreen.ts:25
useLoadingScreentreats any falsy value as "still loading" (!data.value). Several callers pass count refs (e.g.getSpeakersCount()/getPickOfTheDayCount()), which can legitimately resolve to0; in that case the overlay would stay stuck even though the request finished. Consider treating onlynull/undefinedas pending.
isLoading.value = dataList.some((data) => !data.value)
// Change state on update
watch(dataList, () => {
isLoading.value = dataList.some((data) => !data.value)
nuxt-app/test/useLoadingScreen.test.ts:71
- Add a regression test that a legitimately loaded value like
0does not keep the loading screen active. This matters because some pages pass count refs intouseLoadingScreen, and counts can be zero in empty/dev datasets.
it('tracks its data list within a request', async () => {
const { useLoadingScreen } = await import('../composables/useLoadingScreen')
const data = ref<unknown>(null)
const state = useLoadingScreen(data)
Owed from #237 and #238. Part of this section had become actively wrong. Retracts the claim that useWeightedRandomSelection was a latent hydration-mismatch source. TestimonialSlider wraps its list in <ClientOnly>, so testimonials are never server-rendered and the hourly seed cannot participate in hydration. Keeps the correction rather than deleting the claim, because the reasoning looked sound and the negative control is what disproved it: with the client clock shifted past an hour boundary the unfixed build produced zero warnings too. Records the useLoadingScreen fix and, explicitly, that it is not claimed as the mismatch fix -- so both leads are now eliminated and the item stays open with the dev-mode-from-clean-.nuxt next step named. Updates the v-html write-up for helpers/sanitize.ts, which is now the only module touching DOMPurify. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf
Plan: amend for the ClientOnly finding, #238, and the sanitiser module
You asked for both fixes. One shipped, one turned out not to be a bug — and I reverted it rather than ship a change that fixes nothing.
Branched off
mainrather than the shared branch so it doesn't tangle with #237, which is still open.Shipped:
useLoadingScreenwas module-scope stateA module-scope
refis created once per server worker and shared by every concurrent request — the footgun Nuxt documents explicitly. The mechanism is real here:useAsyncDataawaits during SSR, so two requests interleave, and request A can resume and render using request B's flag.useStateis per-request on the server and serialised into the payload. Confirmed present as"loading-screen"in the payload after the change.What I did not do: claim this fixes the podcast-page hydration mismatch.
isLoadingisfalseon both sides in normal operation, and I did not reproduce a live race — hammering six pages concurrently, three rounds, never rendered the overlay. That shows no regression, not a demonstrated fix. This stands on the documented anti-pattern, not on a reproduction, and I'd rather say so than imply I caught it in the act.Behaviour within a request is unchanged, pinned by three tests — including the quirk worth knowing about: calling
useLoadingScreen()with no arguments writesfalserather than reading, because[].some(...)is false. It reads like a getter and isn't one. The order inapp.vue(LoadingScreenbefore<nuxt-page />) is the only thing stopping that from clobbering the page's value.Reverted:
useWeightedRandomSelectionis not a hydration-mismatch sourceI had told you the hourly seed was "a genuine latent instance of this bug class". That was wrong. The evidence:
I wrote the fix (seed pinned in
useState), then built a browser test that reproduces the real trigger —addInitScriptshifting the client's clock 61 minutes before any page JS runs, so the client's first render lands in the next hourly bucket:/meetup/konferenz/The negative control passing means the test proves nothing — so I looked for why, instead of shipping it:
<ClientOnly> <GenericLazyList class="flex" :items="randomizedTestimonials" …>TestimonialSlider.vue:10wraps the entire list in<ClientOnly>. Testimonials are never server-rendered — confirmed against production, where the SSR HTML contains no testimonial text at all, only the component's stylesheet link. There is nothing for hydration to compare, so the seed cannot cause a mismatch however far the clocks diverge.The change would also have made things slightly worse: pinning the seed in the payload ties rotation to when the page was cached rather than when it is viewed, and adds a payload entry — for no benefit. So it is reverted.
(13 testimonials exist against a
maxCountof 5, so the weighted selection really does run. The path is reachable; it just isn't part of SSR.)The podcast-page mismatch is still unexplained
Both of my leads are now ruled out. The open item stands, and everything ruled out so far is recorded in #237's plan entry so nobody repeats it.
Verification
linttestbuildprettier --checkPlan document deliberately untouched here. The hydration write-up lives in #237, which is still open; editing the same paragraphs from two branches would guarantee a conflict. I'll amend the plan — recording the
ClientOnlyfinding and this fix — in a follow-up once #237 merges.🤖 Generated with Claude Code
https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf