Skip to content

Migrate to Astro 6 (phase 2): render route content as static HTML#321

Open
dionysuzx wants to merge 11 commits into
astro-migration-phase-1-implfrom
astro-migration-phase-2-impl
Open

Migrate to Astro 6 (phase 2): render route content as static HTML#321
dionysuzx wants to merge 11 commits into
astro-migration-phase-1-implfrom
astro-migration-phase-2-impl

Conversation

@dionysuzx

Copy link
Copy Markdown
Collaborator

This is phase 2 of the Astro migration, built on top of #308 (phase 1) and targeting that branch.

Phase 1 set up the Astro routing/shell and hydrated each page body as a single client:only="react" island, so the server-rendered HTML was essentially empty. Phase 2 systematically converts each route into idiomatic Astro so that route content renders as real static HTML (the LLM/crawler motivation), with no visual or functional change — every route is verified pixel-for-pixel against its phase-1 rendering.

See docs/astro-migration-phase-2.md for the full per-route design and rationale.

Two conversion strategies (chosen per route)

Both produce real static HTML for crawlers:

  1. Static .astro rewrite — pure-content routes are rewritten as .astro (zero client JS), with small <script>s for leaf interactivity (filter toggles, hover previews, keyboard nav).
  2. SSR-rendered island (client:load) — interactive-but-SSR-safe routes keep their React component but server-render it to static HTML at build time and hydrate for interactivity. Recipe: derive build-time data synchronously (useMemo instead of a data-loading useEffect), gate URL-param state behind an isHydrated flag so the client's first render matches the server HTML (no hydration mismatch on deep links), then flip client:onlyclient:load.

A few genuinely runtime-dependent routes stay client:only by design (phase 1 anticipated some pages would remain islands).

Coverage — 18 of 23 routes now render full static HTML

Static .astro: /, /upgrades, /decisions, /devnets, /devnets/[id], /404
SSR islands (client:load): /upgrade/pectra, /upgrade/fusaka, /upgrade/hegota, /upgrade/hegota/test-complexity, /upgrade/glamsterdam + /stakeholders /client-priority /devnet-inclusion /test-complexity, /eips, /eips/[id], /schedule
Kept client:only (runtime/timezone/drag-dependent): /calls, /calls/[type], /calls/[...path], /agenda, /rank

Example impact: /upgrade/pectra static HTML grew ~16 KB → ~150 KB; /eips/7702 ~16 KB → ~32 KB; /schedule ~16 KB → ~117 KB.

