Skip to content

Sync document head metadata on SPA navigations - #807

Merged
kody-bot merged 1 commit into
mainfrom
cursor/spa-document-head-sync
Jul 20, 2026
Merged

kody-bot merged 1 commit into
mainfrom
cursor/spa-document-head-sync

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Jul 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Expands the SPA title fix into a shared document-head registry covering title, Open Graph/Twitter tags, canonical URLs, and alternate links (e.g. blog RSS).
  • SSR and the client router both resolve from one place; SPA navigations upsert or clear managed data-kody-head tags so previous-page metadata no longer sticks.
  • Public route handlers drop duplicated title / extraHead / OgHead wiring and rely on the registry; dynamic pages get the extra shell fields they need (community.description, profile.bio).

Test plan

  • Unit tests for static/dynamic head resolution + absolutizing
  • Typecheck
  • Pre-push e2e (including OG meta SSR coverage)
  • Spot-check SPA nav in preview: home → blog → post → community detail updates document.title, og:*, and clears RSS off /blog
System recap — extends existing primitives (medium risk)

Mode: recap · Base: main @ 49680f32 · Head: 6afd68de

Classification: extends — client router and SSR now sync managed document-head metadata from a shared registry on every navigation.

Primitives touched

Primitive Group Impact
app-ui surfaces extends — document-head registry + SPA apply path for title/OG/canonical/links
community-listings assistant composes — shell data includes listing description for OG resolution

System map

Head metadata resolves from one registry at SSR time and again on each client-router navigation commit.

Legend: green = composes (wiring only) · amber = extended by this PR · red = new primitive · gray = context (unchanged, included only when an edge crosses it).

flowchart LR
	appUi["app-ui<br/>Browser app (Remix 3)"]:::extended
	communityListings["community-listings<br/>Community package listings"]:::touched
	ogPages["publicOgPages<br/>Static OG registry"]:::untouched
	communityListings -->|"shell description/bio fields"| appUi
	ogPages -->|"static public page OG copy"| appUi
	appUi -->|"applyDocumentHead on nav commit"| appUi
	classDef touched fill:#1a7f37,color:#fff
	classDef extended fill:#9a6700,color:#fff
	classDef added fill:#cf222e,color:#fff
	classDef untouched fill:#57606a,color:#fff
Loading

Change flow

sequenceDiagram
	participant Nav as client-router
	participant Registry as document-head
	participant Head as document.head
	Nav->>Registry: resolveDocumentHead(path, loaderData)
	Registry-->>Nav: descriptor
	Nav->>Head: clear data-kody-head nodes
	Nav->>Head: upsert title/OG/canonical/links
Loading

Before / after

Before After
SPA title Updated (prior PR) Still updated via head registry
SPA OG/canonical/RSS Stale from first full load Synced/cleared on each navigation
Public handlers Manual title + extraHead/OgHead Registry-driven via renderAppPage
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Added centralized page metadata management for titles, canonical URLs, social previews, and alternate links.
    • Metadata now updates during client-side navigation, including navigation error states.
    • Community descriptions and profile bios are now available for page metadata and display.
  • Bug Fixes

    • Improved consistency between server-rendered and client-rendered page metadata.
    • Added fallback metadata for unknown routes and incomplete dynamic page data.
  • Tests

    • Added coverage for static, dynamic, social, canonical, alternate-link, and fallback metadata behavior.

Expand the title registry into a shared document-head primitive that
covers title, Open Graph/Twitter tags, canonical URLs, and alternate
links. SSR and the client router both resolve from one registry, and SPA
navigations upsert or clear managed head tags so metadata no longer
sticks from the first full page load.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The PR introduces a centralized route-based document head registry, uses it for SSR metadata rendering, removes per-handler head construction, and synchronizes managed head tags during SPA navigation. Community descriptions and profile bios are added to loader data for dynamic metadata resolution.

Document head management

