Skip to content

fix(runtime-core): prevent crash when disabled Teleport with component child is inside Suspense#14737

Closed
ZackaryShen wants to merge 2 commits intovuejs:mainfrom
ZackaryShen:fix/teleport-suspense-regression-14701
Closed

fix(runtime-core): prevent crash when disabled Teleport with component child is inside Suspense#14737
ZackaryShen wants to merge 2 commits intovuejs:mainfrom
ZackaryShen:fix/teleport-suspense-regression-14701

Conversation

@ZackaryShen
Copy link
Copy Markdown

@ZackaryShen ZackaryShen commented Apr 19, 2026

Fix Issue #14701

Problem

When a disabled Teleport containing a component child is placed inside a Suspense boundary, moving the Teleport causes a crash:

TypeError: Cannot read properties of null (reading 'subTree')

Root Cause

When Suspense resolves, it calls move() to reposition content. The renderer was unconditionally accessing vnode.component.subTree, but for disabled Teleports, component children haven't been mounted yet.

Fix

Added null check for vnode.component before accessing .subTree.

Testing

Added regression test.

Closes #14701

Summary by CodeRabbit

  • Bug Fixes

    • Fixed a crash when attempting to move unmounted components
    • Fixed issue with Suspense boundaries containing disabled Teleport components with async children
  • Style

    • Improved CSS scoped attribute injection for nested pseudo-class selectors with deep selectors
  • Tests

    • Added test coverage for CSS selector transformations and Suspense edge cases

ZackaryShen added 2 commits April 19, 2026 16:10
Fix issue vuejs#14724 where :deep at the start of :is/:where selector
was not being properly resolved to [data-v-xxx].

Before: :is(:deep(.foo)) -> :is(:deep(.foo))
After:  :is(:deep(.foo)) -> :is([data-v-xxx] .foo)

The fix checks if __deep was set during recursion and injects
the scoped attribute at the start of the inner selector.
…t child is inside Suspense

Fix issue vuejs#14701 where moving a disabled Teleport with a component
child inside Suspense causes 'TypeError: Cannot read properties of null
(reading subTree)'.

Root cause: When a disabled Teleport is inside a Suspense boundary,
the component children haven't been mounted yet (deferred mount via
queuePendingMount). The move() function was unconditionally accessing
vnode.component.subTree without checking if component exists.

Fix: Add null check for vnode.component before trying to move its subTree.
If component hasn't been mounted yet, skip the move.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

Fixes regression in issue #14701 where accessing subTree of an unmounted component vnode causes a crash during Suspense resolution with disabled Teleports. Additionally updates scoped CSS compilation to properly inject scope attributes when :deep() is the first argument inside :is() and :where() selectors. Includes test coverage for both fixes.

Changes

Cohort / File(s) Summary
Scoped CSS Selector Compilation
packages/compiler-sfc/__tests__/compileStyle.spec.ts, packages/compiler-sfc/src/style/pluginScoped.ts
Added test assertions for :is(:deep(...)) and :where(:deep(...)) selector transformations. Modified rewriteSelector logic to inject the scoped attribute selector before the first node of inner selector content when :deep() appears as the first argument, handling both normal and slotted rewriting cases.
Suspense/Teleport Component Move Guard
packages/runtime-core/__tests__/components/Suspense.spec.ts, packages/runtime-core/src/renderer.ts
Added test case for Suspense boundary with disabled Teleport containing async component child. Added null check guard in renderer's move logic to prevent accessing subTree of unmounted component vnodes during Suspense resolution.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

ready to merge, scope: suspense, scope: teleport, :hammer: p3-minor-bug

Suggested reviewers

  • edison1105
  • yyx990803
  • skirtles-code

Poem

