From ec9a4b4458457f8b30c6c621410de2ae2fd4375e Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 4 Aug 2026 10:49:59 +0200 Subject: [PATCH 1/2] Plan: amend for the ClientOnly finding, #238, and the sanitiser module 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 , 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) Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf --- docs/dependency-upgrade-plan.md | 64 ++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/docs/dependency-upgrade-plan.md b/docs/dependency-upgrade-plan.md index 7f34eb48..79e181b5 100644 --- a/docs/dependency-upgrade-plan.md +++ b/docs/dependency-upgrade-plan.md @@ -1340,11 +1340,26 @@ So the swap needed real text, not tag-stripped HTML: | regex + `{{ }}` (the naive fix) | ❌ shows `für` | ❌ | ✅ inert | | `getPlainText` + `{{ }}` (shipped) | ✅ `für` | ✅ `&` | ✅ inert | -`helpers/getPlainText.ts` sanitises with `ALLOWED_TAGS: []` and `RETURN_DOM_FRAGMENT`, then reads -`textContent`. That returns genuine text with every entity decoded, which is safe for `{{ }}` and must -never be handed to `v-html`. It parses instead of pattern-matching, which is the whole point: a regex -cannot match a tag containing `<` or `>`, so ` src=x onerror=alert(1)>` survived -`/<[^<>]+>/g` as a working tag. +`getPlainText` sanitises with `ALLOWED_TAGS: []` and `RETURN_DOM_FRAGMENT`, then reads `textContent`. +That returns genuine text with every entity decoded, which is safe for `{{ }}` and must never be handed +to `v-html`. It parses instead of pattern-matching, which is the whole point: a regex cannot match a tag +containing `<` or `>`, so ` src=x onerror=alert(1)>` survived `/<[^<>]+>/g` as a working tag. + +**`helpers/sanitize.ts` is the only module that touches DOMPurify**, after a review comment on #237 +pointed out that the two ProfileCreation components had each grown their own +`computed(() => DOMPurify.sanitize(...))`, duplicating `InnerHtml.vue`. It cited this repo's own rule +in `AGENTS.md` — never duplicate logic across modules — and was right. Three exports: + +| export | for | +| --- | --- | +| `sanitizeHtml` | default policy, anything bound to `v-html` | +| `sanitizeInlineHtml` | as above but forbids `

`, for the news ticker's single scrolling line | +| `getPlainText` | real plain text, for `{{ }}` | + +`InnerHtml.vue` and `NewsTicker.vue` were routed through it too, rather than fixing only the two new +duplicates — otherwise two of four policies would still have been inline, which is the appearance of +the rule rather than the rule. `NewsTicker`'s `{ FORBID_TAGS: ['p'] }` became a named export rather +than a config parameter, so DOMPurify options do not leak back out to callers. It is deliberately **not** re-exported from `helpers/index.ts`. That barrel is imported by server routes, and pulling `isomorphic-dompurify` through it would instantiate jsdom for consumers that only @@ -1501,12 +1516,19 @@ computed on the podcast index. Fixing two lines restores type checking to a lot one (via a cache-busting query) are **byte-identical**, so SSR output is stable over time. - **`useNow.ts`.** Not used on this page at all — only `pages/konferenz/[slug]/index.vue` — and it exists specifically to avoid this, by serialising the SSR timestamp into the payload. - - **`useWeightedRandomSelection` / `TestimonialSlider`.** Not on this page either. **But it is a - genuine latent instance of this bug elsewhere** — it seeds off `Math.floor(Date.now() / - 3600000)`, an hour bucket, and renders on `/`, `/meetup`, `/konferenz` and - `/konferenz/[slug]`. With `isr: 3600`, HTML cached in one hour bucket can be hydrated by a - client in the next, selecting different testimonials. Those pages showed no warning when - checked, which only means the check did not straddle a boundary. + - **`useWeightedRandomSelection` / `TestimonialSlider`.** This document previously called it "a + genuine latent instance of this bug elsewhere", on the grounds that it seeds off + `Math.floor(Date.now() / 3600000)` and renders on four `isr: 3600` pages. **That was wrong, and + the correction is worth keeping**, because the reasoning looked sound: `TestimonialSlider.vue` + wraps its entire list in **``**, so testimonials are never server-rendered — + confirmed against production, whose SSR HTML contains no testimonial text, only the + component's stylesheet link. There is nothing for hydration to compare, however far the clocks + diverge. + + A fix was written and then reverted (2026-08-04). What settled it was a negative control: a + browser test shifted the client clock 61 minutes before any page JS ran, and **the unfixed + build produced zero warnings too**, so the test proved nothing. Pinning the seed would also + have tied rotation to when the page was *cached* rather than when it is viewed. - **The `` that SSR emits as the first child of `

`.** It looked conclusive — it is absent from the hydrated DOM — but it appears on *every* page, including the three that produce no warning. It is an artefact of comparing `innerHTML`: browsers put @@ -1517,12 +1539,17 @@ computed on the podcast index. Fixing two lines restores type checking to a lot against a server-rendered real page and produced a cascade of mismatch warnings that are artefacts of that failure. Retry from a clean `.nuxt` before trusting anything it reports. - **The most promising lead is a real bug in its own right:** `composables/useLoadingScreen.ts` - holds `isLoading` in a **module-scope `ref`**. On the server that module is instantiated once per - worker, so the flag is shared across concurrent requests — one visitor's navigation can change - what another's SSR renders. `` is the first child of `
`, exactly where the - divergence appears. Fix that regardless of whether it turns out to be this mismatch: SSR state - belongs in `useState`, not a module-level `ref`. + **`useLoadingScreen` was fixed anyway** (#238, 2026-08-04) — it held `isLoading` in a + module-scope `ref`, created once per server worker and shared by every concurrent request, which + is the footgun Nuxt documents. `useAsyncData` awaits during SSR, so requests interleave and one + can resume rendering with another's flag. Now `useState`, which is per-request. + + **It is probably not this mismatch**, and was not shipped as though it were: `isLoading` is + `false` on both sides in normal operation, and hammering six pages concurrently never reproduced + a leak. That fix rests on the documented anti-pattern, not on a reproduction. + + So **both leads are now eliminated and this remains open.** The next thing to try is the dev-mode + reproduction from a clean `.nuxt`, since that is the only tool that names the offending element. - [ ] ⚠️ **Remove `nitro.externals.inline: ['pinia']`.** Waiting on an upstream Pinia fix — see [Waiting on upstream: the Pinia 4 export map](#waiting-on-upstream-the-pinia-4-export-map) below for exactly what to watch for and how to check. @@ -1969,6 +1996,9 @@ Tracked so nobody has to rediscover them. None are urgent on their own. | 2026-08-03 | Keep jsdom for anything still using DOMPurify. Its mXSS handling depends on parsing in a real DOM — verified when `