Skip to content

fix(ContentRenderer): keep async component identity stable across re-resolves - #3835

Open
hendrikheil wants to merge 1 commit into
nuxt:mainfrom
hendrikheil:fix/stable-async-component-identity
Open

fix(ContentRenderer): keep async component identity stable across re-resolves#3835
hendrikheil wants to merge 1 commit into
nuxt:mainfrom
hendrikheil:fix/stable-async-component-identity

Conversation

@hendrikheil

@hendrikheil hendrikheil commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🔗 Linked issue

Ref nuxt-content/nuxt-studio#383

❓ Type of change

  • 🐞 Bug fix (a non-breaking change that fixes an issue)

📚 Description

defineAsyncComponent returns a new component type on every call. resolveVueComponent calls it in two places, and it is reached from componentsMap, a computed that re-resolves whenever props.value changes identity:

const componentsMap = computed(() => {
  return body.value ? resolveContentComponents(body.value, { tags: tags.value }) : {}
})
else if (localComponents.includes(pascalCase(component))) {
  const loader = localComponentLoaders[pascalCase(component)]
  _component = loader ? defineAsyncComponent(loader) : undefined  // new type every time
}

So every re-resolve hands MDCRenderer a fresh component type for each local component. Vue sees a different type at the same vnode position, so it cannot patch — it unmounts and remounts, and the async wrapper renders nothing for a tick while it re-resolves.

This is what makes Studio's live preview flash the entire article on every keystroke: Studio calls app:data:refresh, useAsyncData hands back a new document object, and the whole rendered tree is destroyed and recreated. Because the DOM is torn down, page height momentarily collapses and the scroll position is lost too — that's the "jumps back to the top" half of nuxt-content/nuxt-studio#383.

Projects that mark their content components global don't hit this, because resolveComponent returns a stable definition. Local components are a supported (and bundle-size-motivated) configuration, so they should behave the same.

The fix caches both wrappers — a Map keyed by pascal name for the loader branch, a WeakMap keyed by the component object for the setup branch — so repeated resolutions return the same instance and Vue patches the tree.

I also hoisted the repeated pascalCase(component) into a local, since it's now used three times.

📝 Checklist

  • I have linked an issue or discussion.
  • I have added tests (none — this is a component-identity fix with no observable output change; happy to add a regression test if you'd like one, though asserting "same vnode type across re-resolves" needs a bit of test scaffolding that doesn't exist yet).

@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

@hendrikheil is attempting to deploy a commit to the Nuxt Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 57cb08ca-5804-46bc-9913-e9e0612a12be

📥 Commits

Reviewing files that changed from the base of the PR and between 274d426 and a981e06.

📒 Files selected for processing (1)
  • src/runtime/components/ContentRenderer.vue
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/runtime/components/ContentRenderer.vue

Included review availability: Your plan includes up to 8 reviews per rolling hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

ContentRenderer.vue now types component caches with Vue’s Component type. It caches locally loaded async components by PascalCase name and caches wrappers for component objects by identity. Component loaders and defineAsyncComponent wrappers are reused during repeated component-map resolution.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to a981e

This localized fix keeps async content component identity stable, preventing unnecessary remounts and preview flashes; no actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the ContentRenderer fix that stabilizes async component identity across re-resolves.
Description check ✅ Passed The description explains the component identity bug, its impact, the caching fix, and the related issue.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@pkg-pr-new

pkg-pr-new Bot commented Aug 18, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@nuxt/content@3835

commit: a981e06

…resolves

defineAsyncComponent returns a new component type on every call, and componentsMap re-resolves whenever the rendered document changes. Vue sees a different type at the same vnode position and remounts the subtree instead of patching it.

Cache the wrappers so repeated resolutions return the same instance.

Ref nuxt-content/nuxt-studio#383
@nicokempe

nicokempe commented Aug 18, 2026

Copy link
Copy Markdown

Hi @hendrikheil with that change, edits in Nuxt Studio don't seem to update the live preview at all anymore? No flicker and no scroll-issues anymore as described in nuxt-content/nuxt-studio#383 but now no changes are visible in the front-end, updates are written to the file but a page reload doesn't even help to make them visible - does it work in your project? I can also share a reproduction video again if that's helpful

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.

2 participants