fix(blog): prerender injects window.__ARTICLE__; component skips post-hydration re-fetch (soft 404 root cause)#34
Merged
Conversation
…-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>
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.
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.jsxalways rangetBlogPost(slug)in a useEffect at mount, settingloading: trueand discarding the prerendered DOM. A flaky API call during Googlebot's render then surfaced the error fallback, which Google indexed as the page body.BetaBannerandBlogIndexPagealready hadwindow.__BETA__/window.__BLOG_ARTICLES__globals injected by the prerender; onlyBlogArticlePagewas on the API-loading lottery.Fix (replicates existing pattern)
scripts/prerender.mjs: newfetchAllArticleContent(slugs)fetches/blog/{slug}for the 104 distinct slugs in parallel (concurrency 8). Per-article render injectswindow.__ARTICLE__=<the article>alongside the existing globals. Non-article routes do not carry the payload.src/pages/blog/BlogArticlePage.jsx: exported pure helpergetPrerenderedArticle(slug, win)readswindow.__ARTICLE__when the slug matches.postinitialised from the helper,loadingstartsfalsewhen 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 toapi.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:
Plus a process rule
CLAUDE.md gains a permanent rule: after every
gh pr merge, rungit pullbefore starting the next branch. This sprint's own confusion ("where did og-image.png go?") demonstrated the failure mode.Gates
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