fix: key AppSheet render branches and self-heal never-mounted component vnodes
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 6m50s

This commit is contained in:
2026-09-23 11:12:43 +08:00
parent 2239cd4754
commit a64b7b8262
4 changed files with 39 additions and 6 deletions
+8
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { createApp, h, nextTick, ref } from 'vue' import { createApp, h, nextTick, ref } from 'vue'
import AppSheet from './AppSheet.vue' import AppSheet from './AppSheet.vue'
import AppDialog from './AppDialog.vue' import AppDialog from './AppDialog.vue'
@@ -273,4 +274,11 @@ describe('AppSheet', () => {
await nextTick() await nextTick()
expect(calls).toEqual(['two']) expect(calls).toEqual(['two'])
}) })
it('keys each render branch so modal/inline/bare switches remount instead of cross-patching', () => {
const source = readFileSync('src/components/AppSheet.vue', 'utf8')
expect(source).toContain(`:key="'app-sheet-modal'"`)
expect(source).toContain(`:key="'app-sheet-inline'"`)
expect(source).toContain(`:key="'app-sheet-bare'"`)
})
}) })
+3 -3
View File
@@ -91,7 +91,7 @@ onBeforeUnmount(deactivate)
</script> </script>
<template> <template>
<Teleport v-if="modal" :to="overlayRoot()"> <Teleport v-if="modal" :key="'app-sheet-modal'" :to="overlayRoot()">
<Transition name="app-sheet"> <Transition name="app-sheet">
<div v-if="open" class="app-overlay app-sheet-mask" :style="{ zIndex: layerZIndex }" :aria-busy="busy || undefined" @click="scrimClose"> <div v-if="open" class="app-overlay app-sheet-mask" :style="{ zIndex: layerZIndex }" :aria-busy="busy || undefined" @click="scrimClose">
<component :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown"> <component :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
@@ -100,12 +100,12 @@ onBeforeUnmount(deactivate)
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
<Teleport v-else-if="inlineTarget" :to="inlineTarget"> <Teleport v-else-if="inlineTarget" :key="'app-sheet-inline'" :to="inlineTarget">
<component v-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" :aria-busy="busy || undefined" tabindex="-1" v-bind="$attrs" @keydown="keydown"> <component v-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" :aria-busy="busy || undefined" tabindex="-1" v-bind="$attrs" @keydown="keydown">
<slot /> <slot />
</component> </component>
</Teleport> </Teleport>
<component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown"> <component v-else-if="open" :key="'app-sheet-bare'" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
<slot /> <slot />
</component> </component>
</template> </template>
+1 -1
View File
@@ -1532,7 +1532,7 @@ describe('desktop task and habit detail disclosure', () => {
expect(mvpPanel).toContain('if (busy.value && !force) return') expect(mvpPanel).toContain('if (busy.value && !force) return')
expect(mvpPanel).toContain(':modal="compactLayout"') expect(mvpPanel).toContain(':modal="compactLayout"')
expect(mvpPanel).toContain('inline-target=".shell"') expect(mvpPanel).toContain('inline-target=".shell"')
expect(appSheet).toContain('<Teleport v-else-if="inlineTarget" :to="inlineTarget">') expect(appSheet).toContain(`<Teleport v-else-if="inlineTarget" :key="'app-sheet-inline'" :to="inlineTarget">`)
expect(mvpPanel).toContain("watch(selectedHabit, (habit) => emit('detail', Boolean(habit)))") expect(mvpPanel).toContain("watch(selectedHabit, (habit) => emit('detail', Boolean(habit)))")
expect(app).toContain('@click="closeTaskDetail"') expect(app).toContain('@click="closeTaskDetail"')
expect(app).toContain('function closeTaskDetail()') expect(app).toContain('function closeTaskDetail()')
+27 -2
View File
@@ -1,9 +1,34 @@
import { defineConfig } from 'vite' import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
const GUARD_MARKER = '[dodo:vue-guard]'
// vuejs/core#5184/#8146: a stale component vnode that never mounted (component === null)
// reaches processComponent -> updateComponent -> shouldUpdateComponent and crashes on
// `component.emitsOptions`. Detect it here and self-heal with a clean remount instead,
// logging the component pair so the next production occurrence names itself in console.
function vueNullComponentGuard(): Plugin {
return {
name: 'dodo:vue-null-component-guard',
enforce: 'pre',
transform(code, id) {
if (!id.includes('runtime-core.esm-bundler.js')) return null
const header = code.indexOf('const processComponent = ')
if (header < 0) throw new Error(`${GUARD_MARKER} processComponent not found in runtime-core — Vue internals changed, update the guard anchor`)
const anchor = code.indexOf('if (n1 == null) {', header)
if (anchor < 0 || anchor - header > 400) throw new Error(`${GUARD_MARKER} mount-branch anchor not found — update the guard anchor`)
if (code.slice(header, anchor).includes(GUARD_MARKER)) return null
const inject = `if (n1 != null && n1.component == null) {\n try { const nm = (x) => (x && (x.name || x.__name)) || (typeof x === "string" ? x : typeof x === "symbol" ? (x.description || "symbol") : "?"); console.error("${GUARD_MARKER} never-mounted component vnode — self-healing remount", { oldType: nm(n1.type), newType: nm(n2.type), oldKey: n1.key, newKey: n2.key, el: !!n1.el, oldChildren: Array.isArray(n1.children) ? n1.children.map((c) => nm(c.type)) : String(n1.children).slice(0, 60) }); } catch (e) {}\n n1 = null;\n }\n `
return { code: code.slice(0, anchor) + inject + code.slice(anchor), map: null }
},
}
}
export default defineConfig({ export default defineConfig({
plugins: [vue(), tailwindcss()], plugins: [vue(), tailwindcss(), vueNullComponentGuard()],
// Serve vue through the normal transform pipeline so the guard applies in dev too.
optimizeDeps: { exclude: ['vue', '@vue/runtime-dom', '@vue/runtime-core', '@vue/reactivity', '@vue/shared'] },
server: { proxy: { '/api': 'http://localhost:8781', '/health': 'http://localhost:8781' } }, server: { proxy: { '/api': 'http://localhost:8781', '/health': 'http://localhost:8781' } },
test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'] }, test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'] },
}) })