Layer / File(s) Summary
Route head registry and resolution
packages/worker/src/app/document-head.ts, packages/worker/src/app/document-title.ts, packages/worker/src/app/document-head.node.test.ts
Route patterns now resolve titles, canonical URLs, Open Graph data, alternate links, dynamic loader metadata, fallbacks, and absolute URLs.
SSR document head rendering
packages/worker/src/app/ssr-render.tsx, packages/worker/src/app/ssr-document.tsx
SSR builds a resolved document head and renders managed Open Graph, Twitter, canonical, and alternate link tags.
Handler metadata migration
packages/worker/src/app/handlers/*, packages/worker/src/app/loader-data.ts
Handlers stop passing per-route extraHead metadata, while community descriptions and profile bios are included in shell loader data.
Client head synchronization
packages/worker/client/client-head.ts, packages/worker/client/client-router.tsx, packages/worker/client/document-title.ts, packages/worker/client/routes/*, packages/worker/tsconfig-client.json
SPA navigation applies resolved managed head nodes, compatibility exports delegate to the new implementation, and client compilation includes the new modules.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Request
  participant renderAppPage
  participant DocumentHeadRegistry
  participant SsrDocument
  Request->>renderAppPage: render route with loaderData
  renderAppPage->>DocumentHeadRegistry: resolveDocumentHead(pathname, loaderData)
  DocumentHeadRegistry-->>renderAppPage: resolved document head
  renderAppPage->>SsrDocument: pass documentHead
  SsrDocument-->>Request: render title and managed head tags
Loading

Possibly related PRs

  • kentcdodds/kody#599: Earlier community OG and canonical head generation related to this registry migration.
  • kentcdodds/kody#601: Earlier SSR and hydration head/title plumbing used by this integration.
  • kentcdodds/kody#805: Related client-router updates for loader-driven document head synchronization.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: syncing document head metadata during SPA navigations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/spa-document-head-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kody-bot
kody-bot marked this pull request as ready for review July 20, 2026 18:57
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-807.kody-a99.workers.dev

Worker: kody-pr-807
D1: kody-pr-807-db
KV: kody-pr-807-oauth-kv

Mocks:

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/worker/src/app/ssr-document.tsx (1)

23-101: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Import DOCUMENT_HEAD_ATTR instead of hardcoding "data-kody-head".

Every generated tag repeats the literal data-kody-head, duplicating the constant already exported from document-head.ts for exactly this purpose (/** Stable marker so SPA navigation can upsert/remove managed head tags. */). If that constant is ever renamed on one side only, SSR-rendered tags and the SPA's cleanup/upsert logic (removeManagedHeadNodes) would silently desync, leaving stale OG/canonical tags on navigation. Import and use the constant (via spread, since JSX attribute names must be static identifiers) to keep this contract single-sourced.

♻️ Use the shared constant for the managed-tag marker
-import { type ResolvedDocumentHead } from '`#app/document-head.ts`'
+import {
+	DOCUMENT_HEAD_ATTR,
+	type ResolvedDocumentHead,
+} from '`#app/document-head.ts`'
...
 				<meta
 					property="og:title"
 					content={head.og.title}
-					data-kody-head="og:title"
+					{...{ [DOCUMENT_HEAD_ATTR]: 'og:title' }}
 				/>

(repeat for the remaining managed tags)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/app/ssr-document.tsx` around lines 23 - 101, Update
ManagedDocumentHead to import and use DOCUMENT_HEAD_ATTR from document-head.ts
instead of repeating the literal data-kody-head on each generated meta and link
element. Apply the shared constant via JSX attribute spreading so all managed
OG, Twitter, canonical, and link tags retain the existing marker values while
remaining synchronized with removeManagedHeadNodes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/worker/client/document-head.ts`:
- Around line 57-98: Decouple the metadata logic in the head-generation flow:
update the conditional around the visible Open Graph/Twitter appends so they run
whenever resolved.og exists, and handle the canonical link independently
whenever resolved.canonicalUrl exists. Preserve each existing tag’s values and
ensure either configuration works without requiring the other.

---

