diff --git a/frontend/src/components/AppSheet.test.ts b/frontend/src/components/AppSheet.test.ts index d1d70dc..bb9e4a7 100644 --- a/frontend/src/components/AppSheet.test.ts +++ b/frontend/src/components/AppSheet.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' import { createApp, h, nextTick, ref } from 'vue' import AppSheet from './AppSheet.vue' import AppDialog from './AppDialog.vue' @@ -273,4 +274,11 @@ describe('AppSheet', () => { await nextTick() 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'"`) + }) }) diff --git a/frontend/src/components/AppSheet.vue b/frontend/src/components/AppSheet.vue index d85dfaf..100129a 100644 --- a/frontend/src/components/AppSheet.vue +++ b/frontend/src/components/AppSheet.vue @@ -91,7 +91,7 @@ onBeforeUnmount(deactivate) - + @@ -100,12 +100,12 @@ onBeforeUnmount(deactivate) - + - + diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 7f8c37c..e883915 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -1532,7 +1532,7 @@ describe('desktop task and habit detail disclosure', () => { expect(mvpPanel).toContain('if (busy.value && !force) return') expect(mvpPanel).toContain(':modal="compactLayout"') expect(mvpPanel).toContain('inline-target=".shell"') - expect(appSheet).toContain('') + expect(appSheet).toContain(``) expect(mvpPanel).toContain("watch(selectedHabit, (habit) => emit('detail', Boolean(habit)))") expect(app).toContain('@click="closeTaskDetail"') expect(app).toContain('function closeTaskDetail()') diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1b55e4c..6fca409 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,9 +1,34 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import vue from '@vitejs/plugin-vue' 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({ - 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' } }, test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'] }, })