router icon indicating copy to clipboard operation
router copied to clipboard

fix: prevent duplicate title tags in document head

Open ioslh opened this issue 1 month ago β€’ 1 comments

PR Description:

πŸ› Problem & User Need

The Original Need

I want to display meaningful page titles immediately during route loading, before head() executes. Currently, there's a title gap because:

  1. Route matches β†’ empty/stale title period begins
  2. beforeLoad executes β†’ still no title update
  3. loader executes β†’ still no title update
  4. head() executes β†’ title finally updates

This creates poor UX with blank or stale titles during navigation.

Current Workaround & Issue

Based on existing APIs, the only way to set immediate titles is in beforeLoad:

export const Route = createFileRoute('/posts/$postId')({
  beforeLoad: ({ params }) => {
    // Only way to set immediate title with current API
    document.title = `Loading Post ${params.postId}...`
  },
  head: ({ loaderData }) => ({
    meta: [{ title: loaderData.title }], // Final title after loading
  }),
})

This causes duplicate title tags:

<!-- After beforeLoad sets document.title -->
<head>
  <title>Loading Post 123...</title>  <!-- Set by beforeLoad -->
</head>

<!-- After head() executes and Asset renders -->
<head>
  <title>Loading Post 123...</title>  <!-- Original from document.title -->
  <title>My Amazing Post</title>      <!-- New one from head() -->
</head>

The result is invalid HTML with multiple <title> elements, causing SEO and browser issues.

βœ… Solution

This PR enables the workaround mentioned above by fixing the duplicate title issue. Now developers can safely use beforeLoad to set immediate titles without worrying about invalid HTML.

The fix modifies title handling in Asset.tsx to:

  1. Clean up conflicts: On first router-managed title update, automatically remove any existing <title> tags
  2. Use document.title API on client: Directly set document.title instead of rendering additional <title> elements
  3. Maintain SSR compatibility: Continue rendering <title> tags normally on the server for SEO

Result: Clean HTML

<!-- After this fix, only one title exists -->
<head>
  <title>My Amazing Post</title>  <!-- Only the final title -->
</head>

Technical Implementation

Client-side behavior:

  • First title update removes all existing <title> DOM elements
  • Subsequent updates only use document.title API
  • No additional <title> tags are rendered

Server-side behavior:

  • Normal <title> tag rendering for SSR/SEO
  • No changes to existing SSR behavior

πŸ”§ Implementation Details

File Changed: packages/react-router/src/Asset.tsx

Before:

case 'title':
  return (
    <title {...attrs} suppressHydrationWarning>
      {children}
    </title>
  )

After:

case 'title':
  return <Title attrs={attrs}>{children}</Title>

// New Title component that handles deduplication
function Title({ attrs, children }) {
  const router = useRouter()

  React.useEffect(() => {
    if (typeof children === 'string') {
      // Clean up existing titles on first update
      if (!titleControlled) {
        document.querySelectorAll('title').forEach(el => el.remove())
        titleControlled = true
      }
      // Use document.title API directly
      document.title = children
    }
  }, [children])

  // Client: no DOM rendering, Server: normal SSR
  return !router.isServer ? null : <title {...attrs}>{children}</title>
}

πŸ“ Usage & Developer Experience

Before This Fix

Developers faced a dilemma:

  • Use only head() β†’ good HTML, but title gaps during loading
  • Use beforeLoad + head() β†’ immediate titles, but invalid HTML with duplicates

After This Fix

Developers can now safely use the beforeLoad pattern without HTML validity concerns:

export const Route = createFileRoute('/posts/$postId')({
  beforeLoad: ({ params }) => {
    // βœ… Now safe to use - no more duplicate title tags!
    if (typeof window !== 'undefined') {
      document.title = `Loading Post ${params.postId}...`
    }
  },
  head: ({ loaderData }) => ({
    meta: [{ title: loaderData.title }], // Will cleanly replace the loading title
  }),
})

Summary: This PR fixes duplicate title tags by implementing clean title management that automatically handles conflicts while maintaining full backward compatibility and SSR support.

Summary by CodeRabbit

  • Refactor
    • Optimized how page titles are managed, improving client-side performance by updating the document title more efficiently while maintaining proper server-side behavior.

✏️ Tip: You can customize this high-level summary in your review settings.

ioslh avatar Nov 20 '25 07:11 ioslh

Pre-merge checks and finishing touches

βœ… Passed checks (3 passed)
Check name Status Explanation
Description Check βœ… Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check βœ… Passed The title 'fix: prevent duplicate title tags in document head' directly and clearly describes the main problem being solved by this changeset: preventing duplicate title tags from appearing in the document head when titles are set both via document.title (in beforeLoad) and via head(). This is the core issue the PR addresses.
Docstring Coverage βœ… Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • [ ] πŸ“ Generate docstrings
πŸ§ͺ Generate unit tests (beta)
  • [ ] Create PR with unit tests
  • [ ] Post copyable unit tests in a comment

[!TIP]

πŸ“ Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests β€” including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. πŸ“ Description β€” Summarize the main change in 50–60 words, explaining what was done.
  2. πŸ““ References β€” List relevant issues, discussions, documentation, or related PRs.
  3. πŸ“¦ Dependencies & Requirements β€” Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. πŸ“Š Contributor Summary β€” Include a Markdown table showing contributions: | Contributor | Lines Added | Lines Removed | Files Changed |
  5. βœ”οΈ Additional Notes β€” Add any extra reviewer context. Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


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 and usage tips.

coderabbitai[bot] avatar Nov 20 '25 07:11 coderabbitai[bot]