Nitpick comments:
In `@packages/worker/src/app/ssr-document.tsx`:
- Around line 23-101: Update ManagedDocumentHead to import and use
DOCUMENT_HEAD_ATTR from document-head.ts instead of repeating the literal
data-kody-head on each generated meta and link element. Apply the shared
constant via JSX attribute spreading so all managed OG, Twitter, canonical, and
link tags retain the existing marker values while remaining synchronized with
removeManagedHeadNodes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 90f55711-39ce-4a20-b74c-6bcde176f75c

📥 Commits

Reviewing files that changed from the base of the PR and between 49680f3 and 6afd68d.

📒 Files selected for processing (22)
  • packages/worker/client/client-router.tsx
  • packages/worker/client/document-head.ts
  • packages/worker/client/document-title.ts
  • packages/worker/client/routes/community-detail.tsx
  • packages/worker/client/routes/profile.tsx
  • packages/worker/src/app/document-head.node.test.ts
  • packages/worker/src/app/document-head.ts
  • packages/worker/src/app/document-title.node.test.ts
  • packages/worker/src/app/document-title.ts
  • packages/worker/src/app/handlers/auth-page.ts
  • packages/worker/src/app/handlers/blog.tsx
  • packages/worker/src/app/handlers/community-detail.tsx
  • packages/worker/src/app/handlers/community.tsx
  • packages/worker/src/app/handlers/home.ts
  • packages/worker/src/app/handlers/onboarding.ts
  • packages/worker/src/app/handlers/privacy.ts
  • packages/worker/src/app/handlers/profile.tsx
  • packages/worker/src/app/handlers/reset-password.ts
  • packages/worker/src/app/loader-data.ts
  • packages/worker/src/app/ssr-document.tsx
  • packages/worker/src/app/ssr-render.tsx
  • packages/worker/tsconfig-client.json
💤 Files with no reviewable changes (6)
  • packages/worker/src/app/handlers/blog.tsx
  • packages/worker/src/app/handlers/privacy.ts
  • packages/worker/src/app/document-title.node.test.ts
  • packages/worker/src/app/handlers/community.tsx
  • packages/worker/src/app/handlers/reset-password.ts
  • packages/worker/src/app/handlers/onboarding.ts