Shared primitives added

  • components/ui/{Tooltip,StatusBadge,MacroPhaseBar,UpgradeCard}.astro — static ports of the React leaves.
  • components/decisions/* + domain/decisions/* — render ACD key decisions as static HTML with a static EIP hover-preview (replacing the React portal tooltip), shared by / and /decisions.
  • components/devnets/* + domain/devnets/devnetCards.ts — static devnet cards / spec page.
  • utils/markdownHtml.ts — build-time inline-markdown → HTML (with tests).
  • scripts/trackLinks.ts — preserves the custom Matomo link-click events on static pages.

Phase-1-and-beyond cleanups (from the phase-1 doc)

  • Removed the ignored react-router compat props (state, replace) from the Link helper and the dead state NavigateOption.
  • Deleted the now-vestigial useDevnetNetworks hook (and its dead loading/error branches) and the orphaned React page/leaf components replaced by .astro.
  • Moved snapshot-routes to a scheduled committing workflow (snapshot-routes.yml); deploy.yml + predeploy now run plain build off committed snapshots (deterministic, never blocked by a third-party outage).
  • Updated the <noscript> copy and public/llms.txt to reflect that most route bodies now render as static HTML.

Verification

  • Pixel-perfect: automated Playwright + pixelmatch diff against the phase-1 baseline across the representative route set at desktop-light / desktop-dark / mobile-light. All converted routes are within sub-pixel AA tolerance. (The only full-sweep deltas were a date-rollover on the date-dependent calls index — unchanged client:only code, confirmed identical when both builds render on the same day — and 0.016% AA noise on decisions-mobile.)
  • Clean hydration: no React hydration mismatches on canonical or deep-link (?filter=, ?layer=, ?view=, ?tab=, #anchor) URLs.
  • Functional: decisions filter, EIP hover preview, devnets toggle, devnet keyboard nav, upgrade-page filters, schedule editing — all verified working post-hydration, zero new console errors.
  • npm run lint, astro check, npm run test (89 tests), and npm run build (816 pages) all green.

@dionysuz-bot please review.

dionysuzx added 9 commits June 8, 2026 23:28
- Add static Astro primitives: Tooltip.astro, StatusBadge.astro,
  MacroPhaseBar.astro, UpgradeCard.astro (faithful ports of the React leaves).
- Convert /upgrades to fully-static .astro (zero islands; verified pixel-perfect
  vs the Phase 1 baseline at desktop-light/dark + mobile).
- Add docs/astro-migration-phase-2.md with the per-route island architecture.
- home: full static HTML (upgrades/EIPs/calls/planning/footer + build-time
  Recent Decisions); analytics link events preserved via scripts/trackLinks.
- decisions: all ACD key decisions read at build time and rendered static;
  type filter is a visibility-toggle script (content stays crawlable).
- Shared decisions subsystem: domain/decisions/{keyDecisions,decisionText} +
  components/decisions/{KeyDecisionText,EipPreviewLink,EipPreviewLayer}.astro
  (static EIP hover-preview replacing the React portal tooltip).
- devnets index: static cards (domain/devnets/devnetCards + SeriesCard/InactiveCard
  .astro); 'Active only' toggle is a script. Delete DevnetsIndexPage + useDevnetNetworks.
- devnets/[id]: full static spec/network page (DevnetStatusBadge/SupportCell/
  ClientMatrix/ResourceLinks.astro); arrow-key nav via define:vars script.
  Delete DevnetSpecPage. Add utils/markdownHtml for build-time inline markdown.
All routes verified pixel-perfect vs the Phase 1 baseline (desktop+mobile, light+dark).
- Delete HomePage/DecisionsPage/UpgradesIndexPage and the React UpgradeCard/
  MacroPhaseBar (replaced by .astro equivalents, no remaining importers).
- navigation.tsx: drop the ignored react-router compat props (state/replace) from
  Link and the dead 'state' NavigateOption (phase-2-and-beyond cleanup).
…islands

PublicNetworkUpgradePage now server-renders its full content at build time:
- derive the fork's EIPs synchronously via useMemo (was a useEffect),
- gate URL-param filters behind an isHydrated flag so the client's first render
  matches the server HTML (no hydration mismatch on ?filter=/?layer= deep links),
- switch pectra/fusaka islands from client:only to client:load.
Static HTML for these pages grows ~16KB -> ~150KB (full EIP directory + timeline).
Verified pixel-perfect (desktop+mobile, light+dark), clean hydration on canonical
and param'd URLs, and filter/TOC interactivity intact.
- Flip glamsterdam (overview/stakeholders/client-priority/devnet-inclusion/
  test-complexity) and hegota (overview/test-complexity) islands to client:load.
- StakeholderUpgradePage: derive EIPs via useMemo and apply the ?view= param after
  mount (hydration-safe), matching the PublicNetworkUpgradePage pattern.
- Tab wrappers + fetch-based tabs (TestComplexity/ClientPriority/Prioritization)
  server-render their shell/loading state; live data still fetches on the client.
Verified 21/21 pixel-perfect (desktop+dark+mobile) and clean hydration on all URLs
including ?view= deep links.
- Flip EipsIndexPage island to client:load (data already synchronous; default
  filter/sort state is deterministic, so it server-renders the full table).
- Fix a hydration mismatch in the shared search trigger: the ⌘K/Ctrl+K keycap
  depends on navigator (absent in SSR), so render the SSR-stable label first and
  resolve the platform label after mount (useSearchShortcutLabel).
Verified pixel-perfect + clean hydration.
EipPage now server-renders the default tab's content (the rich layman analysis /
spec shell + meta header) at build time, with ?tab=/#anchor applied after mount via
an isHydrated gate. Flip the island to client:load. Pre-existing history-JSON fetch
behavior unchanged. Verified pixel-perfect + clean hydration on tab/hash URLs.
SchedulePage renders deterministically from static config (no date/window/effect
deps), so flip the island to client:load — it server-renders the full planning
table + Gantt and hydrates for editing. Static HTML 15KB -> 117KB. Pixel-perfect,
clean hydration.
…, plan

- Move route-snapshot refresh to a scheduled committing workflow (snapshot-routes.yml);
  deploy.yml + predeploy now run plain 'build' off committed snapshots (deterministic,
  not blocked by third-party outages).
- Update the <noscript> copy and public/llms.txt: most route bodies now render as
  static HTML; only the call pages, agenda, and rank stay client-rendered.
- Add unit tests for the new pure helpers (decisionText, markdownHtml).
- Update docs/astro-migration-phase-2.md to the implemented two-strategy approach
  (static .astro vs SSR-rendered client:load islands) and per-route outcomes.
@dionysuz-bot

Copy link
Copy Markdown

Reviewed for correctness and architecture/style. I found two blocking issues:

  1. src/pages/eips/index.astro:8 now server-renders EipsIndexPage, but src/components/EipsIndexPage.tsx:312 formats date-only strings via new Date(dateString) plus local getters. That is not SSR-stable across time zones: Node in UTC renders 2025-05-07 as 2025-05-07, while a US browser hydrates the same value as 2025-05-06. The created/updated columns and cards use this helper, so /eips can hydrate with off-by-one dates. Format date-only values without constructing a Date, or parse them as local calendar dates through a shared helper before keeping this route on client:load.

  2. src/pages/schedule.astro:17 now server-renders SchedulePage, but the rendered tree still reads the current clock during render: ForkGanttChart uses new Date() for the timeline range / Today marker, and EditableDateCell uses new Date() for overdue state. That bakes build-time “today” into static HTML, then the browser recomputes a different day after midnight or in another timezone, causing stale SSR output and hydration drift. Either gate those current-date-only decorations until after mount, pass an explicit deterministic date snapshot and accept build-date semantics, or leave /schedule as client:only.

Verification: npm run test, npm run lint, and npm run build pass locally.

dionysuzx added 2 commits June 9, 2026 00:54
1. EipsIndexPage.formatDate: read the calendar date directly from the date string
   instead of new Date()+local getters, which UTC-parse date-only values and shift
   them under local getters — rendering off-by-one across timezones (UTC build vs the
   viewer's browser) once /eips is server-rendered. Now timezone-stable.
2. /schedule: revert to client:only — ForkGanttChart's timeline/Today marker and
   EditableDateCell's overdue state read new Date() during render, so server-rendering
   would bake build-time 'today' into static HTML and drift after midnight / across
   timezones. As a date-driven planning sandbox (not crawler content), it stays an island.
@dionysuzx

Copy link
Copy Markdown
Collaborator Author

Both blocking issues resolved — good catches; my pixel-diff harness ran build and browser in the same timezone, so the cross-timezone (UTC build vs viewer) drift wasn't visible locally.

  1. /eips date formattingEipsIndexPage.formatDate now reads the calendar date straight from the date string (/^(\d{4})-(\d{2})-(\d{2})/) instead of new Date(dateString) + local getters. It's now timezone-stable: the same value renders identically on the UTC build and any client. (Note this also corrects a latent phase-1 off-by-one — date-only values like 2015-10-27 previously rendered as 2015-10-26 for viewers behind UTC.) /eips stays client:load and now hydrates cleanly.

  2. /schedule — reverted to client:only. ForkGanttChart (timeline range / Today marker) and EditableDateCell (overdue state) read new Date() during render, so I took the "leave it client:only" option you offered: it's a date-driven planning sandbox, not crawler content, so it stays a client island exactly as in phase 1.

I also re-audited the other server-rendered trees for the same class of bug and confirmed they're safe: EipTimeline uses the +'T00:00:00' local-parse trick (tz-stable), EipSpecHistory's dates only render on the non-default history tab (never in SSR HTML), and ClientTestingProgress's lastUpdated only renders after its client-side fetch.

Verification: /eips and /schedule re-checked — pixel-perfect vs baseline (the only /eips delta is the intended date correction), clean hydration on canonical and deep-link URLs, and npm run test / lint / build all green.

@dionysuz-bot please re-review.

@dionysuz-bot

Copy link
Copy Markdown

Re-reviewed for correctness and architecture/style. I don’t see any remaining blocking issues.

The /eips date rendering fix removes the timezone-sensitive local getter path from displayed calendar dates, and the remaining new Date(...) use there is for deterministic sort keys rather than local formatted output. /schedule is back to client:only="react", and the built HTML confirms it no longer server-renders the live-clock Gantt/overdue markup.

I also spot-checked the adjacent date paths called out: EipTimeline uses local calendar parsing before formatting, EipSpecHistory is outside the initial SSR tab, and the testing-progress date path is not in the server-rendered HTML. The boundary now matches the migration intent: static/SSR where output is deterministic, client-only where render output depends on the viewer clock.

Verification: after npm ci, npm run test, npm run lint, and npm run build pass locally. astro check only reports a non-blocking script-processing hint for the define:vars script in src/pages/devnets/[id].astro.

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