🐰 A deep fix for selectors bright,
Where :is and :where find the light,
Suspense breathes calm, no crash in sight,
Component vnodes guarded tight,
Teleports dance without a fright! 🎉

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main fix: preventing a crash when a disabled Teleport with a component child is inside Suspense.
Linked Issues check ✅ Passed The PR successfully addresses all objectives from issue #14701: adding a null check for vnode.component before accessing .subTree in the move logic, and including a regression test covering the exact failure conditions.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing issue #14701: guard against null vnode.component in renderer.ts, add :deep() selector handling in pluginScoped.ts, and include relevant regression tests.
Description check ✅ Passed The PR description clearly explains the regression, root cause, and solution; it properly references issue #14701 and describes the conditions needed to trigger the bug.

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

✨ Finishing Touches
🧪 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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
packages/compiler-sfc/src/style/pluginScoped.ts (1)

245-268: ⚠️ Potential issue | 🟠 Major

Fix cross-container node references and trailing selector handling in :is/:where + :deep() processing.

Lines 253 and 262 call selector.insertBefore() (the outer selector container) using (node as selectorParser.Pseudo).nodes[0] (a child of the inner selector container) as the anchor. According to postcss-selector-parser, insertBefore requires the anchor to be a direct child of the calling container. Additionally, the node selection logic (lines 128–135) causes this entire block to be skipped for cases like :is(:deep(.foo)) .bar since node becomes .bar, not :is. To fix this, the :deep() handling for :is/:where should either:

  • Detect :deep() inside :is/:where before the node selection loop completes, or
  • Insert using the inner selector's container ((node as selectorParser.Pseudo).nodes[0].parent) instead of the outer selector

Also consider whether (rule as any).__deep persisting across multiple selectors in a comma-separated list can cause unexpected state carryover.

Verify with:

