From 241f246a3d3548791592d30b324d81ae6b0a1696 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Sun, 28 Jun 2026 14:59:56 +0700 Subject: [PATCH] fix: make homepage drills hydration-safe --- docs/dev-net-core-seo-plan-handoff.md | 24 ++++++++++---- .../dev-net-core-seo-improvement-plan.md | 17 ++++++++++ src/pages/Home.tsx | 31 ++++++++----------- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/docs/dev-net-core-seo-plan-handoff.md b/docs/dev-net-core-seo-plan-handoff.md index 921e41e..f235b8f 100644 --- a/docs/dev-net-core-seo-plan-handoff.md +++ b/docs/dev-net-core-seo-plan-handoff.md @@ -478,7 +478,8 @@ The merged implementation completes the next readiness steps: - Invalid content and generic routes remain Not Found with `noindex`, no canonical, and no structured data. - The Pages workflow is now manual-only, runs the complete dual-build gate, and - still deploys the existing `dist/` SPA from `main`. + completed its first confirmed `framework` deployment from `main` at + `27d6f39`. The `spa-rollback` target remains available. - A read-only framework candidate workflow validates pull requests and pushes to `main` without uploading or deploying a Pages artifact. - Four stable candidate runs completed in 30-32 seconds. `Validate framework @@ -492,6 +493,13 @@ The merged implementation completes the next readiness steps: `dist/CNAME`, and `build-framework/client/CNAME`. - Cloudflare configuration remains unchanged for cutover; the unrelated Cloudflare Workers build integration was removed. +- Production validation passed all 227 sitemap URLs, canonical redirects, + sitemap discovery, dedicated Not Found behavior, and representative route + hydration. +- Homepage browser validation exposed one hydration mismatch: randomized Live + Drills differed between SSG and the first browser render. The local follow-up + now uses deterministic first, middle, and last candidates and passes repeated + browser loads without console errors. ## Work Not Yet Completed @@ -543,6 +551,10 @@ Do not deploy `build-framework/server`; React Router removes it under After cutover, test the Cloudflare-managed custom domain on GitHub Pages. +The first production route matrix passed on June 28, 2026. Keep this gate open +until the deterministic homepage drill fix is merged, redeployed, and confirmed +against the production browser console. + For every sitemap URL, verify: - direct request returns `200 OK` @@ -617,12 +629,12 @@ Then: ## Git State at Handoff -The latest merged readiness commit on `main` is: +The latest merged and deployed readiness commit on `main` is: ```txt -023d0f8 Merge pull request #3 from CircleQMinh/codex/require-framework-check +27d6f39 Merge pull request #4 from CircleQMinh/codex/framework-pages-cutover ``` -Step 15 pre-cutover work is developed on -`codex/framework-pages-cutover`. Commit each completed implementation step -locally. Do not push, merge, revert, or deploy without user approval. +The Step 15 homepage hydration follow-up is developed on +`codex/fix-homepage-drill-hydration`. Commit each completed implementation +step locally. Do not push, merge, revert, or deploy without user approval. diff --git a/docs/plans/dev-net-core-seo-improvement-plan.md b/docs/plans/dev-net-core-seo-improvement-plan.md index 349d099..a56dc74 100644 --- a/docs/plans/dev-net-core-seo-improvement-plan.md +++ b/docs/plans/dev-net-core-seo-improvement-plan.md @@ -2982,6 +2982,23 @@ Validation: - Confirm non-canonical route variants redirect to or canonicalize to trailing-slash URLs. - Confirm valid trailing-slash routes direct-load in production. +Current Step 15 status as of June 28, 2026: + +- The first guarded `framework` deployment completed successfully from + `main` at commit `27d6f39`. +- All 227 sitemap URLs passed direct production checks for `200 OK`, canonical + trailing-slash URLs, indexability, source titles, and visible headings. +- HTTPS, apex-to-`www`, non-trailing route redirects, `robots.txt`, and the + dedicated `404` + `noindex` response passed. +- Initial browser validation found a homepage-only React hydration mismatch + because the Live Drills list used `Math.random()` independently during SSG + and browser hydration. +- The homepage now selects deterministic first, middle, and last drill + candidates. Local framework preview validation passes repeated homepage + loads with identical drill content and no console errors. +- Keep Step 15 open until this fix is merged, redeployed, and the production + browser hydration check passes. + ### Task 16: Search Console Setup No code changes required. diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index b2f6d44..824ec9b 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -11,7 +11,6 @@ import StorageOutlinedIcon from "@mui/icons-material/StorageOutlined"; import SyncOutlinedIcon from "@mui/icons-material/SyncOutlined"; import TerminalOutlinedIcon from "@mui/icons-material/TerminalOutlined"; import { Box, Button, Container, Stack } from "@mui/material"; -import { useMemo } from "react"; import type { ReactNode } from "react"; import { Link as RouterLink } from "react-router-dom"; import { @@ -199,27 +198,25 @@ function scrollToPageTop() { }); } -function getRandomDrills() { +function getFeaturedDrills() { const candidates = liveDrillCandidates.length >= 3 ? liveDrillCandidates : fallbackDrills; - return shuffleDrills(candidates).slice(0, 3); -} - -function shuffleDrills(drills: Drill[]) { - const shuffledDrills = [...drills]; - - for (let index = shuffledDrills.length - 1; index > 0; index -= 1) { - const randomIndex = Math.floor(Math.random() * (index + 1)); - const currentDrill = shuffledDrills[index]; - - shuffledDrills[index] = shuffledDrills[randomIndex]; - shuffledDrills[randomIndex] = currentDrill; + if (candidates.length <= 3) { + return candidates.slice(0, 3); } - return shuffledDrills; + const lastIndex = candidates.length - 1; + + return [ + candidates[0], + candidates[Math.floor(lastIndex / 2)], + candidates[lastIndex], + ]; } +const featuredDrills = getFeaturedDrills(); + const roadmapSteps: RoadmapStep[] = [ { id: "01", @@ -530,8 +527,6 @@ function RoadmapStepCard({ step }: { step: RoadmapStep }) { } export default function Home() { - const drills = useMemo(() => getRandomDrills(), []); - return ( @@ -630,7 +625,7 @@ export default function Home() {
- {drills.map((drill) => ( + {featuredDrills.map((drill) => ( ))}