Comment on lines +57 to +98
if (resolved.og && resolved.canonicalUrl) {
appendMeta(head, 'og:title', {
property: 'og:title',
content: resolved.og.title,
})
appendMeta(head, 'og:description', {
property: 'og:description',
content: resolved.og.description,
})
appendMeta(head, 'og:image', {
property: 'og:image',
content: resolved.og.imageUrl,
})
appendMeta(head, 'og:type', {
property: 'og:type',
content: 'website',
})
appendMeta(head, 'og:url', {
property: 'og:url',
content: resolved.canonicalUrl,
})
appendMeta(head, 'twitter:card', {
name: 'twitter:card',
content: 'summary_large_image',
})
appendMeta(head, 'twitter:title', {
name: 'twitter:title',
content: resolved.og.title,
})
appendMeta(head, 'twitter:description', {
name: 'twitter:description',
content: resolved.og.description,
})
appendMeta(head, 'twitter:image', {
name: 'twitter:image',
content: resolved.og.imageUrl,
})
appendLink(head, 'canonical', {
rel: 'canonical',
href: resolved.canonicalUrl,
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Decouple canonical URL and Open Graph metadata.

The current logic strictly couples Open Graph tags and canonical URLs. If a route provides a canonicalUrl but no og metadata, the canonical link is completely skipped. Conversely, if og is provided without a canonicalUrl, all OG and Twitter tags are skipped.

These should be evaluated and appended independently, as a canonical URL is an independent SEO concept from Open Graph.

♻️ Proposed fix to evaluate them independently
-	if (resolved.og && resolved.canonicalUrl) {
+	if (resolved.canonicalUrl) {
+		appendLink(head, 'canonical', {
+			rel: 'canonical',
+			href: resolved.canonicalUrl,
+		})
+	}
+
+	if (resolved.og) {
 		appendMeta(head, 'og:title', {
 			property: 'og:title',
 			content: resolved.og.title,
 		})
 		appendMeta(head, 'og:description', {
 			property: 'og:description',
 			content: resolved.og.description,
 		})
 		appendMeta(head, 'og:image', {
 			property: 'og:image',
 			content: resolved.og.imageUrl,
 		})
 		appendMeta(head, 'og:type', {
 			property: 'og:type',
 			content: 'website',
 		})
-		appendMeta(head, 'og:url', {
-			property: 'og:url',
-			content: resolved.canonicalUrl,
-		})
+		if (resolved.canonicalUrl) {
+			appendMeta(head, 'og:url', {
+				property: 'og:url',
+				content: resolved.canonicalUrl,
+			})
+		}
 		appendMeta(head, 'twitter:card', {
 			name: 'twitter:card',
 			content: 'summary_large_image',
 		})
 		appendMeta(head, 'twitter:title', {
 			name: 'twitter:title',
 			content: resolved.og.title,
 		})
 		appendMeta(head, 'twitter:description', {
 			name: 'twitter:description',
 			content: resolved.og.description,
 		})
 		appendMeta(head, 'twitter:image', {
 			name: 'twitter:image',
 			content: resolved.og.imageUrl,
 		})
-		appendLink(head, 'canonical', {
-			rel: 'canonical',
-			href: resolved.canonicalUrl,
-		})
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (resolved.og && resolved.canonicalUrl) {
appendMeta(head, 'og:title', {
property: 'og:title',
content: resolved.og.title,
})
appendMeta(head, 'og:description', {
property: 'og:description',
content: resolved.og.description,
})
appendMeta(head, 'og:image', {
property: 'og:image',
content: resolved.og.imageUrl,
})
appendMeta(head, 'og:type', {
property: 'og:type',
content: 'website',
})
appendMeta(head, 'og:url', {
property: 'og:url',
content: resolved.canonicalUrl,
})
appendMeta(head, 'twitter:card', {
name: 'twitter:card',
content: 'summary_large_image',
})
appendMeta(head, 'twitter:title', {
name: 'twitter:title',
content: resolved.og.title,
})
appendMeta(head, 'twitter:description', {
name: 'twitter:description',
content: resolved.og.description,
})
appendMeta(head, 'twitter:image', {
name: 'twitter:image',
content: resolved.og.imageUrl,
})
appendLink(head, 'canonical', {
rel: 'canonical',
href: resolved.canonicalUrl,
})
}
if (resolved.canonicalUrl) {
appendLink(head, 'canonical', {
rel: 'canonical',
href: resolved.canonicalUrl,
})
}
if (resolved.og) {
appendMeta(head, 'og:title', {
property: 'og:title',
content: resolved.og.title,
})
appendMeta(head, 'og:description', {
property: 'og:description',
content: resolved.og.description,
})
appendMeta(head, 'og:image', {
property: 'og:image',
content: resolved.og.imageUrl,
})
appendMeta(head, 'og:type', {
property: 'og:type',
content: 'website',
})
if (resolved.canonicalUrl) {
appendMeta(head, 'og:url', {
property: 'og:url',
content: resolved.canonicalUrl,
})
}
appendMeta(head, 'twitter:card', {
name: 'twitter:card',
content: 'summary_large_image',
})
appendMeta(head, 'twitter:title', {
name: 'twitter:title',
content: resolved.og.title,
})
appendMeta(head, 'twitter:description', {
name: 'twitter:description',
content: resolved.og.description,
})
appendMeta(head, 'twitter:image', {
name: 'twitter:image',
content: resolved.og.imageUrl,
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/client/document-head.ts` around lines 57 - 98, Decouple the
metadata logic in the head-generation flow: update the conditional around the
visible Open Graph/Twitter appends so they run whenever resolved.og exists, and
handle the canonical link independently whenever resolved.canonicalUrl exists.
Preserve each existing tag’s values and ensure either configuration works
without requiring the other.

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.

3 participants