feat(vue): add onHeadUpdated composable for DOM sync#685
Conversation
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
📝 WalkthroughWalkthroughThe PR adds a new Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 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 |
Bundle Size Analysis
|
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
packages/vue/src/autoImports.tspackages/vue/src/composables.tspackages/vue/src/index.tspackages/vue/test/unit/dom/onHeadUpdated.test.ts
| const head = options.head || injectHead() | ||
| const unhook = head.hooks.hook('dom:rendered', callback as any) | ||
| const vm = getCurrentInstance() | ||
| if (vm) { | ||
| onUnmounted(unhook) | ||
| } | ||
| return unhook |
There was a problem hiding this comment.
🧩 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.tsRepository: 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.
| 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.
| 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') | ||
| }) |
There was a problem hiding this comment.
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).
🔗 Linked issue
Resolves #615
❓ Type of change
📚 Description
Users had no way to synchronize analytics tools (Amplitude, etc.) with unhead's async DOM updates —
document.titlewasn't ready afteronMounted+nextTick. AddedonHeadUpdated(callback)composable that hooks into the internaldom:renderedevent. Auto-cleans up viaonUnmountedwhen used inside a component, or returns an unsubscribe function for manual control. Also added to Vue auto-imports.Summary by CodeRabbit
onHeadUpdatedcomposable that enables registering callbacks to execute after document head updates are applied. Callbacks receive render context information and automatically clean up on component unmount.