Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/gentle-tasks-guard.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/qwik/src/core/reactive-primitives/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions packages/qwik/src/core/use/use-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
128 changes: 126 additions & 2 deletions packages/qwik/src/core/use/use-task.unit.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
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';
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';

Expand Down Expand Up @@ -169,3 +169,127 @@ describe('runTask', () => {
expect(cleanupCalls).toBe(1);
});
});

/**
* 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(),
}));

vi.mock('../shared/vnode/vnode-dirty', () => ({
markVNodeDirty: vi.fn(),
}));

describe('scheduleTask', () => {
afterEach(() => {
vi.restoreAllMocks();
});

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,
};

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('42', mockEvent, mockElement);
}).not.toThrow();
});

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<unknown>,
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('42', mockEvent, mockElement);
}).not.toThrow();
});

it('calls markVNodeDirty when container is alive and task.$el$ is defined', async () => {
const host = {} as HostElement;
const task = new Task(
TaskFlags.TASK,
0,
host,
{} as QRLInternal<unknown>,
undefined,
null
);

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('42', mockEvent, mockElement);

expect(markVNodeDirty).toHaveBeenCalledWith(liveContainer, host, expect.any(Number));
expect(task.$flags$ & TaskFlags.DIRTY).toBeTruthy();
});
});