Skip to content

fix(qwik): guard null/undefined vNode in markVNodeDirty#8563

Open
juslintek wants to merge 1 commit intoQwikDev:build/v2from
juslintek:fix/guard-null-vnode-in-mark-dirty
Open

fix(qwik): guard null/undefined vNode in markVNodeDirty#8563
juslintek wants to merge 1 commit intoQwikDev:build/v2from
juslintek:fix/guard-null-vnode-in-mark-dirty

Conversation

@juslintek
Copy link
Copy Markdown

@juslintek juslintek commented Apr 16, 2026

What is it?

  • Bug fix

Description

Fixes a runtime crash where markVNodeDirty receives an undefined/null vNode, causing:

TypeError: Cannot read properties of undefined (reading 'dirty')
  at markVNodeDirty (@qwik.dev/core/dist/core.mjs:6727:29)

This crash makes the entire application non-functional — no page renders, no login, all routes are dead.

Root Cause

When DomContainer.$destroy$() is called during async qwikloader dispatch (e.g., SPA navigation destroys the container while queued event handlers are pending), it:

  1. Truncates $rawStateData$ and $stateData$ to length 0
  2. Replaces $getObjectById$ with () => undefined

Pending event handlers then call deserializeCaptures() which invokes container.$getObjectById$(id) → returns undefined. The deserialized vNode references are undefined, and callers pass them directly to markVNodeDirty.

Affected callers (all have the same vulnerability)

Caller File What it passes
scheduleTask use-task.ts task.$el$
scheduleEffects reactive-primitives/utils.ts consumer.$el$ / consumer
_hmr use-hmr.ts _captures![0]
_val bind-handlers.ts _captures![0]
_chk bind-handlers.ts _captures![0]
_res bind-handlers.ts _captures![0]

Why fix in markVNodeDirty instead of each caller

  • Single fix point — one guard protects all 20 callers, including the 6 vulnerable ones above
  • Zero API surface change — no new fields, no interface changes, no coupling to DomContainer
  • Covers all edge cases — not just full container destruction, but also partially-inflated $stateData$ during lazy deserialization
  • Future-proof — any new caller that passes an undefined vNode is automatically protected

Alternative approaches considered

Approach Pros Cons
Guard at each call site (!task?.$el$) Matches existing WrappedSignalImpl pattern Decentralized — must be added to every caller; doesn't protect _hmr, _val, _chk, _res
$destroyed$ flag on DomContainer Semantically explicit Requires Container interface change; doesn't cover partial inflation; more invasive
Fix in qwikloader (element.closest() re-check) Fixes all handlers at once Adds DOM traversal cost to every event dispatch; qwikloader is size-sensitive
Harden markVNodeDirty (this PR) Single fix, all callers, zero API change Silent return on undefined — mitigated by comment explaining when/why

Changes

packages/qwik/src/core/shared/vnode/vnode-dirty.ts

  • Added null/undefined guard at the top of markVNodeDirty with explanatory comment

packages/qwik/src/core/shared/vnode/vnode-dirty.unit.ts (new)

  • Test: does not throw when vNode is undefined (destroyed container scenario)
  • Test: does not throw when vNode is null

.changeset/guard-null-vnode-mark-dirty.md

  • Patch changeset for @qwik.dev/core

Docs

No user-facing documentation changes needed — markVNodeDirty is an internal API.

Checklist

  • My code follows the developer guidelines of this project
  • I performed a self-review of my own code
  • I added a changeset with pnpm change
  • I made corresponding changes to the Qwik docs (N/A — internal API only)
  • I added new tests to cover the fix / functionality

When a container is destroyed during async qwikloader dispatch,
DomContainer.$destroy$() replaces $getObjectById$ with () => undefined
and truncates $stateData$. Pending event handlers (scheduleTask, _hmr,
_val, _chk, _res) then deserialize undefined vNodes and pass them to
markVNodeDirty, causing:

  TypeError: Cannot read properties of undefined (reading 'dirty')

This single guard in markVNodeDirty protects all 20 callers at once,
rather than requiring individual guards at each call site.
@juslintek juslintek requested a review from a team as a code owner April 16, 2026 14:49
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 16, 2026

🦋 Changeset detected

Latest commit: 05d7ef1

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Member

@Varixo Varixo left a comment

Choose a reason for hiding this comment

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

Hello, thank you for PR, but this look like workaround for me. Added comments

// $stateData$, so deserializeCaptures returns [undefined] for pending event handlers.
// This affects scheduleTask, scheduleEffects, _hmr, _val, _chk, and _res.
return;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Then its a bug that this funtion called on container destroyed, it is workaround not real fix

Comment on lines +6 to +23
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();
});
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

you are testing a fix, not failing test case. If I understand it correctly, to test it correctly you need to test $destroy$ on container

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.

2 participants