fix(seo): keep canonical and og:url in sync with client-side navigation - #25
Merged
Merged
Conversation
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
force-pushed
the
fix/reactive-canonical-and-hoodi-bundle
branch
from
August 19, 2026 01:49
0e6e98a to
9fdc1bc
Compare
RogerLamTd
approved these changes
Aug 19, 2026
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.
Addresses the "Important" review finding on #11. The minor
software-releases.mdxfinding was reviewed and declined — the existing sentence stands, so this PR touches no docs pages.Canonical and
og:urlwent stale after client-side navigationVocs 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. Socanonicalandog:urlfroze at whichever page the visitor landed on: navigating/protocol/overview→/protocol/based-rollupsupdated the URL,<title>,og:titleandog:image, but left both route URLs pointing at/protocol/overview.The two route-dependent tags now live in a
docs/layout.tsxconsumer 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>andog:title, which is why those were correct.Prerendered output is preserved: every page still emits exactly one
canonicaland oneog:urlwith the same hrefs, only later in<head>(verified across all 22 routes, in both preview andVERCEL_ENV=productionbuilds).Two supporting details:
siteUrlreaches the browser bundle viavite.define, because theprocess.envchain inscripts/site-url.mjsis build-time only. Vite inlines it at build and injects it as a global in dev, sopnpm devworks too.config.headkeeps the route-independent tags (og:site_name,og:locale,twitter:site,robots) and stays a function. Vocs reads an object-valuedheadas a path → element map, and a bareReactElementis an object — the element form silently emits nothing. Confirmed the hard way.Test
tests/route-metadata.spec.tscovers both halves:og:urlfor its own route (/,/protocol/overview,/protocol/based-rollups);windowsentinel proving no document reload happened.Reverting
docs/layout.tsxmakes the soft-navigation test fail with exactly the reported symptom:Runs in CI as a new
e2ejob. The suite builds and serves the site itself, sopnpm test:e2eneeds nothing running.Notes for reviewers
node_modules/@playwright/test/cli.jsinstead ofpnpm exec playwright. Vocs depends on its ownplaywright(1.58.2 viarehype-mermaid), and which copy pnpm links intonode_modules/.binflips depending on install order. When the 1.58.2 bin runs 1.62.1 specs you get a confusingNo tests found. Calling the CLI by module path removes the coin flip.--with-depson thearc-runner-setrunner. The browser-install step assumes the runner canapt-getChromium's system libraries. If it can't, that step is the one to adjust.@types/reactadded as a devDependency for the new.tsxfile; pinned loosely to^19.0.0so it resolves to the 19.2.14 already in the tree and the lockfile diff stays small.tsc --noEmitstill reports two pre-existing errors invocs.config.ts(the.mjsimport andtheme.accentColor) — untouched here, and nothing runstscin CI.🤖 Generated with Claude Code