Skip to content

feat(vue): add onHeadUpdated composable for DOM sync#685

Closed
harlan-zw wants to merge 1 commit into
mainfrom
fix/615-sync-title-analytics
Closed

feat(vue): add onHeadUpdated composable for DOM sync#685
harlan-zw wants to merge 1 commit into
mainfrom
fix/615-sync-title-analytics

Conversation

@harlan-zw

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

Copy link
Copy Markdown
Collaborator

🔗 Linked issue

Resolves #615

❓ Type of change

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

📚 Description

Users had no way to synchronize analytics tools (Amplitude, etc.) with unhead's async DOM updates — document.title wasn't ready after onMounted + nextTick. Added onHeadUpdated(callback) composable that hooks into the internal dom:rendered event. Auto-cleans up via onUnmounted when used inside a component, or returns an unsubscribe function for manual control. Also added to Vue auto-imports.

Summary by CodeRabbit

  • New Features
    • Added onHeadUpdated composable that enables registering callbacks to execute after document head updates are applied. Callbacks receive render context information and automatically clean up on component unmount.

Exposes a public composable that registers a callback fired after unhead
finishes applying DOM updates, enabling synchronisation with analytics
tools (e.g. Amplitude) that read document.title.

Wraps the existing internal `dom:rendered` hook and auto-cleans up on
component unmount.

Closes #615
@coderabbitai

coderabbitai Bot commented Mar 10, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR adds a new onHeadUpdated composable that registers a callback to run after unhead finishes applying DOM updates. The callback receives render context information and returns an unsubscribe function, enabling synchronization of head updates with external systems like analytics tools.

Changes

Cohort / File(s) Summary
New onHeadUpdated Composable
packages/vue/src/composables.ts, packages/vue/src/autoImports.ts, packages/vue/src/index.ts
Implements onHeadUpdated function that listens to unhead's dom:rendered hook, executes a callback with render context, handles component unmount cleanup via onUnmounted, and returns an unhook function. Adds composable to auto-imports list and exports it from the public API.
Unit Tests
packages/vue/test/unit/dom/onHeadUpdated.test.ts
Comprehensive test suite validating callback invocation after DOM renders, render context structure (including renders array), reactivity across multiple renders, and unsubscribe behavior preventing further callback execution.

Sequence Diagram

sequenceDiagram
    participant VC as Vue Component
    participant Composable as onHeadUpdated()
    participant Unhead as Unhead<br/>(dom:rendered)
    participant Unmount as Component<br/>Unmount

    VC->>Composable: Call onHeadUpdated(callback)
    Composable->>Unhead: Register listener on dom:rendered
    Composable-->>VC: Return unhook function
    
    Note over Unhead: DOM updates applied
    Unhead->>Composable: Fire dom:rendered event
    Composable->>Composable: Execute callback with<br/>render context
    
    rect rgba(200, 150, 255, 0.5)
    Note over VC: Component lifecycle continues
    VC->>Unmount: Component unmounts
    Unmount->>Composable: onUnmounted fires
    Composable->>Unhead: Call unhook function
    Unhead->>Unhead: Remove listener
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A hook to sync when titles dance,
When DOM updates get their chance,
Analytics wait no longer blind,
For fresh page truth they'll surely find!
No stale captures—just new romance! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main change: adding a new onHeadUpdated composable to the Vue package for DOM synchronization.
Description check ✅ Passed The PR description fully follows the template with linked issue, type of change selection, and detailed explanation of the feature's purpose and implementation approach.
Linked Issues check ✅ Passed All code changes directly address issue #615 by implementing the onHeadUpdated composable to synchronize analytics with unhead's DOM updates, including auto-cleanup and manual control options.
Out of Scope Changes check ✅ Passed All changes are strictly scoped to implementing the onHeadUpdated feature: composable implementation, exports, auto-imports, and comprehensive unit tests with no extraneous modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/615-sync-title-analytics

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

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Bundle Size Gzipped
Client (Minimal) 10.5 kB 4.3 kB
Server (Minimal) 10.2 kB 4.2 kB
Vue Client (Minimal) 11.4 kB 4.7 kB
Vue Server (Minimal) 11.1 kB 4.6 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.