compileScoped(`:is(:deep(.foo)) .bar { color: red; }`)
compileScoped(`:is(:deep(.foo)), :is(.bar) { color: red; }`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/compiler-sfc/src/style/pluginScoped.ts` around lines 245 - 268, The
insertion into the selector is using selector.insertBefore with an anchor that
is a child of the inner :is/:where pseudo, which violates
postcss-selector-parser’s container/anchor rules and also misses cases where
node becomes the outer sibling (e.g. :is(:deep(.foo)) .bar); to fix, detect
:deep() inside :is/:where earlier (inside the rewriteSelector traversal of the
pseudo) or change the insert target to the inner selector container (use (node
as selectorParser.Pseudo).nodes[0].parent as the container when calling
insertBefore instead of selector), and ensure (rule as any).__deep is
cleared/isolated per selector (reset before moving to the next comma-separated
selector) to avoid state carryover; update the logic around
selector.insertBefore, the pseudo handling block that uses (node as
selectorParser.Pseudo).nodes[0], and the __deep lifecycle to implement these
changes and verify with compileScoped tests provided.
🤖 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/runtime-core/__tests__/components/Suspense.spec.ts`:
- Around line 2575-2619: The test currently uses real DOM nodes
(document.createElement/appendChild/removeChild) but the renderer imported via
render (from `@vue/runtime-test`) expects test nodes from nodeOps; replace any
document.createElement('div') usages for target and root with
nodeOps.createElement('div'), stop using document.body.appendChild/removeChild,
and update the assertion to use serializeInner(root) (or serializeInner(target)
as appropriate) instead of checking root.innerHTML; keep the rest of the logic
(Comp, Child, Parent, toggle, resolve, Suspense, Teleport) unchanged so the test
uses nodeOps.createElement and serializeInner from `@vue/runtime-test` utilities.

---

Outside diff comments:
In `@packages/compiler-sfc/src/style/pluginScoped.ts`:
- Around line 245-268: The insertion into the selector is using
selector.insertBefore with an anchor that is a child of the inner :is/:where
pseudo, which violates postcss-selector-parser’s container/anchor rules and also
misses cases where node becomes the outer sibling (e.g. :is(:deep(.foo)) .bar);
to fix, detect :deep() inside :is/:where earlier (inside the rewriteSelector
traversal of the pseudo) or change the insert target to the inner selector
container (use (node as selectorParser.Pseudo).nodes[0].parent as the container
when calling insertBefore instead of selector), and ensure (rule as any).__deep
is cleared/isolated per selector (reset before moving to the next
comma-separated selector) to avoid state carryover; update the logic around
selector.insertBefore, the pseudo handling block that uses (node as
selectorParser.Pseudo).nodes[0], and the __deep lifecycle to implement these
changes and verify with compileScoped tests provided.
🪄 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: 2a4b1332-1cfc-4eed-aa66-d9096900772f

📥 Commits

Reviewing files that changed from the base of the PR and between 7df0edd and ef04e30.

📒 Files selected for processing (4)
  • packages/compiler-sfc/__tests__/compileStyle.spec.ts
  • packages/compiler-sfc/src/style/pluginScoped.ts
  • packages/runtime-core/__tests__/components/Suspense.spec.ts
  • packages/runtime-core/src/renderer.ts

Comment on lines +2575 to +2619
const target = document.createElement('div')
document.body.appendChild(target)

const Comp = {
render() {
return [
h('div', 'content'),
h(Teleport, { to: target, disabled: true }, h(Child, { msg: 'hello' })),
]
},
}

let resolve!: () => void
const Child = {
props: ['msg'],
async setup(props: any) {
await new Promise(r => (resolve = r))
return () => h('span', props.msg)
},
}

const toggle = ref(true)
const Parent = {
render() {
return toggle.value
? h(Suspense, null, {
default: () => h(Comp),
fallback: h('div', 'fallback'),
})
: null
},
}

const root = document.createElement('div')
render(h(Parent), root)

// Trigger move by toggling
toggle.value = false
await nextTick()

// Should not throw "Cannot read properties of null (reading 'subTree')"
expect(root.innerHTML).toBe('')

// Clean up
document.body.removeChild(target)
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
# Description: Inspect `@vue/runtime-test` nodeOps to confirm it expects runtime-test nodes, not native DOM nodes.

fd -i 'runtime-test' -t f | sed -n '1,80p'

rg -n -C4 'createRenderer|nodeOps|insert\s*\(|children\.(push|splice)|parentNode\s*=' --iglob '*.ts' --iglob '*runtime-test*'

Repository: vuejs/core

Length of output: 50369


Use nodeOps test nodes with @vue/runtime-test render.

This test imports render from @vue/runtime-test, which uses a custom node system backed by nodeOps. Passing real DOM nodes created via document.createElement() will cause failures—the renderer expects test nodes with children and parentNode properties. Match surrounding Suspense/Teleport tests by using nodeOps.createElement() and serializeInner() instead.

Proposed fix
-    const target = document.createElement('div')
-    document.body.appendChild(target)
+    const target = nodeOps.createElement('div')
@@
-    const root = document.createElement('div')
+    const root = nodeOps.createElement('div')
     render(h(Parent), root)
@@
-    expect(root.innerHTML).toBe('')
-
-    // Clean up
-    document.body.removeChild(target)
+    expect(serializeInner(root)).toBe('')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/runtime-core/__tests__/components/Suspense.spec.ts` around lines
2575 - 2619, The test currently uses real DOM nodes
(document.createElement/appendChild/removeChild) but the renderer imported via
render (from `@vue/runtime-test`) expects test nodes from nodeOps; replace any
document.createElement('div') usages for target and root with
nodeOps.createElement('div'), stop using document.body.appendChild/removeChild,
and update the assertion to use serializeInner(root) (or serializeInner(target)
as appropriate) instead of checking root.innerHTML; keep the rest of the logic
(Comp, Child, Parent, toggle, resolve, Suspense, Teleport) unchanged so the test
uses nodeOps.createElement and serializeInner from `@vue/runtime-test` utilities.

@edison1105
Copy link
Copy Markdown
Member

duplicate of #14702

@edison1105 edison1105 closed this Apr 19, 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.

[Teleport] TypeError: Cannot read properties of null (reading 'subTree') during Suspense resolve — regression in 3.5.32

2 participants