Skip to content

Make loading-screen state per-request; and why the testimonial-seed fix was reverted - #238

Merged
Jan0707 merged 3 commits into
mainfrom
fix-ssr-state
Aug 4, 2026
Merged

Make loading-screen state per-request; and why the testimonial-seed fix was reverted#238
Jan0707 merged 3 commits into
mainfrom
fix-ssr-state

Conversation

@Jan0707

@Jan0707 Jan0707 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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 main rather than the shared branch so it doesn't tangle with #237, which is still open.

Shipped: useLoadingScreen was module-scope state

-// Global is loading state
-const isLoading = ref(false)
-
 export function useLoadingScreen(...dataList: Ref<unknown>[]) {
+    const isLoading = useState<boolean>('loading-screen', () => false)

A module-scope ref is created once per server worker and shared by every concurrent request — the footgun Nuxt documents explicitly. The mechanism is real here: useAsyncData awaits during SSR, so two requests interleave, and request A can resume and render using request B's flag.

useState is 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. isLoading is false on 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 writes false rather than reading, because [].some(...) is false. It reads like a getter and isn't one. The order in app.vue (LoadingScreen before <nuxt-page />) is the only thing stopping that from clobbering the page's value.

Reverted: useWeightedRandomSelection is not a hydration-mismatch source

I 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 — addInitScript shifting the client's clock 61 minutes before any page JS runs, so the client's first render lands in the next hourly bucket:

build /meetup /konferenz /
with the seed fix 0 warnings 0 0
negative control (old recomputing seed) 0 warnings 0 0

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:10 wraps 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 maxCount of 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

gate result
lint 0 errors, 134 warnings (unchanged)
test 55/55 (52 + 3 new)
ratchet 263 (unchanged)
build exit 0, 31.3 MB (unchanged)
prettier --check clean on both changed files

Plan 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 ClientOnly finding and this fix — in a follow-up once #237 merges.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf

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
Copilot AI lite review requested due to automatic review settings August 3, 2026 17:11
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
programmierbar-website Ready Ready Preview Aug 4, 2026 7:12am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) with useState('loading-screen') inside useLoadingScreen to 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 writes false when called with no arguments ([].some(...) === false) and also installs a watch([]); since LoadingScreen.vue calls 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-only useLoadingScreenState() plus an explicit disableLoadingScreen()/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.

Comment thread nuxt-app/test/useLoadingScreen.test.ts Outdated
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
Copilot AI review requested due to automatic review settings August 4, 2026 07:01
@Jan0707

Jan0707 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

On the suggested read/write split — declining it, with evidence

The suppressed comment on useLoadingScreen.ts:22 is the more interesting one, and it's the same quirk I'd flagged in the PR body: calling with no arguments writes false rather than reading, so the loading state depends on call order. The suggestion is to split read from write — a read-only useLoadingScreenState() plus an explicit setter.

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:

pages/admin/checkin.vue
pages/kommt-bald.vue
pages/konferenz/[slug]/tickets/index.vue
pages/konferenz/[slug]/tickets/success.vue
pages/newsletter/confirm.vue
pages/newsletter/unsubscribe.vue
pages/speaker-portal.vue
pages/ticket-portal.vue

So the failure mode of making read access non-mutating is concrete: page A sets isLoading = true while its data is pending → the visitor navigates client-side to /ticket-portal → nothing resets the flag → a full-screen black overlay stuck over the page, with no way to clear it short of a reload. On 8 routes, including the ticket checkout and the check-in tool.

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 useLoadingIndicator / <NuxtLoadingIndicator>, which is route-aware by design and would delete the whole hand-rolled mechanism. Either is a behaviour change to a full-screen overlay and belongs in its own PR, not inside an SSR-state fix.

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: lint 0 errors / 134 warnings, test 55/55, ratchet 263, prettier --check clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 by LoadingScreen.vue, which means this function currently sets up a watch([] , ...). 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.

Copilot AI review requested due to automatic review settings August 4, 2026 07:11
@Jan0707
Jan0707 merged commit d07aa78 into main Aug 4, 2026
4 of 5 checks passed
@Jan0707
Jan0707 deleted the fix-ssr-state branch August 4, 2026 07:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • useLoadingScreen treats any falsy value as "still loading" (!data.value). Several callers pass count refs (e.g. getSpeakersCount() / getPickOfTheDayCount()), which can legitimately resolve to 0; in that case the overlay would stay stuck even though the request finished. Consider treating only null/undefined as 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 0 does not keep the loading screen active. This matters because some pages pass count refs into useLoadingScreen, 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)

Jan0707 added a commit that referenced this pull request Aug 4, 2026
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
Jan0707 added a commit that referenced this pull request Aug 4, 2026
Plan: amend for the ClientOnly finding, #238, and the sanitiser module
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.

2 participants