Actionable comments posted: 2

🤖 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/vue/src/composables.ts`:
- Around line 104-110: The hook registered in onHeadUpdated (the
head.hooks.hook('dom:rendered', callback) -> unhook) only removes the listener
onUnmounted, so kept-alive components still receive events while deactivated;
update onHeadUpdated to mirror clientUseHead's lifecycle handling by calling
onDeactivated to temporarily remove/suspend the dom:rendered subscription (or
call unhook) and onActivated to re-register it (or reattach the hook) in
addition to onUnmounted, using the same vm/getCurrentInstance logic, and add a
KeepAlive regression test to verify the listener does not fire while the
component is deactivated.

In `@packages/vue/test/unit/dom/onHeadUpdated.test.ts`:
- Around line 11-24: The test must await renderDOMHead to guarantee ordering:
change each renderDOMHead(...) call to be awaited (await renderDOMHead(...))
and, in the first test where callback is vi.fn(), move the document.title
assertion into the callback (i.e. assert dom.window.document.title === 'Hello
World' from inside the onHeadUpdated callback) so you verify onHeadUpdated runs
after DOM commit; apply the same await change to the other renderDOMHead calls
referenced (the calls around uses of csrVueAppWithUnhead, onHeadUpdated, and
useHead).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 06d4040c-bff9-4b3f-85c7-7a0cb805453d

📥 Commits

Reviewing files that changed from the base of the PR and between beeb5f1 and b7caf9e.

📒 Files selected for processing (4)
  • packages/vue/src/autoImports.ts
  • packages/vue/src/composables.ts
  • packages/vue/src/index.ts
  • packages/vue/test/unit/dom/onHeadUpdated.test.ts

Comment on lines +104 to +110
const head = options.head || injectHead()
const unhook = head.hooks.hook('dom:rendered', callback as any)
const vm = getCurrentInstance()
if (vm) {
onUnmounted(unhook)
}
return unhook

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== clientUseHead lifecycle handling =="
sed -n '42,68p' packages/vue/src/composables.ts

echo
echo "== onHeadUpdated lifecycle handling =="
sed -n '100,110p' packages/vue/src/composables.ts

echo
echo "== onHeadUpdated test coverage =="
sed -n '1,220p' packages/vue/test/unit/dom/onHeadUpdated.test.ts

Repository: unjs/unhead

Length of output: 3831


Suspend this hook while a kept-alive component is deactivated.

onUnmounted only removes the subscription when the instance is destroyed. A component cached by KeepAlive stays mounted, so this callback will keep receiving dom:rendered events after navigation and can double-fire analytics or other page-sync logic. clientUseHead() already handles this on Lines 42-68 with onDeactivated/onActivated; onHeadUpdated() should mirror that lifecycle and add a KeepAlive regression test.

🔧 Proposed fix
 export function onHeadUpdated(
   callback: (ctx: { renders: import('unhead/types').DomRenderTagContext[] }) => void | Promise<void>,
   options: { head?: import('unhead/types').Unhead<any> } = {},
 ): () => void {
   const head = options.head || injectHead()
-  const unhook = head.hooks.hook('dom:rendered', callback as any)
+  let active = true
+  let disposed = false
+  let unhook = head.hooks.hook('dom:rendered', callback as any)
+  const remove = () => {
+    if (active) {
+      active = false
+      unhook()
+    }
+  }
+  const subscribe = () => {
+    if (!disposed && !active) {
+      unhook = head.hooks.hook('dom:rendered', callback as any)
+      active = true
+    }
+  }
+  const dispose = () => {
+    disposed = true
+    remove()
+  }
   const vm = getCurrentInstance()
   if (vm) {
-    onUnmounted(unhook)
+    onDeactivated(remove)
+    onActivated(subscribe)
+    onUnmounted(dispose)
   }
-  return unhook
+  return dispose
 }
📝 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
const head = options.head || injectHead()
const unhook = head.hooks.hook('dom:rendered', callback as any)
const vm = getCurrentInstance()
if (vm) {
onUnmounted(unhook)
}
return unhook
export function onHeadUpdated(
callback: (ctx: { renders: import('unhead/types').DomRenderTagContext[] }) => void | Promise<void>,
options: { head?: import('unhead/types').Unhead<any> } = {},
): () => void {
const head = options.head || injectHead()
let active = true
let disposed = false
let unhook = head.hooks.hook('dom:rendered', callback as any)
const remove = () => {
if (active) {
active = false
unhook()
}
}
const subscribe = () => {
if (!disposed && !active) {
unhook = head.hooks.hook('dom:rendered', callback as any)
active = true
}
}
const dispose = () => {
disposed = true
remove()
}
const vm = getCurrentInstance()
if (vm) {
onDeactivated(remove)
onActivated(subscribe)
onUnmounted(dispose)
}
return dispose
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vue/src/composables.ts` around lines 104 - 110, The hook registered
in onHeadUpdated (the head.hooks.hook('dom:rendered', callback) -> unhook) only
removes the listener onUnmounted, so kept-alive components still receive events
while deactivated; update onHeadUpdated to mirror clientUseHead's lifecycle
handling by calling onDeactivated to temporarily remove/suspend the dom:rendered
subscription (or call unhook) and onActivated to re-register it (or reattach the
hook) in addition to onUnmounted, using the same vm/getCurrentInstance logic,
and add a KeepAlive regression test to verify the listener does not fire while
the component is deactivated.

