diff --git a/.changeset/guard-null-vnode-mark-dirty.md b/.changeset/guard-null-vnode-mark-dirty.md new file mode 100644 index 00000000000..077a2bf576d --- /dev/null +++ b/.changeset/guard-null-vnode-mark-dirty.md @@ -0,0 +1,7 @@ +--- +'@qwik.dev/core': patch +--- + +fix: guard null/undefined vNode in markVNodeDirty + +Prevent `TypeError: Cannot read properties of undefined (reading 'dirty')` crash in `markVNodeDirty` when callers pass an undefined vNode. This happens when `DomContainer.$destroy$()` is called during async qwikloader dispatch — `$getObjectById$` returns `undefined` for all pending deserialization, so `scheduleTask`, `scheduleEffects`, `_hmr`, `_val`, `_chk`, and `_res` all pass undefined vNodes to `markVNodeDirty`. diff --git a/packages/qwik/src/core/shared/vnode/vnode-dirty.ts b/packages/qwik/src/core/shared/vnode/vnode-dirty.ts index f38ebae7773..de256e7d447 100644 --- a/packages/qwik/src/core/shared/vnode/vnode-dirty.ts +++ b/packages/qwik/src/core/shared/vnode/vnode-dirty.ts @@ -111,6 +111,13 @@ export function markVNodeDirty( bits: ChoreBits, cursorRoot: VNode | null = null ): void { + if (!vNode) { + // vNode can be undefined when a container is destroyed during async qwikloader dispatch. + // DomContainer.$destroy$() replaces $getObjectById$ with () => undefined and truncates + // $stateData$, so deserializeCaptures returns [undefined] for pending event handlers. + // This affects scheduleTask, scheduleEffects, _hmr, _val, _chk, and _res. + return; + } const prevDirty = vNode.dirty; vNode.dirty |= bits; if (isSsrNodeGuard(vNode)) { diff --git a/packages/qwik/src/core/shared/vnode/vnode-dirty.unit.ts b/packages/qwik/src/core/shared/vnode/vnode-dirty.unit.ts new file mode 100644 index 00000000000..1a2a273ddb6 --- /dev/null +++ b/packages/qwik/src/core/shared/vnode/vnode-dirty.unit.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; +import { markVNodeDirty } from './vnode-dirty'; +import { ChoreBits } from './enums/chore-bits.enum'; +import type { Container } from '../types'; + +describe('markVNodeDirty', () => { + it('does not throw when vNode is undefined (destroyed container)', () => { + // After DomContainer.$destroy$(), $getObjectById$ returns undefined for all IDs. + // Callers like scheduleTask, _hmr, _val, _chk pass the deserialized result + // directly to markVNodeDirty — which would be undefined. + const container = {} as Container; + expect(() => { + markVNodeDirty(container, undefined as any, ChoreBits.TASKS); + }).not.toThrow(); + }); + + it('does not throw when vNode is null', () => { + const container = {} as Container; + expect(() => { + markVNodeDirty(container, null as any, ChoreBits.TASKS); + }).not.toThrow(); + }); +});