Sync document head metadata on SPA navigations - #807
Conversation
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.
📝 WalkthroughWalkthroughChangesThe 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
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
🔎 Preview deployed: https://kody-pr-807.kody-a99.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/worker/src/app/ssr-document.tsx (1)
23-101: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winImport
DOCUMENT_HEAD_ATTRinstead of hardcoding"data-kody-head".Every generated tag repeats the literal
data-kody-head, duplicating the constant already exported fromdocument-head.tsfor 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
📒 Files selected for processing (22)
packages/worker/client/client-router.tsxpackages/worker/client/document-head.tspackages/worker/client/document-title.tspackages/worker/client/routes/community-detail.tsxpackages/worker/client/routes/profile.tsxpackages/worker/src/app/document-head.node.test.tspackages/worker/src/app/document-head.tspackages/worker/src/app/document-title.node.test.tspackages/worker/src/app/document-title.tspackages/worker/src/app/handlers/auth-page.tspackages/worker/src/app/handlers/blog.tsxpackages/worker/src/app/handlers/community-detail.tsxpackages/worker/src/app/handlers/community.tsxpackages/worker/src/app/handlers/home.tspackages/worker/src/app/handlers/onboarding.tspackages/worker/src/app/handlers/privacy.tspackages/worker/src/app/handlers/profile.tsxpackages/worker/src/app/handlers/reset-password.tspackages/worker/src/app/loader-data.tspackages/worker/src/app/ssr-document.tsxpackages/worker/src/app/ssr-render.tsxpackages/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
| 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, | ||
| }) | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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.
Summary
data-kody-headtags so previous-page metadata no longer sticks.title/extraHead/OgHeadwiring and rely on the registry; dynamic pages get the extra shell fields they need (community.description,profile.bio).Test plan
document.title,og:*, and clears RSS off/blogSystem recap — extends existing primitives (medium risk)
Mode: recap · Base:
main@49680f32· Head:6afd68deClassification: extends — client router and SSR now sync managed document-head metadata from a shared registry on every navigation.
Primitives touched
app-uicommunity-listingsSystem 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).
Change flow
Before / after
title+extraHead/OgHeadrenderAppPageSummary by CodeRabbit
New Features
Bug Fixes
Tests