Comment on lines +11 to +24
it('calls callback after DOM is rendered', async () => {
const dom = useDom()
const callback = vi.fn()

const head = csrVueAppWithUnhead(dom, () => {
onHeadUpdated(callback)
useHead({ title: 'Hello World' })
})

renderDOMHead(head, { document: dom.window.document })

expect(callback).toHaveBeenCalledOnce()
expect(dom.window.document.title).toBe('Hello World')
})

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

These tests need to await the render and assert the ordering contract.

Right now they only prove that the callback and the title update both happen at some point. That does not actually lock in the API promise that onHeadUpdated runs after the DOM commit. Please await each renderDOMHead() call, and in the first case assert document.title from inside callback.

✅ Proposed fix
   it('calls callback after DOM is rendered', async () => {
     const dom = useDom()
-    const callback = vi.fn()
+    const callback = vi.fn(() => {
+      expect(dom.window.document.title).toBe('Hello World')
+    })

     const head = csrVueAppWithUnhead(dom, () => {
       onHeadUpdated(callback)
       useHead({ title: 'Hello World' })
     })

-    renderDOMHead(head, { document: dom.window.document })
+    await renderDOMHead(head, { document: dom.window.document })

     expect(callback).toHaveBeenCalledOnce()
-    expect(dom.window.document.title).toBe('Hello World')
   })
-    renderDOMHead(head, { document: dom.window.document })
+    await renderDOMHead(head, { document: dom.window.document })

Apply the same await change to the calls on Lines 37, 56, 63, 79, and 85.

Also applies to: 37-37, 56-64, 79-85

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

In `@packages/vue/test/unit/dom/onHeadUpdated.test.ts` around lines 11 - 24, The
test must await renderDOMHead to guarantee ordering: change each
renderDOMHead(...) call to be awaited (await renderDOMHead(...)) and, in the
first test where callback is vi.fn(), move the document.title assertion into the
callback (i.e. assert dom.window.document.title === 'Hello World' from inside
the onHeadUpdated callback) so you verify onHeadUpdated runs after DOM commit;
apply the same await change to the other renderDOMHead calls referenced (the
calls around uses of csrVueAppWithUnhead, onHeadUpdated, and useHead).

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.

[Vue] Can't synchronise head title with analytics tools

1 participant