Skip to content

fix: handle missing title with titleTemplate and add defaultTitle#682

Closed
harlan-zw wants to merge 1 commit into
mainfrom
fix/618-default-title-support
Closed

fix: handle missing title with titleTemplate and add defaultTitle#682
harlan-zw wants to merge 1 commit into
mainfrom
fix/618-default-title-support

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

🔗 Linked issue

Resolves #618

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

📚 Description

When titleTemplate: '%s - MyApp' was set with no page title, %s was replaced with empty string producing ' - MyApp'. Now the title tag is silently dropped when no title exists and the template contains %s. Additionally, a new defaultTitle option in templateParams provides React Helmet-style behavior — when no page title is set and defaultTitle is provided, it renders as the title bypassing the template entirely.

Summary by CodeRabbit

  • New Features

    • Added a defaultTitle template parameter that serves as a fallback page title when no page title is provided, preventing partially rendered title templates (e.g., "%s - My Site") from producing incomplete results.
  • Bug Fixes

    • When no page title exists, title templates containing a placeholder no longer render a dangling suffix; the defaultTitle or empty title behavior is handled predictably.
  • Tests

    • Added unit and SSR tests validating defaultTitle and edge-case titleTemplate behaviors.

@coderabbitai

coderabbitai Bot commented Mar 10, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a defaultTitle template parameter and logic so when no page title is provided the resolver uses defaultTitle instead of producing partial titles (e.g., " - App"). Updates plugin preprocessing, title resolution guards, type defs, and client/server tests.

Changes

Cohort / File(s) Summary
Type Definitions
packages/unhead/src/types/schema/head.ts, packages/unhead/src/types/tags.ts
Add optional defaultTitle?: string to ResolvableTemplateParams and TemplateParams with docs explaining fallback behavior when no page title exists.
Template Params Plugin
packages/unhead/src/plugins/templateParams.ts
Extracts defaultTitle from templateParams, passes rawPageTitle into processTemplateParams, and if rawPageTitle is empty uses defaultTitle to set resolved <title> directly (bypassing titleTemplate substitution).
Title Resolution Logic
packages/unhead/src/utils/resolve.ts
Add guards in resolveTitleTemplate: skip promoting null templates and skip promoting plain %s-containing templates when template-params plugin is absent to avoid rendering partial suffixes.
Client Tests
packages/unhead/test/unit/client/templateParams.test.ts, packages/unhead/test/unit/client/titleTemplate.test.ts
Add tests covering defaultTitle behavior and titleTemplate edge cases (no page title with/without %s).
Server Tests
packages/unhead/test/unit/server/templateParams.test.ts, packages/unhead/test/unit/server/titleTemplate.test.ts
Add SSR tests asserting defaultTitle is used when no title provided and titleTemplate behaves correctly in %s and non-%s scenarios; update imports.

Sequence Diagram(s)

sequenceDiagram
  participant App as Client/API
  participant Plugin as TemplateParamsPlugin
  participant Resolver as resolveTitleTemplate
  participant DOM as DOM/SSR Renderer

  App->>Plugin: push head with titleTemplate + templateParams(defaultTitle)
  Plugin->>Plugin: extract defaultTitle, compute rawPageTitle
  Plugin->>Resolver: call processTemplateParams(rawPageTitle, params)
  Resolver->>Resolver: compute substituted title (v)
  alt rawPageTitle empty and defaultTitle present
    Resolver->>DOM: set <title> textContent = defaultTitle
  else v is null or unsafe to promote
    Resolver-->>DOM: do not promote titleTemplate (no <title>)
  else
    Resolver->>DOM: set/promote <title> with substituted v
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 In hop and code I found the clue,
A default title, tidy and true.
No dangling dash, no lonely part —
A grateful rabbit, fixing the art.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Title check ✅ Passed The title clearly summarizes the main changes: fixing the titleTemplate handling with missing titles and adding a defaultTitle feature.
Description check ✅ Passed The description covers all required template sections with specific details about the bug fix, the new feature, and links to the resolved issue.
Linked Issues check ✅ Passed The changes fully implement the requirements from #618: preventing malformed titles when titleTemplate contains %s without a page title, and adding a defaultTitle option to provide a fallback title.
Out of Scope Changes check ✅ Passed All changes are directly related to addressing issue #618: type definitions extended with defaultTitle, plugin logic updated to handle missing titles, resolve utilities enhanced, and comprehensive tests added for both features.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 fix/618-default-title-support

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.

