Skip to content

fix(seo): keep canonical and og:url in sync with client-side navigation - #25

Merged
RogerLamTd merged 1 commit into
mainfrom
fix/reactive-canonical-and-hoodi-bundle
Aug 19, 2026
Merged

fix(seo): keep canonical and og:url in sync with client-side navigation#25
RogerLamTd merged 1 commit into
mainfrom
fix/reactive-canonical-and-hoodi-bundle

Conversation

@davidtaikocha

@davidtaikocha davidtaikocha commented Aug 19, 2026

Copy link
Copy Markdown
Member

Addresses the "Important" review finding on #11. The minor software-releases.mdx finding was reviewed and declined — the existing sentence stands, so this PR touches no docs pages.

Canonical and og:url went stale after client-side navigation

Vocs evaluates config.head({ path }) once per page while prerendering and never re-runs it in the browser — vocs 1.4.1 builds the <head> string at prerender time only. So canonical and og:url froze at whichever page the visitor landed on: navigating /protocol/overview/protocol/based-rollups updated the URL, <title>, og:title and og:image, but left both route URLs pointing at /protocol/overview.

The two route-dependent tags now live in a docs/layout.tsx consumer layout, which Vocs mounts inside the router. It re-renders on every navigation, and React 19 hoists <link>/<meta> into <head> when prerendering as well as in the browser — the same mechanism Vocs already relies on for <title> and og:title, which is why those were correct.

Prerendered output is preserved: every page still emits exactly one canonical and one og:url with the same hrefs, only later in <head> (verified across all 22 routes, in both preview and VERCEL_ENV=production builds).

Two supporting details:

  • The resolved siteUrl reaches the browser bundle via vite.define, because the process.env chain in scripts/site-url.mjs is build-time only. Vite inlines it at build and injects it as a global in dev, so pnpm dev works too.
  • config.head keeps the route-independent tags (og:site_name, og:locale, twitter:site, robots) and stays a function. Vocs reads an object-valued head as a path → element map, and a bare ReactElement is an object — the element form silently emits nothing. Confirmed the hard way.

Test

tests/route-metadata.spec.ts covers both halves:

  • the prerendered document, fetched without JS, carries exactly one canonical/og:url for its own route (/, /protocol/overview, /protocol/based-rollups);
  • a real browser soft-navigates overview → based-rollups → overview, asserting the tags follow and never duplicate, with a window sentinel proving no document reload happened.

Reverting docs/layout.tsx makes the soft-navigation test fail with exactly the reported symptom:

Expected: "https://docs.example.test/protocol/based-rollups"
Received: "https://docs.example.test/protocol/overview"

Runs in CI as a new e2e job. The suite builds and serves the site itself, so pnpm test:e2e needs nothing running.

Notes for reviewers

  • node_modules/@playwright/test/cli.js instead of pnpm exec playwright. Vocs depends on its own playwright (1.58.2 via rehype-mermaid), and which copy pnpm links into node_modules/.bin flips depending on install order. When the 1.58.2 bin runs 1.62.1 specs you get a confusing No tests found. Calling the CLI by module path removes the coin flip.
  • --with-deps on the arc-runner-set runner. The browser-install step assumes the runner can apt-get Chromium's system libraries. If it can't, that step is the one to adjust.
  • @types/react added as a devDependency for the new .tsx file; pinned loosely to ^19.0.0 so it resolves to the 19.2.14 already in the tree and the lockfile diff stays small. tsc --noEmit still reports two pre-existing errors in vocs.config.ts (the .mjs import and theme.accentColor) — untouched here, and nothing runs tsc in CI.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Vocs evaluates config.head({ path }) once per page during prerender and
never again, so canonical and og:url froze at whichever page the visitor
landed on. Navigating from /protocol/overview to /protocol/based-rollups
updated the URL, title, og:title and og:image, but left both route URLs
pointing at the entry page.

Move the two route-dependent tags into a docs/layout.tsx consumer layout,
which Vocs mounts inside the router: it re-renders on every navigation and
React 19 hoists <link>/<meta> into <head> when prerendering as well as in
the browser. The prerendered HTML is unchanged apart from tag order --
still exactly one canonical and one og:url per page, with the same hrefs.
The resolved siteUrl reaches the browser bundle through vite.define, since
the process.env chain in scripts/site-url.mjs is build-time only.

config.head keeps the tags that do not depend on the route, and stays a
function: Vocs reads an object-valued head as a path -> element map, and a
bare ReactElement is an object, so the element form silently emits nothing.

Adds Playwright coverage for both halves -- the prerendered document and a
soft navigation in a real browser -- wired into CI. The soft-navigation
test fails against the previous behaviour with exactly the reported
symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@davidtaikocha
davidtaikocha force-pushed the fix/reactive-canonical-and-hoodi-bundle branch from 0e6e98a to 9fdc1bc Compare August 19, 2026 01:49
@RogerLamTd
RogerLamTd merged commit 681ae1a into main Aug 19, 2026
4 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.

2 participants