From 6d7b0cea40764b97b242d8d6ea2fa4713c32b2d0 Mon Sep 17 00:00:00 2001 From: Linas Jusys Date: Wed, 15 Apr 2026 17:14:34 +0300 Subject: [PATCH 1/5] fix(qwik): guard undefined vNode in scheduleTask and scheduleEffects When the qwikloader dispatches events asynchronously, the Task's host element ($el$) can be undefined if the container was destroyed during async dispatch or if deserialization produced an incomplete Task object. This causes `markVNodeDirty` to crash with: TypeError: Cannot read properties of undefined (reading 'dirty') Add null checks for `task.$el$` / `consumer.$el$` before calling `markVNodeDirty`, consistent with the guard already present in `wrapped-signal-impl.ts`. --- packages/qwik/src/core/reactive-primitives/utils.ts | 4 ++++ packages/qwik/src/core/use/use-task.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/qwik/src/core/reactive-primitives/utils.ts b/packages/qwik/src/core/reactive-primitives/utils.ts index 054eaa5343e..74c070d515a 100644 --- a/packages/qwik/src/core/reactive-primitives/utils.ts +++ b/packages/qwik/src/core/reactive-primitives/utils.ts @@ -95,6 +95,10 @@ export const scheduleEffects = ( const property = effectSubscription.property; isDev && assertDefined(container, 'Container must be defined.'); if (isTask(consumer)) { + if (!consumer.$el$) { + // Host element not available (container may have been destroyed) + return; + } consumer.$flags$ |= TaskFlags.DIRTY; markVNodeDirty(container!, consumer.$el$, ChoreBits.TASKS); } else if (consumer instanceof SignalImpl) { diff --git a/packages/qwik/src/core/use/use-task.ts b/packages/qwik/src/core/use/use-task.ts index f6d36d6c8c7..2a3f911d155 100644 --- a/packages/qwik/src/core/use/use-task.ts +++ b/packages/qwik/src/core/use/use-task.ts @@ -270,6 +270,11 @@ export function scheduleTask(this: string, _event: Event, element: Element) { setCaptures(deserializeCaptures(container, this)); } const task = _captures![0] as Task; + if (!task?.$el$) { + // Task or its host element was not properly deserialized + // (e.g., container destroyed during async dispatch) + return; + } task.$flags$ |= TaskFlags.DIRTY; markVNodeDirty(container, task.$el$, ChoreBits.TASKS); } From 4157811b772353c7150898b2c91baed827f9e8e6 Mon Sep 17 00:00:00 2001 From: Linas Jusys Date: Wed, 15 Apr 2026 17:15:33 +0300 Subject: [PATCH 2/5] test(qwik): add tests for scheduleTask with undefined vNode Verify that scheduleTask does not throw when task.$el$ is undefined or when the task itself is not properly deserialized. --- packages/qwik/src/core/use/use-task.unit.ts | 72 ++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/qwik/src/core/use/use-task.unit.ts b/packages/qwik/src/core/use/use-task.unit.ts index 627f1de4a77..3e629dd9fdc 100644 --- a/packages/qwik/src/core/use/use-task.unit.ts +++ b/packages/qwik/src/core/use/use-task.unit.ts @@ -5,7 +5,7 @@ import type { Container, HostElement } from '../shared/types'; import { useResource$ } from './use-resource-dollar'; import { useSignal } from './use-signal'; import { useStore } from './use-store.public'; -import { Task, TaskFlags, runTask } from './use-task'; +import { Task, TaskFlags, runTask, scheduleTask } from './use-task'; import { useTask$ } from './use-task-dollar'; import { useVisibleTask$ } from './use-visible-task-dollar'; @@ -169,3 +169,73 @@ describe('runTask', () => { expect(cleanupCalls).toBe(1); }); }); + +describe('scheduleTask', () => { + it('does not throw when task.$el$ is undefined', () => { + // Simulate a task with undefined $el$ (e.g., container destroyed during async dispatch) + const task = new Task( + TaskFlags.TASK, + 0, + undefined as unknown as HostElement, + {} as QRLInternal, + undefined, + null + ); + + // Mock _captures to return our task + vi.mock('../shared/qrl/qrl-class', async () => { + const actual = + await vi.importActual('../shared/qrl/qrl-class'); + return { + ...actual, + _captures: [task], + deserializeCaptures: vi.fn(), + setCaptures: vi.fn(), + }; + }); + + vi.mock('../client/dom-container', () => ({ + getDomContainer: vi.fn(() => ({})), + })); + + vi.mock('../shared/vnode/vnode-dirty', () => ({ + markVNodeDirty: vi.fn(), + })); + + const mockElement = {} as Element; + const mockEvent = new Event('qinit'); + + // Should not throw + expect(() => { + scheduleTask.call('', mockEvent, mockElement); + }).not.toThrow(); + }); + + it('does not throw when _captures[0] is undefined', () => { + vi.mock('../shared/qrl/qrl-class', async () => { + const actual = + await vi.importActual('../shared/qrl/qrl-class'); + return { + ...actual, + _captures: [undefined], + deserializeCaptures: vi.fn(), + setCaptures: vi.fn(), + }; + }); + + vi.mock('../client/dom-container', () => ({ + getDomContainer: vi.fn(() => ({})), + })); + + vi.mock('../shared/vnode/vnode-dirty', () => ({ + markVNodeDirty: vi.fn(), + })); + + const mockElement = {} as Element; + const mockEvent = new Event('qinit'); + + expect(() => { + scheduleTask.call('', mockEvent, mockElement); + }).not.toThrow(); + }); +}); From e12e24cdc4b09963e45f9cee5d0b6860d8f02711 Mon Sep 17 00:00:00 2001 From: Linas Jusys Date: Wed, 15 Apr 2026 17:42:22 +0300 Subject: [PATCH 3/5] fix(test): rewrite scheduleTask tests to avoid hoisted vi.mock trap Move vi.mock calls to module level (where Vitest hoists them) and use Object.defineProperty to set _captures per-test. This avoids the closure-over-test-local-variables issue flagged in review. Also adds a positive test verifying markVNodeDirty IS called when task.$el$ is defined. --- packages/qwik/src/core/use/use-task.unit.ts | 96 ++++++++++++--------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/packages/qwik/src/core/use/use-task.unit.ts b/packages/qwik/src/core/use/use-task.unit.ts index 3e629dd9fdc..92eb2d5c999 100644 --- a/packages/qwik/src/core/use/use-task.unit.ts +++ b/packages/qwik/src/core/use/use-task.unit.ts @@ -1,4 +1,4 @@ -import { describe, expect, expectTypeOf, it, test, vi } from 'vitest'; +import { describe, expect, expectTypeOf, it, test, vi, afterEach } from 'vitest'; import { component$ } from '../shared/component.public'; import type { QRLInternal } from '../shared/qrl/qrl-class'; import type { Container, HostElement } from '../shared/types'; @@ -170,9 +170,32 @@ describe('runTask', () => { }); }); +// Module-level mocks — hoisted before imports by Vitest +vi.mock('../client/dom-container', () => ({ + getDomContainer: vi.fn(() => ({})), +})); + +vi.mock('../shared/vnode/vnode-dirty', () => ({ + markVNodeDirty: vi.fn(), +})); + +vi.mock('../shared/qrl/qrl-class', async () => { + const actual = + await vi.importActual('../shared/qrl/qrl-class'); + return { + ...actual, + _captures: [undefined], + deserializeCaptures: vi.fn(), + setCaptures: vi.fn(), + }; +}); + describe('scheduleTask', () => { - it('does not throw when task.$el$ is undefined', () => { - // Simulate a task with undefined $el$ (e.g., container destroyed during async dispatch) + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not throw when task.$el$ is undefined', async () => { const task = new Task( TaskFlags.TASK, 0, @@ -182,60 +205,51 @@ describe('scheduleTask', () => { null ); - // Mock _captures to return our task - vi.mock('../shared/qrl/qrl-class', async () => { - const actual = - await vi.importActual('../shared/qrl/qrl-class'); - return { - ...actual, - _captures: [task], - deserializeCaptures: vi.fn(), - setCaptures: vi.fn(), - }; - }); + // Set _captures per-test via the mocked module + const qrlMod = await import('../shared/qrl/qrl-class'); + Object.defineProperty(qrlMod, '_captures', { value: [task], writable: true }); - vi.mock('../client/dom-container', () => ({ - getDomContainer: vi.fn(() => ({})), - })); + const mockElement = {} as Element; + const mockEvent = new Event('qinit'); - vi.mock('../shared/vnode/vnode-dirty', () => ({ - markVNodeDirty: vi.fn(), - })); + expect(() => { + scheduleTask.call('', mockEvent, mockElement); + }).not.toThrow(); + }); + + it('does not throw when _captures[0] is undefined', async () => { + const qrlMod = await import('../shared/qrl/qrl-class'); + Object.defineProperty(qrlMod, '_captures', { value: [undefined], writable: true }); const mockElement = {} as Element; const mockEvent = new Event('qinit'); - // Should not throw expect(() => { scheduleTask.call('', mockEvent, mockElement); }).not.toThrow(); }); - it('does not throw when _captures[0] is undefined', () => { - vi.mock('../shared/qrl/qrl-class', async () => { - const actual = - await vi.importActual('../shared/qrl/qrl-class'); - return { - ...actual, - _captures: [undefined], - deserializeCaptures: vi.fn(), - setCaptures: vi.fn(), - }; - }); + it('calls markVNodeDirty when task.$el$ is defined', async () => { + const host = {} as HostElement; + const task = new Task( + TaskFlags.TASK, + 0, + host, + {} as QRLInternal, + undefined, + null + ); - vi.mock('../client/dom-container', () => ({ - getDomContainer: vi.fn(() => ({})), - })); + const qrlMod = await import('../shared/qrl/qrl-class'); + Object.defineProperty(qrlMod, '_captures', { value: [task], writable: true }); - vi.mock('../shared/vnode/vnode-dirty', () => ({ - markVNodeDirty: vi.fn(), - })); + const { markVNodeDirty } = await import('../shared/vnode/vnode-dirty'); const mockElement = {} as Element; const mockEvent = new Event('qinit'); - expect(() => { - scheduleTask.call('', mockEvent, mockElement); - }).not.toThrow(); + scheduleTask.call('', mockEvent, mockElement); + + expect(markVNodeDirty).toHaveBeenCalled(); }); }); From a757a309c118781c79c985ec0f1c5ace1aeda4c6 Mon Sep 17 00:00:00 2001 From: Linas Jusys Date: Wed, 15 Apr 2026 18:07:38 +0300 Subject: [PATCH 4/5] chore: add changeset for markVNodeDirty null guard fix --- .changeset/gentle-tasks-guard.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/gentle-tasks-guard.md diff --git a/.changeset/gentle-tasks-guard.md b/.changeset/gentle-tasks-guard.md new file mode 100644 index 00000000000..36925c78948 --- /dev/null +++ b/.changeset/gentle-tasks-guard.md @@ -0,0 +1,7 @@ +--- +'@qwik.dev/core': patch +--- + +fix: guard undefined vNode in scheduleTask and scheduleEffects + +Prevent `TypeError: Cannot read properties of undefined (reading 'dirty')` crash in `markVNodeDirty` when `task.$el$` or `consumer.$el$` is undefined during async event dispatch. From efa3221ee34cf854d947f4522d7171ca2d7df66d Mon Sep 17 00:00:00 2001 From: Linas Jusys Date: Thu, 16 Apr 2026 15:21:05 +0300 Subject: [PATCH 5/5] test: rewrite scheduleTask tests to show real container-destroyed-during-async-dispatch scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback from @Varixo — tests now simulate the actual DomContainer.$destroy$() flow where $getObjectById$ returns undefined after container destruction during async qwikloader dispatch, rather than artificially constructing Tasks with undefined host elements. The tests mock getDomContainer (not qrl-class internals) and exercise the real deserializeCaptures → $getObjectById$ → undefined path that causes the crash in production. --- packages/qwik/src/core/use/use-task.unit.ts | 110 +++++++++++++------- 1 file changed, 75 insertions(+), 35 deletions(-) diff --git a/packages/qwik/src/core/use/use-task.unit.ts b/packages/qwik/src/core/use/use-task.unit.ts index 92eb2d5c999..127639061d4 100644 --- a/packages/qwik/src/core/use/use-task.unit.ts +++ b/packages/qwik/src/core/use/use-task.unit.ts @@ -170,66 +170,101 @@ describe('runTask', () => { }); }); -// Module-level mocks — hoisted before imports by Vitest +/** + * scheduleTask tests — simulate the real scenario where a container is destroyed + * during async qwikloader dispatch. + * + * Real-world flow: + * 1. qwikloader dispatches an event asynchronously (dispatch is now async) + * 2. During the await, a navigation/SPA transition destroys the container + * via DomContainer.$destroy$(), which: + * - truncates $rawStateData$ and $stateData$ to length 0 + * - replaces $getObjectById$ with () => undefined + * 3. The queued scheduleTask handler fires AFTER destruction + * 4. deserializeCaptures() calls container.$getObjectById$(id) → returns undefined + * 5. _captures[0] is undefined → crash on `task.$flags$ |= TaskFlags.DIRTY` + * + * The guard `if (!task?.$el$)` prevents this crash, matching the existing pattern + * in WrappedSignalImpl.invalidate() which checks `if (this.$container$ && this.$hostElement$)`. + */ + +// Mock getDomContainer to return our controlled container vi.mock('../client/dom-container', () => ({ - getDomContainer: vi.fn(() => ({})), + getDomContainer: vi.fn(), })); vi.mock('../shared/vnode/vnode-dirty', () => ({ markVNodeDirty: vi.fn(), })); -vi.mock('../shared/qrl/qrl-class', async () => { - const actual = - await vi.importActual('../shared/qrl/qrl-class'); - return { - ...actual, - _captures: [undefined], - deserializeCaptures: vi.fn(), - setCaptures: vi.fn(), - }; -}); - describe('scheduleTask', () => { afterEach(() => { vi.restoreAllMocks(); }); - it('does not throw when task.$el$ is undefined', async () => { - const task = new Task( - TaskFlags.TASK, - 0, - undefined as unknown as HostElement, - {} as QRLInternal, - undefined, - null - ); + it('does not throw when container was destroyed during async dispatch (_captures[0] is undefined)', async () => { + // Simulate DomContainer.$destroy$() — $getObjectById$ returns undefined for all IDs, + // exactly as the real $destroy$() method does: + // this.$getObjectById$ = () => undefined; + // this.$rawStateData$.length = 0; + // this.$stateData$.length = 0; + const destroyedContainer = { + $getObjectById$: () => undefined, + }; - // Set _captures per-test via the mocked module - const qrlMod = await import('../shared/qrl/qrl-class'); - Object.defineProperty(qrlMod, '_captures', { value: [task], writable: true }); + const { getDomContainer } = await import('../client/dom-container'); + vi.mocked(getDomContainer).mockReturnValue(destroyedContainer as any); const mockElement = {} as Element; const mockEvent = new Event('qinit'); + // scheduleTask is called by qwikloader with `this` = serialized captures string (e.g. "42"). + // Inside, it calls deserializeCaptures(container, "42") which does: + // container.$getObjectById$("42") → undefined (container destroyed) + // So _captures becomes [undefined] and _captures[0] is undefined. + // Without the guard, this crashes: + // TypeError: Cannot read properties of undefined (reading '$flags$') expect(() => { - scheduleTask.call('', mockEvent, mockElement); + scheduleTask.call('42', mockEvent, mockElement); }).not.toThrow(); }); - it('does not throw when _captures[0] is undefined', async () => { - const qrlMod = await import('../shared/qrl/qrl-class'); - Object.defineProperty(qrlMod, '_captures', { value: [undefined], writable: true }); + it('does not throw when task.$el$ is undefined due to truncated $stateData$', async () => { + // Simulate a partially-destroyed container where the Task object itself was deserialized + // but its $el$ (host VNode) resolved to undefined. + // + // This happens during inflate.ts Task deserialization: + // task.$el$ = v[3] as HostElement; + // where v[3] comes from $stateData$[someId]. After $destroy$() truncates $stateData$ + // to length 0, any pending lazy deserialization of the VNode reference yields undefined. + const task = new Task( + TaskFlags.TASK, + 0, + undefined as unknown as HostElement, // $el$ is undefined — VNode ref was cleared + {} as QRLInternal, + undefined, + null + ); + + const partialContainer = { + $getObjectById$: () => task, + }; + + const { getDomContainer } = await import('../client/dom-container'); + vi.mocked(getDomContainer).mockReturnValue(partialContainer as any); const mockElement = {} as Element; const mockEvent = new Event('qinit'); + // The Task was deserialized but task.$el$ is undefined. + // Without the guard, markVNodeDirty receives undefined vNode → crash: + // TypeError: Cannot read properties of undefined (reading 'dirty') expect(() => { - scheduleTask.call('', mockEvent, mockElement); + scheduleTask.call('42', mockEvent, mockElement); }).not.toThrow(); }); - it('calls markVNodeDirty when task.$el$ is defined', async () => { + it('calls markVNodeDirty when container is alive and task.$el$ is defined', async () => { const host = {} as HostElement; const task = new Task( TaskFlags.TASK, @@ -240,16 +275,21 @@ describe('scheduleTask', () => { null ); - const qrlMod = await import('../shared/qrl/qrl-class'); - Object.defineProperty(qrlMod, '_captures', { value: [task], writable: true }); + const liveContainer = { + $getObjectById$: () => task, + }; + + const { getDomContainer } = await import('../client/dom-container'); + vi.mocked(getDomContainer).mockReturnValue(liveContainer as any); const { markVNodeDirty } = await import('../shared/vnode/vnode-dirty'); const mockElement = {} as Element; const mockEvent = new Event('qinit'); - scheduleTask.call('', mockEvent, mockElement); + scheduleTask.call('42', mockEvent, mockElement); - expect(markVNodeDirty).toHaveBeenCalled(); + expect(markVNodeDirty).toHaveBeenCalledWith(liveContainer, host, expect.any(Number)); + expect(task.$flags$ & TaskFlags.DIRTY).toBeTruthy(); }); });