Skip to content

fix(blog): prerender injects window.__ARTICLE__; component skips post-hydration re-fetch (soft 404 root cause)#34

Merged
miquelmatoses merged 1 commit into
mainfrom
fix/blog-article-prerender-hydration
May 23, 2026
Merged

fix(blog): prerender injects window.__ARTICLE__; component skips post-hydration re-fetch (soft 404 root cause)#34
miquelmatoses merged 1 commit into
mainfrom
fix/blog-article-prerender-hydration

Conversation

@miquelmatoses
Copy link
Copy Markdown
Collaborator

Soft 404 root cause

GSC URL Inspection reported the blog body as "Could not load article" while the header and beta banner rendered fine. Reason: BlogArticlePage.jsx always ran getBlogPost(slug) in a useEffect at mount, setting loading: true and discarding the prerendered DOM. A flaky API call during Googlebot's render then surfaced the error fallback, which Google indexed as the page body. BetaBanner and BlogIndexPage already had window.__BETA__ / window.__BLOG_ARTICLES__ globals injected by the prerender; only BlogArticlePage was on the API-loading lottery.

Fix (replicates existing pattern)

  • scripts/prerender.mjs: new fetchAllArticleContent(slugs) fetches /blog/{slug} for the 104 distinct slugs in parallel (concurrency 8). Per-article render injects window.__ARTICLE__=<the article> alongside the existing globals. Non-article routes do not carry the payload.
  • src/pages/blog/BlogArticlePage.jsx: exported pure helper getPrerenderedArticle(slug, win) reads window.__ARTICLE__ when the slug matches. post initialised from the helper, loading starts false when the global was present. The fetch useEffect skips entirely when the global already matched (direct landings on prerendered routes), falls through on client-side navigation. A late refresh failure no longer wipes existing content.
  • src/pages/blog/__tests__/getPrerenderedArticle.test.js: 5 unit tests (match, mismatch, no global, SSR, malformed).
  • scripts/validate_prerender_global.mjs: runtime puppeteer GATE. Builds dist/, serves with SPA fallback, BLOCKS every request to api.cercol.team, asserts (A) with __ARTICLE__ injected the article body renders; (B) without the global the body does NOT render. Proves the global is doing the work.

Local gate result:

[gate] Case A: API blocked + window.__ARTICLE__ injected ...
[gate]   blocked 3 API request(s)
[gate] Case B: API blocked + NO __ARTICLE__ ...
[gate]   blocked 2 API request(s)
[gate] PASS

Plus a process rule

CLAUDE.md gains a permanent rule: after every gh pr merge, run git pull before starting the next branch. This sprint's own confusion ("where did og-image.png go?") demonstrated the failure mode.

Gates

  • pytest 117 + 21s
  • vitest 204 (+5)
  • vite build OK
  • ruff 8 baseline

Post-merge action for the operator

Once the deploy lands and the new prerender ships, in GSC re-request indexing of the articles that were reporting soft 404 so Google re-fetches with the working page.

🤖 Generated with Claude Code

…-hydration re-fetch

Root cause of the soft-404 reports in GSC URL Inspection: every blog
article ran getBlogPost(slug) inside a useEffect at mount, which
SET LOADING TRUE and discarded the prerendered DOM. A flaky API call
during Googlebot's render then surfaced "Could not load article" and
Google indexed that fallback as the page body. The header and beta
banner rendered fine because BetaBanner and BlogIndexPage already
read window.__BETA__ / window.__BLOG_ARTICLES__ injected by the
prerender; only BlogArticlePage had no equivalent global.

Fix mirrors the existing pattern:

- scripts/prerender.mjs `fetchAllArticleContent(slugs)` pulls
  /blog/{slug} for each of the ~104 distinct slugs in parallel
  (concurrency 8) and returns a Map<slug, article>. Per-article
  render injects `window.__ARTICLE__=<the article>` alongside the
  existing globals; non-article routes do not carry the payload.
  Routes matched with `/^\/(?:([a-z]{2})\/)?blog\/([^/]+)\/?$/`,
  so `/blog`, `/<lang>/blog` (index) get no __ARTICLE__.

- src/pages/blog/BlogArticlePage.jsx: new pure helper
  `getPrerenderedArticle(slug, win)` reads window.__ARTICLE__ when
  the slug matches and is exported for unit tests. `post` is now
  initialised from this helper; `loading` starts false when the
  global was present. The fetch useEffect skips the API call
  entirely when the global already matched the slug; on client-side
  navigation (slug mismatch) it falls through and runs as before.
  A failed late refresh no longer wipes out existing content -
  setPost(prev) keeps the previous article and only sets `error`
  when there is nothing else to show.

- src/pages/blog/__tests__/getPrerenderedArticle.test.js: 5
  vitest cases (match, mismatch, no global, SSR, malformed
  global).

- scripts/validate_prerender_global.mjs: a runtime puppeteer gate.
  Builds dist/ with vite (no prerender), serves it with SPA
  fallback, blocks every request to api.cercol.team, and asserts:
  Case A (with __ARTICLE__ injected) renders the article body;
  Case B (no global) does not. The "before-fix" comparison
  control proves the new path is doing the work.
  Local run: PASS.

CLAUDE.md gains a new permanent rule in "Claude Code workflow":
after every `gh pr merge`, run `git pull` before starting the next
branch. The recent "where did public/og-image.png go?" confusion
on this very sprint demonstrated the failure mode; treat the sync
as non-optional.

Gate results:
- pytest 117 + 21s.
- vitest 204 passed (+5).
- vite build OK.
- ruff 8 baseline.
- Puppeteer gate (API blocked + global) PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@miquelmatoses miquelmatoses merged commit f0b0447 into main May 23, 2026
7 checks passed
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.

1 participant