Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/runtime-core/__tests__/hmr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,4 +1040,57 @@ describe('hot module replacement', () => {

expect(serializeInner(root)).toBe('<div>bar</div>')
})

// #14127
test('update cached text nodes', async () => {
const root = nodeOps.createElement('div')
const appId = 'test-cached-text-nodes'
const App: ComponentOptions = {
__hmrId: appId,
data() {
return {
count: 0,
}
},
render: compileToFunction(
`{{count}}
<button @click="count++">++</button>
static text`,
),
}
createRecord(appId, App)
render(h(App), root)
expect(serializeInner(root)).toBe(`0 <button>++</button> static text`)

// trigger count update
triggerEvent((root as any).children[2], 'click')
await nextTick()
expect(serializeInner(root)).toBe(`1 <button>++</button> static text`)

// trigger HMR update
rerender(
appId,
compileToFunction(
`{{count}}
<button @click="count++">++</button>
static text updated`,
),
)
expect(serializeInner(root)).toBe(
`1 <button>++</button> static text updated`,
)

// trigger HMR update again
rerender(
appId,
compileToFunction(
`{{count}}
<button @click="count++">++</button>
static text updated2`,
),
)
expect(serializeInner(root)).toBe(
`1 <button>++</button> static text updated2`,
)
})
})
37 changes: 31 additions & 6 deletions packages/runtime-core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,27 @@ function baseCreateRenderer(
} else {
const el = (n2.el = n1.el!)
if (n2.children !== n1.children) {
hostSetText(el, n2.children as string)
// We don't inherit el for cached text nodes in `traverseStaticChildren`
// to avoid retaining detached DOM nodes. However, the text node may be
// changed during HMR. In this case we need to replace the old text node
// with the new one.
if (
__DEV__ &&
isHmrUpdating &&
n2.patchFlag === PatchFlags.CACHED &&
'__elIndex' in n1
) {
const childNodes = __TEST__
? container.children
: container.childNodes
const newChild = hostCreateText(n2.children as string)
const oldChild =
childNodes[((n2 as any).__elIndex = (n1 as any).__elIndex)]
hostInsert(newChild, container, oldChild)
hostRemove(oldChild)
} else {
hostSetText(el, n2.children as string)
}
Comment on lines +503 to +523
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: n2.el is not updated to reference the new text node.

After creating and inserting newChild (line 516-519), then removing oldChild (line 520), n2.el still points to the old element that was set on line 501. This means n2.el references a detached DOM node after the replacement.

Apply this diff to update n2.el to reference the new text node:

         const newChild = hostCreateText(n2.children as string)
         const oldChild =
           childNodes[((n2 as any).__elIndex = (n1 as any).__elIndex)]
+        n2.el = newChild
         hostInsert(newChild, container, oldChild)
         hostRemove(oldChild)

This follows the pattern used in patchStaticNode (lines 574-584), which properly updates n2.el and n2.anchor after replacing static content during HMR.

📝 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
// We don't inherit el for cached text nodes in `traverseStaticChildren`
// to avoid retaining detached DOM nodes. However, the text node may be
// changed during HMR. In this case we need to replace the old text node
// with the new one.
if (
__DEV__ &&
isHmrUpdating &&
n2.patchFlag === PatchFlags.CACHED &&
'__elIndex' in n1
) {
const childNodes = __TEST__
? container.children
: container.childNodes
const newChild = hostCreateText(n2.children as string)
const oldChild =
childNodes[((n2 as any).__elIndex = (n1 as any).__elIndex)]
hostInsert(newChild, container, oldChild)
hostRemove(oldChild)
} else {
hostSetText(el, n2.children as string)
}
// We don't inherit el for cached text nodes in `traverseStaticChildren`
// to avoid retaining detached DOM nodes. However, the text node may be
// changed during HMR. In this case we need to replace the old text node
// with the new one.
if (
__DEV__ &&
isHmrUpdating &&
n2.patchFlag === PatchFlags.CACHED &&
'__elIndex' in n1
) {
const childNodes = __TEST__
? container.children
: container.childNodes
const newChild = hostCreateText(n2.children as string)
const oldChild =
childNodes[((n2 as any).__elIndex = (n1 as any).__elIndex)]
n2.el = newChild
hostInsert(newChild, container, oldChild)
hostRemove(oldChild)
} else {
hostSetText(el, n2.children as string)
}
🤖 Prompt for AI Agents
In packages/runtime-core/src/renderer.ts around lines 503 to 523, after creating
and inserting newChild and removing oldChild during HMR replace logic, n2.el is
still pointing at the detached old element; update n2 to reference the newly
inserted node (assign n2.el = newChild) and also update n2.anchor if the node
uses an anchor (follow the same assignment pattern as patchStaticNode lines
~574-584) so the vnode now points to the live DOM node.

}
}
}
Expand Down Expand Up @@ -2496,12 +2516,17 @@ export function traverseStaticChildren(
traverseStaticChildren(c1, c2)
}
// #6852 also inherit for text nodes
if (
c2.type === Text &&
if (c2.type === Text) {
// avoid cached text nodes retaining detached dom nodes
c2.patchFlag !== PatchFlags.CACHED
) {
c2.el = c1.el
if (c2.patchFlag !== PatchFlags.CACHED) {
c2.el = c1.el
} else {
// cache the child index for HMR updates
;(c2 as any).__elIndex =
i +
// take fragment start anchor into account
(n1.type === Fragment ? 1 : 0)
}
}
// #2324 also inherit for comment nodes, but not placeholders (e.g. v-if which
// would have received .el during block patch)
Expand Down
Loading