@github-actions

github-actions Bot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Bundle Size Gzipped
Client (Minimal) 10.6 kB → 10.7 kB 🔴 +0.1 kB 4.4 kB
Server (Minimal) 10.3 kB → 10.4 kB 🔴 +0.1 kB 4.2 kB
Vue Client (Minimal) 11.6 kB → 11.7 kB 🔴 +0.1 kB 4.8 kB
Vue Server (Minimal) 11.3 kB → 11.4 kB 🔴 +0.1 kB 4.7 kB

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/unhead/src/plugins/templateParams.ts (1)

17-39: ⚠️ Potential issue | 🟠 Major

Resolve defaultTitle earlier and synthesize a title when none exists.

This only mutates tagMap.get('title'), so the fallback is skipped when there is no concrete title key yet — for example templateParams.defaultTitle without any titleTemplate, or a template-only title that was promoted from titleTemplate but is still stored under the titleTemplate key. Because this runs in tags:resolve, tags:beforeResolve consumers also still observe head._title as empty. defaultTitle needs to be applied in the main title-resolution path, or at least before tags:beforeResolve, and it should create/update the actual title tag instead of relying on tagMap.get('title').

Possible direction
-      'tags:resolve': ({ tagMap, tags }) => {
+      'tags:resolve': ({ tagMap, tags }) => {
         // ...
         const useDefaultTitle = !rawPageTitle && !!defaultTitle
         if (useDefaultTitle) {
-          const titleTag = tagMap.get('title')
-          if (titleTag)
-            titleTag.textContent = defaultTitle!
+          const titleTag = tags.find(tag => tag.tag === 'title')
+          if (titleTag) {
+            titleTag.textContent = defaultTitle!
+          }
+          else {
+            tags.push({ tag: 'title', props: {}, textContent: defaultTitle! })
+          }
+          params.pageTitle = defaultTitle!
+          head._title = defaultTitle!
         }

If the fallback must be visible to tags:beforeResolve plugins too, this should move into packages/unhead/src/utils/resolve.ts instead of staying in tags:resolve.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/plugins/templateParams.ts` around lines 17 - 39, The
current tags:resolve handler applies defaultTitle only by mutating
tagMap.get('title') which skips cases where no concrete title tag exists or when
title is still represented by titleTemplate/head._title; move the defaultTitle
logic earlier into the main title-resolution path (or into resolve.ts if
tags:beforeResolve consumers must see it) so that when defaultTitle is present
and rawPageTitle is empty you synthesize the actual title value into head._title
and/or create/update the real title tag in tagMap (instead of only touching
titleTag), ensuring the code paths that use processTemplateParams, tagMap,
head._title and titleTemplate observe the fallback consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/unhead/src/plugins/templateParams.ts`:
- Around line 17-39: The current tags:resolve handler applies defaultTitle only
by mutating tagMap.get('title') which skips cases where no concrete title tag
exists or when title is still represented by titleTemplate/head._title; move the
defaultTitle logic earlier into the main title-resolution path (or into
resolve.ts if tags:beforeResolve consumers must see it) so that when
defaultTitle is present and rawPageTitle is empty you synthesize the actual
title value into head._title and/or create/update the real title tag in tagMap
(instead of only touching titleTag), ensuring the code paths that use
processTemplateParams, tagMap, head._title and titleTemplate observe the
fallback consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a2698943-56fb-4e9b-a7b7-65e737933712

📥 Commits

Reviewing files that changed from the base of the PR and between 4838b84 and bf88354.

📒 Files selected for processing (8)
  • packages/unhead/src/plugins/templateParams.ts
  • packages/unhead/src/types/schema/head.ts
  • packages/unhead/src/types/tags.ts
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/client/templateParams.test.ts
  • packages/unhead/test/unit/client/titleTemplate.test.ts
  • packages/unhead/test/unit/server/templateParams.test.ts
  • packages/unhead/test/unit/server/titleTemplate.test.ts

…port

When using a string titleTemplate like `%s - MyApp` without a page title,
the result was an empty substitution (` - MyApp`). This commit:

- Skips promoting `titleTemplate` to `title` when no page title is set and
  the template is a plain string containing `%s` (non-plugin path)
- Adds `defaultTitle` to `TemplateParams` / `ResolvableTemplateParams`: when
  using the TemplateParamsPlugin, setting `templateParams.defaultTitle` causes
  that value to be used as the final title when no page-specific title is set,
  bypassing the titleTemplate entirely (e.g. `defaultTitle: 'MyApp'` with no
  title gives `<title>MyApp</title>` instead of `<title> - MyApp</title>`)

Closes #618
@harlan-zw harlan-zw force-pushed the fix/618-default-title-support branch from bf88354 to 4686abb Compare April 5, 2026 16:32

@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/unhead/test/unit/client/templateParams.test.ts (1)

250-264: Strengthen this test to actually catch the %s empty-substitution regression

On Line 256, %separator + %siteName can still produce <title>My Site</title> without using defaultTitle, so this case may pass even if the new behavior is broken.

Suggested test adjustment
 it('defaultTitle used when no page title is set', async () => {
   const head = useDOMHead({
     plugins: [TemplateParamsPlugin],
   })

   useHead(head, {
-    titleTemplate: '%s %separator %siteName',
+    titleTemplate: '%s - My App',
     templateParams: {
-      siteName: 'My Site',
       defaultTitle: 'My Site',
     },
   })

-  expect(await useDelayedSerializedDom()).toContain('<title>My Site</title>')
+  const html = await useDelayedSerializedDom()
+  expect(html).toContain('<title>My Site</title>')
+  expect(html).not.toContain('<title> - My App</title>')
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/test/unit/client/templateParams.test.ts` around lines 250 -
264, The test currently can pass even when %s is not replaced by defaultTitle
because %separator + %siteName can produce the same output; update the test that
uses useDOMHead/TemplateParamsPlugin and useHead so templateParams.siteName is a
different value than defaultTitle (e.g., 'Other Site') and set
templateParams.defaultTitle to 'My Site', then assert the serialized DOM
contains exactly '<title>My Site</title>' — this ensures the code must
substitute defaultTitle for the empty %s instead of relying on
%separator/%siteName producing that string.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/unhead/src/plugins/templateParams.ts`:
- Around line 35-39: When useDefaultTitle is true we currently only set
defaultTitle if an existing titleTag is present, so inputs that only provide
titleTemplate still end up producing %s-based titles; fix by ensuring a title
tag is created or populated when absent: in the same block that checks
useDefaultTitle and accesses tagMap/get('title'), if titleTag is undefined
create a new title tag entry in tagMap (or otherwise set the resolved title
value) and set its textContent to defaultTitle; also ensure this override
happens before any titleTemplate resolution so defaultTitle always wins when
useDefaultTitle is true. Use the symbols useDefaultTitle, tagMap, titleTag,
defaultTitle and titleTemplate to locate and modify the logic.

---

Nitpick comments:
In `@packages/unhead/test/unit/client/templateParams.test.ts`:
- Around line 250-264: The test currently can pass even when %s is not replaced
by defaultTitle because %separator + %siteName can produce the same output;
update the test that uses useDOMHead/TemplateParamsPlugin and useHead so
templateParams.siteName is a different value than defaultTitle (e.g., 'Other
Site') and set templateParams.defaultTitle to 'My Site', then assert the
serialized DOM contains exactly '<title>My Site</title>' — this ensures the code
must substitute defaultTitle for the empty %s instead of relying on
%separator/%siteName producing that string.
🪄 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

Run ID: 0d3d74c8-ef65-44c9-b882-997cb06099fa

📥 Commits

Reviewing files that changed from the base of the PR and between bf88354 and 4686abb.

📒 Files selected for processing (8)
  • packages/unhead/src/plugins/templateParams.ts
  • packages/unhead/src/types/schema/head.ts
  • packages/unhead/src/types/tags.ts
  • packages/unhead/src/utils/resolve.ts
  • packages/unhead/test/unit/client/templateParams.test.ts
  • packages/unhead/test/unit/client/titleTemplate.test.ts
  • packages/unhead/test/unit/server/templateParams.test.ts
  • packages/unhead/test/unit/server/titleTemplate.test.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/unhead/test/unit/server/titleTemplate.test.ts
  • packages/unhead/test/unit/server/templateParams.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/unhead/test/unit/client/titleTemplate.test.ts
  • packages/unhead/src/types/schema/head.ts
  • packages/unhead/src/types/tags.ts
  • packages/unhead/src/utils/resolve.ts

Comment on lines +35 to +39
if (useDefaultTitle) {
const titleTag = tagMap.get('title')
if (titleTag)
titleTag.textContent = defaultTitle!
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

defaultTitle does not reliably bypass titleTemplate

On Line 36, this only applies when a title tag already exists. If the input only has titleTemplate, this branch is a no-op, and later title resolution can still render %s-based output (e.g., - MyApp) instead of defaultTitle.

Suggested fix
 const useDefaultTitle = !rawPageTitle && !!defaultTitle
 if (useDefaultTitle) {
-  const titleTag = tagMap.get('title')
-  if (titleTag)
-    titleTag.textContent = defaultTitle!
+  // enforce bypass of titleTemplate when defaultTitle is used
+  tagMap.delete('titleTemplate')
+  const titleTag = tagMap.get('title')
+  if (titleTag) {
+    tagMap.set('title', { ...titleTag, textContent: defaultTitle! })
+  }
+  else {
+    tagMap.set('title', { tag: 'title', textContent: defaultTitle! } as HeadTag)
+  }
 }
📝 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 (useDefaultTitle) {
const titleTag = tagMap.get('title')
if (titleTag)
titleTag.textContent = defaultTitle!
}
if (useDefaultTitle) {
// enforce bypass of titleTemplate when defaultTitle is used
tagMap.delete('titleTemplate')
const titleTag = tagMap.get('title')
if (titleTag) {
tagMap.set('title', { ...titleTag, textContent: defaultTitle! })
}
else {
tagMap.set('title', { tag: 'title', textContent: defaultTitle! } as HeadTag)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/unhead/src/plugins/templateParams.ts` around lines 35 - 39, When
useDefaultTitle is true we currently only set defaultTitle if an existing
titleTag is present, so inputs that only provide titleTemplate still end up
producing %s-based titles; fix by ensuring a title tag is created or populated
when absent: in the same block that checks useDefaultTitle and accesses
tagMap/get('title'), if titleTag is undefined create a new title tag entry in
tagMap (or otherwise set the resolved title value) and set its textContent to
defaultTitle; also ensure this override happens before any titleTemplate
resolution so defaultTitle always wins when useDefaultTitle is true. Use the
symbols useDefaultTitle, tagMap, titleTag, defaultTitle and titleTemplate to
locate and modify the logic.

@harlan-zw harlan-zw closed this Apr 6, 2026
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.

Add support for defaultTitle when title is not provided

1 participant