From 5a132252522a8642c1fa4492b8d140b424778847 Mon Sep 17 00:00:00 2001 From: Varixo Date: Mon, 30 Jun 2025 22:59:22 +0200 Subject: [PATCH 01/35] WIP: scheduler rewrite Co-Authored-By: Wout.Mertens@gmail.com --- .../qwik/src/core/client/dom-container.ts | 44 +- packages/qwik/src/core/client/dom-render.ts | 4 +- packages/qwik/src/core/client/queue-qrl.ts | 5 +- packages/qwik/src/core/client/vnode-diff.ts | 6 +- packages/qwik/src/core/internal.ts | 2 +- packages/qwik/src/core/qwik.core.api.md | 9 +- .../impl/async-computed-signal-impl.ts | 4 +- .../impl/computed-signal-impl.ts | 4 +- .../reactive-primitives/impl/signal-impl.ts | 16 +- .../reactive-primitives/impl/signal.unit.tsx | 13 +- .../core/reactive-primitives/impl/store.ts | 15 +- .../reactive-primitives/impl/store.unit.tsx | 6 +- .../impl/wrapped-signal-impl.ts | 6 +- .../src/core/reactive-primitives/utils.ts | 13 +- packages/qwik/src/core/shared/scheduler.ts | 769 ++++++++++-------- .../qwik/src/core/shared/scheduler.unit.tsx | 169 +++- .../qwik/src/core/shared/shared-container.ts | 14 +- .../src/core/shared/shared-serialization.ts | 2 +- packages/qwik/src/core/shared/types.ts | 2 +- .../qwik/src/core/shared/util-chore-type.ts | 8 +- packages/qwik/src/core/shared/utils/types.ts | 4 + .../qwik/src/core/ssr/ssr-render-component.ts | 12 +- packages/qwik/src/core/ssr/ssr-render-jsx.ts | 4 +- .../qwik/src/core/tests/component.spec.tsx | 14 +- packages/qwik/src/core/tests/ref.spec.tsx | 3 +- .../src/core/tests/render-promise.spec.tsx | 2 +- .../qwik/src/core/tests/use-resource.spec.tsx | 1 - .../qwik/src/core/tests/use-task.spec.tsx | 78 +- .../src/core/tests/use-visible-task.spec.tsx | 2 +- packages/qwik/src/core/use/use-resource.ts | 5 +- packages/qwik/src/core/use/use-task.ts | 12 +- .../qwik/src/core/use/use-visible-task.ts | 2 +- packages/qwik/src/server/ssr-container.ts | 16 +- packages/qwik/src/server/ssr-render.ts | 8 +- packages/qwik/src/testing/element-fixture.ts | 11 +- packages/qwik/src/testing/platform.ts | 27 +- .../qwik/src/testing/rendering.unit-util.tsx | 2 +- 37 files changed, 803 insertions(+), 511 deletions(-) diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index e30d1708fbd..2849f558185 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -3,7 +3,6 @@ import { assertTrue } from '../shared/error/assert'; import { QError, qError } from '../shared/error/error'; import { ERROR_CONTEXT, isRecoverable } from '../shared/error/error-handling'; -import { getPlatform } from '../shared/platform/platform'; import { emitEvent, type QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; import { ChoreType } from '../shared/util-chore-type'; @@ -38,7 +37,6 @@ import { QLocaleAttr, QManifestHashAttr, } from '../shared/utils/markers'; -import { isPromise } from '../shared/utils/promises'; import { isSlotProp } from '../shared/utils/prop'; import { qDev } from '../shared/utils/qdev'; import { @@ -127,12 +125,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { private $renderCount$ = 0; constructor(element: ContainerElement) { - super( - () => this.scheduleRender(), - () => vnode_applyJournal(this.$journal$), - {}, - element.getAttribute(QLocaleAttr)! - ); + super(() => vnode_applyJournal(this.$journal$), {}, element.getAttribute(QLocaleAttr)!); this.qContainer = element.getAttribute(QContainerAttr)!; if (!this.qContainer) { throw qError(QError.elementWithoutContainer); @@ -181,7 +174,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { return inflateQRL(this, parseQRL(qrl)) as QRL; } - handleError(err: any, host: HostElement): void { + handleError(err: any, host: HostElement | null): void { if (qDev && host) { // Clean vdom if (typeof document !== 'undefined') { @@ -279,31 +272,20 @@ export class DomContainer extends _SharedContainer implements IClientContainer { return vnode_getProp(vNode, name, getObjectById); } + // TODO: call this in _wait scheduleRender() { - this.$renderCount$++; - this.renderDone ||= getPlatform().nextTick(() => this.processChores()); - return this.renderDone.finally(() => - emitEvent('qrender', { instanceHash: this.$instanceHash$, renderCount: this.$renderCount$ }) - ); - } - - private processChores() { - let renderCount = this.$renderCount$; - const result = this.$scheduler$(ChoreType.WAIT_FOR_ALL); - if (isPromise(result)) { - return result.then(async () => { - while (renderCount !== this.$renderCount$) { - renderCount = this.$renderCount$; - await this.$scheduler$(ChoreType.WAIT_FOR_ALL); - } + if (!this.renderDone) { + const chore = this.$scheduler$.schedule(ChoreType.WAIT_FOR_QUEUE); + this.renderDone = chore.$returnValue$!.finally(() => { + this.$renderCount$++; this.renderDone = null; + emitEvent('qrender', { + instanceHash: this.$instanceHash$, + renderCount: this.$renderCount$, + }); }); } - if (renderCount !== this.$renderCount$) { - this.processChores(); - return; - } - this.renderDone = null; + return this.renderDone; } ensureProjectionResolved(vNode: VirtualVNode): void { @@ -392,7 +374,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { private $scheduleInitialQRLs$(): void { if (this.$initialQRLsIndexes$) { for (const index of this.$initialQRLsIndexes$) { - this.$scheduler$( + this.$scheduler$.schedule( ChoreType.QRL_RESOLVE, null, this.$getObjectById$(index) as QRLInternal<(...args: unknown[]) => unknown> diff --git a/packages/qwik/src/core/client/dom-render.ts b/packages/qwik/src/core/client/dom-render.ts index 890589931c8..8aa632c592d 100644 --- a/packages/qwik/src/core/client/dom-render.ts +++ b/packages/qwik/src/core/client/dom-render.ts @@ -42,8 +42,8 @@ export const render = async ( const container = getDomContainer(parent as HTMLElement) as DomContainer; container.$serverData$ = opts.serverData || {}; const host = container.rootVNode; - container.$scheduler$(ChoreType.NODE_DIFF, host, host, jsxNode as JSXNode); - await container.$scheduler$(ChoreType.WAIT_FOR_ALL); + container.$scheduler$.schedule(ChoreType.NODE_DIFF, host, host, jsxNode as JSXNode); + await container.$scheduler$.schedule(ChoreType.WAIT_FOR_QUEUE).$returnValue$; return { cleanup: () => { cleanup(container, container.rootVNode); diff --git a/packages/qwik/src/core/client/queue-qrl.ts b/packages/qwik/src/core/client/queue-qrl.ts index fd62eb96a3a..c86073c5988 100644 --- a/packages/qwik/src/core/client/queue-qrl.ts +++ b/packages/qwik/src/core/client/queue-qrl.ts @@ -10,7 +10,7 @@ import { getDomContainer } from './dom-container'; * * @internal */ -export const queueQRL = (...args: unknown[]) => { +export const _run = (...args: unknown[]): void => { // This will already check container const [runQrl] = useLexicalScope<[QRLInternal<(...args: unknown[]) => unknown>]>(); const context = getInvokeContext(); @@ -28,5 +28,6 @@ export const queueQRL = (...args: unknown[]) => { throw qError(QError.schedulerNotFound); } - return scheduler(ChoreType.RUN_QRL, hostElement, runQrl, args); + // We don't return anything, the scheduler is in charge now + scheduler.schedule(ChoreType.RUN_QRL, hostElement, runQrl, args); }; diff --git a/packages/qwik/src/core/client/vnode-diff.ts b/packages/qwik/src/core/client/vnode-diff.ts index a00c2992365..b43af0ff745 100644 --- a/packages/qwik/src/core/client/vnode-diff.ts +++ b/packages/qwik/src/core/client/vnode-diff.ts @@ -774,7 +774,7 @@ export const vnode_diff = ( let returnValue = false; qrls.flat(2).forEach((qrl) => { if (qrl) { - const value = container.$scheduler$( + const value = container.$scheduler$.schedule( ChoreType.RUN_QRL, vNode, qrl as QRLInternal<(...args: unknown[]) => unknown>, @@ -1104,7 +1104,7 @@ export const vnode_diff = ( * deleted. */ host[VNodeProps.flags] &= ~VNodeFlags.Deleted; - container.$scheduler$(ChoreType.COMPONENT, host, componentQRL, vNodeProps); + container.$scheduler$.schedule(ChoreType.COMPONENT, host, componentQRL, vNodeProps); } } descendContentToProject(jsxNode.children, host); @@ -1347,7 +1347,7 @@ export function cleanup(container: ClientContainer, vNode: VNode) { const task = obj; clearAllEffects(container, task); if (task.$flags$ & TaskFlags.VISIBLE_TASK) { - container.$scheduler$(ChoreType.CLEANUP_VISIBLE, task); + container.$scheduler$.schedule(ChoreType.CLEANUP_VISIBLE, task); } else { cleanupTask(task); } diff --git a/packages/qwik/src/core/internal.ts b/packages/qwik/src/core/internal.ts index 97d61dc1636..a0f6490f4ff 100644 --- a/packages/qwik/src/core/internal.ts +++ b/packages/qwik/src/core/internal.ts @@ -5,7 +5,7 @@ export { DomContainer as _DomContainer, getDomContainer as _getDomContainer, } from './client/dom-container'; -export { queueQRL as _run } from './client/queue-qrl'; +export { _run } from './client/queue-qrl'; export type { ContainerElement as _ContainerElement, ElementVNode as _ElementVNode, diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index d59671e6980..1a71f820948 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -11,7 +11,6 @@ import { isDev } from '@qwik.dev/core/build'; import { isServer } from '@qwik.dev/core/build'; import { QRL as QRL_2 } from './qrl.public'; import type { StreamWriter as StreamWriter_2 } from '@qwik.dev/core'; -import { ValueOrPromise as ValueOrPromise_2 } from '..'; // @public export const $: (expression: T) => QRL; @@ -249,7 +248,7 @@ class DomContainer extends _SharedContainer implements ClientContainer { // Warning: (ae-forgotten-export) The symbol "HostElement" needs to be exported by the entry point index.d.ts // // (undocumented) - handleError(err: any, host: HostElement): void; + handleError(err: any, host: HostElement | null): void; // (undocumented) parseQRL(qrl: string): QRL; // (undocumented) @@ -903,7 +902,7 @@ export type ResourceReturn = ResourcePending | ResourceResolved | Resou export const _restProps: (props: PropsProxy, omit: string[], target?: Props) => Props; // @internal -export const _run: (...args: unknown[]) => ValueOrPromise_2; +export const _run: (...args: unknown[]) => void; // @public (undocumented) export type SerializationStrategy = 'never' | 'always'; @@ -946,7 +945,7 @@ export abstract class _SharedContainer implements Container { readonly $storeProxyMap$: ObjToProxyMap; // (undocumented) readonly $version$: string; - constructor(scheduleDrain: () => void, journalFlush: () => void, serverData: Record, locale: string); + constructor(journalFlush: () => void, serverData: Record, locale: string); // (undocumented) abstract ensureProjectionResolved(host: HostElement): void; // (undocumented) @@ -954,7 +953,7 @@ export abstract class _SharedContainer implements Container { // (undocumented) abstract getParentHost(host: HostElement): HostElement | null; // (undocumented) - abstract handleError(err: any, $host$: HostElement): void; + abstract handleError(err: any, $host$: HostElement | null): void; // (undocumented) abstract resolveContext(host: HostElement, contextId: ContextId): T | undefined; // Warning: (ae-forgotten-export) The symbol "SymbolToChunkResolver" needs to be exported by the entry point index.d.ts diff --git a/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts index 2e9d53023e0..22f02154af4 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts @@ -60,7 +60,7 @@ export class AsyncComputedSignalImpl set untrackedLoading(value: boolean) { if (value !== this.$untrackedLoading$) { this.$untrackedLoading$ = value; - this.$container$?.$scheduler$( + this.$container$?.$scheduler$.schedule( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, this, @@ -85,7 +85,7 @@ export class AsyncComputedSignalImpl set untrackedError(value: Error | null) { if (value !== this.$untrackedError$) { this.$untrackedError$ = value; - this.$container$?.$scheduler$( + this.$container$?.$scheduler$.schedule( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, this, diff --git a/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts index ddd68e42b66..4535fe4aff5 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts @@ -55,7 +55,7 @@ export class ComputedSignalImpl> invalidate() { this.$flags$ |= SignalFlags.INVALID; this.$forceRunEffects$ = false; - this.$container$?.$scheduler$( + this.$container$?.$scheduler$.schedule( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, this, @@ -69,7 +69,7 @@ export class ComputedSignalImpl> */ force() { this.$forceRunEffects$ = true; - this.$container$?.$scheduler$( + this.$container$?.$scheduler$.schedule( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, this, diff --git a/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts index fdbff77772b..19a11b5c9af 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts @@ -9,10 +9,10 @@ import { addQrlToSerializationCtx, ensureContainsBackRef, ensureContainsSubscription, - triggerEffects, } from '../utils'; import type { Signal } from '../signal.public'; import { SignalFlags, type EffectSubscription } from '../types'; +import { ChoreType } from '../../shared/util-chore-type'; const DEBUG = false; // eslint-disable-next-line no-console @@ -54,14 +54,12 @@ export class SignalImpl implements Signal { DEBUG && log('Signal.set', this.$untrackedValue$, '->', value, pad('\n' + this.toString(), ' ')); this.$untrackedValue$ = value; - // TODO: move this to the scheduler - triggerEffects(this.$container$, this, this.$effects$); - // this.$container$?.$scheduler$( - // ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, - // null, - // this, - // this.$effects$ - // ); + this.$container$?.$scheduler$.schedule( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, + this, + this.$effects$ + ); } } diff --git a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx index 8f1e9f4cedf..7ca7412f6e7 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx +++ b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx @@ -118,7 +118,7 @@ describe('signal', () => { afterEach(async () => { delayMap.clear(); - await container.$scheduler$(ChoreType.WAIT_FOR_ALL); + await container.$scheduler$.schedule(ChoreType.WAIT_FOR_QUEUE).$returnValue$; await getTestPlatform().flush(); container = null!; }); @@ -213,8 +213,8 @@ describe('signal', () => { }); }); - it('force', () => - withContainer(async () => { + it('force', async () => { + await withContainer(async () => { const obj = { count: 0 }; const computed = await retryOnPromise(() => { return createComputedQrl( @@ -242,7 +242,8 @@ describe('signal', () => { computed.force(); await flushSignals(); expect(log).toEqual([1, 2]); - })); + }); + }); }); //////////////////////////////////////// @@ -252,8 +253,8 @@ describe('signal', () => { return invoke(ctx, fn); } - function flushSignals() { - return container.$scheduler$(ChoreType.WAIT_FOR_ALL); + async function flushSignals() { + await container.$scheduler$.schedule(ChoreType.WAIT_FOR_QUEUE).$returnValue$; } /** Simulates the QRLs being lazy loaded once per test. */ diff --git a/packages/qwik/src/core/reactive-primitives/impl/store.ts b/packages/qwik/src/core/reactive-primitives/impl/store.ts index a8eaf98eaf9..0f2c110b75a 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/store.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/store.ts @@ -7,7 +7,6 @@ import { addQrlToSerializationCtx, ensureContainsBackRef, ensureContainsSubscription, - triggerEffects, } from '../utils'; import { STORE_ALL_PROPS, @@ -17,6 +16,7 @@ import { type EffectSubscription, type StoreTarget, } from '../types'; +import { ChoreType } from '../../shared/util-chore-type'; const DEBUG = false; @@ -160,7 +160,12 @@ export class StoreHandler implements ProxyHandler { if (typeof prop != 'string' || !delete target[prop]) { return false; } - triggerEffects(this.$container$, this, getEffects(target, prop, this.$effects$)); + this.$container$?.$scheduler$.schedule( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, + this, + getEffects(target, prop, this.$effects$) + ); return true; } @@ -244,9 +249,9 @@ function setNewValueAndTriggerEffects>( currentStore: StoreHandler ): void { (target as any)[prop] = value; - // TODO: trigger effects through the scheduler - triggerEffects( - currentStore.$container$, + currentStore.$container$?.$scheduler$?.schedule( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, currentStore, getEffects(target, prop, currentStore.$effects$) ); diff --git a/packages/qwik/src/core/reactive-primitives/impl/store.unit.tsx b/packages/qwik/src/core/reactive-primitives/impl/store.unit.tsx index 2fea57969cd..4bcd78fd127 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/store.unit.tsx +++ b/packages/qwik/src/core/reactive-primitives/impl/store.unit.tsx @@ -21,7 +21,7 @@ describe('v2/store', () => { }); afterEach(async () => { - await container.$scheduler$(ChoreType.WAIT_FOR_ALL); + await container.$scheduler$.schedule(ChoreType.WAIT_FOR_QUEUE).$returnValue$; await getTestPlatform().flush(); container = null!; }); @@ -60,8 +60,8 @@ describe('v2/store', () => { return invoke(ctx, fn); } - function flushSignals() { - return container.$scheduler$(ChoreType.WAIT_FOR_ALL); + async function flushSignals() { + await container.$scheduler$.schedule(ChoreType.WAIT_FOR_QUEUE).$returnValue$; } function effectQrl(fnQrl: QRL<() => void>) { diff --git a/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts index 0b2ed85c181..f857abd2844 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts @@ -43,7 +43,7 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { invalidate() { this.$flags$ |= SignalFlags.INVALID; this.$forceRunEffects$ = false; - this.$container$?.$scheduler$( + this.$container$?.$scheduler$.schedule( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, this.$hostElement$, this, @@ -56,8 +56,8 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { * remained the same object. */ force() { - this.$forceRunEffects$ = true; - this.$container$?.$scheduler$( + this.$forceRunEffects$ = false; + this.$container$?.$scheduler$.schedule( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, this.$hostElement$, this, diff --git a/packages/qwik/src/core/reactive-primitives/utils.ts b/packages/qwik/src/core/reactive-primitives/utils.ts index e0fc2351b2a..9cbcfc0856f 100644 --- a/packages/qwik/src/core/reactive-primitives/utils.ts +++ b/packages/qwik/src/core/reactive-primitives/utils.ts @@ -3,6 +3,7 @@ import { pad, qwikDebugToString } from '../debug'; import type { OnRenderFn } from '../shared/component.public'; import { assertDefined } from '../shared/error/assert'; import type { Props } from '../shared/jsx/jsx-runtime'; +import { isServerPlatform } from '../shared/platform/platform'; import { type QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; import type { Container, HostElement, SerializationStrategy } from '../shared/types'; @@ -88,7 +89,7 @@ export const triggerEffects = ( signal: SignalImpl | StoreTarget, effects: Set | null ) => { - const isBrowser = isDomContainer(container); + const isBrowser = !isServerPlatform(); if (effects) { const scheduleEffect = (effectSubscription: EffectSubscription) => { const consumer = effectSubscription[EffectSubscriptionProp.CONSUMER]; @@ -101,7 +102,7 @@ export const triggerEffects = ( if (consumer.$flags$ & TaskFlags.VISIBLE_TASK) { choreType = ChoreType.VISIBLE; } - container.$scheduler$(choreType, consumer); + container.$scheduler$.schedule(choreType, consumer); } else if (consumer instanceof SignalImpl) { // we don't schedule ComputedSignal/DerivedSignal directly, instead we invalidate it and // and schedule the signals effects (recursively) @@ -109,7 +110,7 @@ export const triggerEffects = ( // Ensure that the computed signal's QRL is resolved. // If not resolved schedule it to be resolved. if (!consumer.$computeQrl$.resolved) { - container.$scheduler$(ChoreType.QRL_RESOLVE, null, consumer.$computeQrl$); + container.$scheduler$.schedule(ChoreType.QRL_RESOLVE, null, consumer.$computeQrl$); } } @@ -119,11 +120,11 @@ export const triggerEffects = ( const qrl = container.getHostProp>>(host, OnRenderProp); assertDefined(qrl, 'Component must have QRL'); const props = container.getHostProp(host, ELEMENT_PROPS); - container.$scheduler$(ChoreType.COMPONENT, host, qrl, props); + container.$scheduler$.schedule(ChoreType.COMPONENT, host, qrl, props); } else if (isBrowser) { if (property === EffectProperty.VNODE) { const host: HostElement = consumer; - container.$scheduler$(ChoreType.NODE_DIFF, host, host, signal as SignalImpl); + container.$scheduler$.schedule(ChoreType.NODE_DIFF, host, host, signal as SignalImpl); } else { const host: HostElement = consumer; const effectData = effectSubscription[EffectSubscriptionProp.DATA]; @@ -133,7 +134,7 @@ export const triggerEffects = ( ...data, $value$: signal as SignalImpl, }; - container.$scheduler$(ChoreType.NODE_PROP, host, property, payload); + container.$scheduler$.schedule(ChoreType.NODE_PROP, host, property, payload); } } } diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index 6e4fbf28b18..259a53a5920 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -78,9 +78,17 @@ * within component. * - Visible Tasks are sorted afterJournalFlush, than depth first on component and finally in * declaration order within component. + * + * Blocking chores: + * + * - RUN_QRL -> TASK + * - TASK -> subsequent TASK + * - COMPONENT -> NODE_DIFF + * - COMPONENT -> TRIGGER_EFFECTS + * - QRL_RESOLVE -> RUN_QRL, COMPONENT */ -import { isDomContainer, type DomContainer } from '../client/dom-container'; +import { type DomContainer } from '../client/dom-container'; import { ElementVNodeProps, VNodeFlags, @@ -91,14 +99,20 @@ import { } from '../client/types'; import { VNodeJournalOpCode, vnode_isVNode, vnode_setAttr } from '../client/vnode'; import { vnode_diff } from '../client/vnode-diff'; -import { triggerEffects } from '../reactive-primitives/utils'; +import { AsyncComputedSignalImpl } from '../reactive-primitives/impl/async-computed-signal-impl'; +import { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; +import { SignalImpl } from '../reactive-primitives/impl/signal-impl'; +import type { StoreHandler } from '../reactive-primitives/impl/store'; +import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; import { isSignal, type Signal } from '../reactive-primitives/signal.public'; +import type { NodePropPayload } from '../reactive-primitives/subscription-data'; import { type AsyncComputeQRL, type ComputeQRL, type EffectSubscription, type StoreTarget, } from '../reactive-primitives/types'; +import { triggerEffects } from '../reactive-primitives/utils'; import type { ISsrNode } from '../ssr/ssr-types'; import { runResource, type ResourceDescriptor } from '../use/use-resource'; import { @@ -109,42 +123,57 @@ import { type DescriptorBase, type TaskFn, } from '../use/use-task'; -import { ChoreType } from './util-chore-type'; import { executeComponent } from './component-execution'; import type { OnRenderFn } from './component.public'; -import { assertEqual, assertFalse } from './error/assert'; +import { assertFalse } from './error/assert'; import type { Props } from './jsx/jsx-runtime'; import type { JSXOutput } from './jsx/types/jsx-node'; +import { getPlatform, isServerPlatform } from './platform/platform'; import { type QRLInternal } from './qrl/qrl-class'; +import { isQrl } from './qrl/qrl-utils'; import { ssrNodeDocumentPosition, vnode_documentPosition } from './scheduler-document-position'; import type { Container, HostElement } from './types'; +import { ChoreType } from './util-chore-type'; import { logWarn } from './utils/log'; -import { QScopedStyle } from './utils/markers'; +import { ELEMENT_SEQ, QScopedStyle } from './utils/markers'; import { isPromise, maybeThen, retryOnPromise, safeCall } from './utils/promises'; import { addComponentStylePrefix } from './utils/scoped-styles'; import { serializeAttribute } from './utils/styles'; -import type { ValueOrPromise } from './utils/types'; -import type { NodePropPayload } from '../reactive-primitives/subscription-data'; -import { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; -import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; -import type { StoreHandler } from '../reactive-primitives/impl/store'; -import { SignalImpl } from '../reactive-primitives/impl/signal-impl'; -import { isQrl } from './qrl/qrl-utils'; +import { isNumber, type ValueOrPromise } from './utils/types'; import { invoke, newInvokeContext } from '../use/use-core'; // Turn this on to get debug output of what the scheduler is doing. const DEBUG: boolean = false; -export interface Chore { - $type$: ChoreType; +enum ChoreState { + NONE = 0, + RUNNING = 1, + FAILED = 2, + DONE = 3, + BLOCKED = 4, +} + +type ChoreReturnValue = T extends + | ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS + | ChoreType.WAIT_FOR_QUEUE + | ChoreType.NODE_PROP + ? void + : T extends ChoreType.NODE_DIFF | ChoreType.COMPONENT + ? JSXOutput + : unknown; + +export interface Chore { + $type$: T; $idx$: number | string; $host$: HostElement; $target$: ChoreTarget | null; $payload$: unknown; + $state$: ChoreState; + $blockedChores$: Chore[] | null; + $resolve$?: (value: any) => void; - $promise$?: Promise; - $returnValue$: any; - $executed$: boolean; + $reject$?: (reason?: any) => void; + $returnValue$: ValueOrPromise>; } export type Scheduler = ReturnType; @@ -155,23 +184,33 @@ type ChoreTarget = | Signal | StoreTarget; -const getPromise = (chore: Chore) => - (chore.$promise$ ||= new Promise((resolve) => { - chore.$resolve$ = resolve; - })); +export const getChorePromise = (chore: Chore) => + chore.$state$ === ChoreState.NONE + ? (chore.$returnValue$ ||= new Promise((resolve, reject) => { + chore.$resolve$ = resolve; + chore.$reject$ = reject; + })) + : chore.$returnValue$; -export const createScheduler = ( - container: Container, - scheduleDrain: () => void, - journalFlush: () => void -) => { +export const createScheduler = (container: Container, journalFlush: () => void) => { const choreQueue: Chore[] = []; - const qrlRuns: Promise[] = []; - let currentChore: Chore | null = null; - let drainScheduled: boolean = false; + let drainChore: Chore | null = null; + let drainScheduled = false; + let runningChoresCount = 0; + let isDraining = false; + + function drainInNextTick() { + if (!drainScheduled) { + drainScheduled = true; + getPlatform().nextTick(() => drainChoreQueue()); + } + } + // Drain for ~16.67ms, then apply journal flush for ~16.67ms, then repeat + // We divide by 60 because we want to run at 60fps + const FREQUENCY_MS = Math.floor(1000 / 60); - return schedule; + return { schedule, drainChoreQueue }; //////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// @@ -180,9 +219,8 @@ export const createScheduler = ( type: ChoreType.QRL_RESOLVE, ignore: null, target: ComputeQRL | AsyncComputeQRL - ): ValueOrPromise; - function schedule(type: ChoreType.JOURNAL_FLUSH): ValueOrPromise; - function schedule(type: ChoreType.WAIT_FOR_ALL): ValueOrPromise; + ): Chore; + function schedule(type: ChoreType.WAIT_FOR_QUEUE): Chore; /** * Schedule rendering of a component. * @@ -195,68 +233,54 @@ export const createScheduler = ( host: HostElement | null, target: Signal | StoreHandler, effects: Set | null - ): ValueOrPromise; - function schedule(type: ChoreType.TASK | ChoreType.VISIBLE, task: Task): ValueOrPromise; + ): Chore; + function schedule( + type: ChoreType.TASK | ChoreType.VISIBLE, + task: Task + ): Chore; function schedule( type: ChoreType.RUN_QRL, host: HostElement, target: QRLInternal<(...args: unknown[]) => unknown>, args: unknown[] - ): ValueOrPromise; + ): Chore; function schedule( type: ChoreType.COMPONENT, host: HostElement, qrl: QRLInternal>, props: Props | null - ): ValueOrPromise; + ): Chore; function schedule( type: ChoreType.NODE_DIFF, host: HostElement, target: HostElement, value: JSXOutput | Signal - ): ValueOrPromise; + ): Chore; function schedule( type: ChoreType.NODE_PROP, host: HostElement, prop: string, value: any - ): ValueOrPromise; - function schedule(type: ChoreType.CLEANUP_VISIBLE, task: Task): ValueOrPromise; + ): Chore; + function schedule(type: ChoreType.CLEANUP_VISIBLE, task: Task): Chore; ///// IMPLEMENTATION ///// - function schedule( - type: ChoreType, + function schedule( + type: T, hostOrTask: HostElement | Task | null = null, targetOrQrl: ChoreTarget | string | null = null, payload: any = null - ): ValueOrPromise { - const isServer = !isDomContainer(container); - const isComponentSsr = isServer && type === ChoreType.COMPONENT; + ): Chore | null { + if (type === ChoreType.WAIT_FOR_QUEUE && drainChore) { + return drainChore as Chore; + } - const runLater: boolean = - type !== ChoreType.WAIT_FOR_ALL && !isComponentSsr && type !== ChoreType.RUN_QRL; const isTask = type === ChoreType.TASK || type === ChoreType.VISIBLE || type === ChoreType.CLEANUP_VISIBLE; - const isClientOnly = - type === ChoreType.JOURNAL_FLUSH || - type === ChoreType.NODE_DIFF || - type === ChoreType.NODE_PROP || - type === ChoreType.QRL_RESOLVE || - type === ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS; - if (isServer && isClientOnly) { - DEBUG && - debugTrace( - `skip client chore ${debugChoreTypeToString(type)}`, - null, - currentChore, - choreQueue - ); - return; - } if (isTask) { (hostOrTask as Task).$flags$ |= TaskFlags.DIRTY; } - let chore: Chore = { + let chore: Chore = { $type$: type, $idx$: isTask ? (hostOrTask as Task).$index$ @@ -266,275 +290,395 @@ export const createScheduler = ( $host$: isTask ? (hostOrTask as Task).$el$ : (hostOrTask as HostElement), $target$: targetOrQrl as ChoreTarget | null, $payload$: isTask ? hostOrTask : payload, - $resolve$: null!, - $promise$: null!, - $returnValue$: null, - $executed$: false, + $state$: ChoreState.NONE, + $blockedChores$: null, + $returnValue$: null!, }; - chore = sortedInsert(choreQueue, chore, (container as DomContainer).rootVNode || null); + if (type === ChoreType.WAIT_FOR_QUEUE) { + getChorePromise(chore); + drainChore = chore as Chore; + // TODO: I think this is not right, because we can drain the same queue twice + immediateDrain(); + return chore; + } - DEBUG && debugTrace('schedule', chore, currentChore, choreQueue); - if (!drainScheduled && runLater) { - // If we are not currently draining, we need to schedule a drain. - drainScheduled = true; - schedule(ChoreType.JOURNAL_FLUSH); - // Catch here to avoid unhandled promise rejection - (scheduleDrain() as any)?.catch?.(() => {}); + const isServer = isServerPlatform(); + const isClientOnly = + type === ChoreType.NODE_DIFF || + type === ChoreType.NODE_PROP || + type === ChoreType.QRL_RESOLVE; + if (isServer && isClientOnly) { + DEBUG && debugTrace(`skip client chore ${debugChoreTypeToString(type)}`, null, choreQueue); + // TODO mark done? + return chore; } - // TODO figure out what to do with chore errors - if (runLater) { - return getPromise(chore); + + let blocked = false; + if ( + chore.$type$ === ChoreType.RUN_QRL || + chore.$type$ === ChoreType.TASK || + chore.$type$ === ChoreType.VISIBLE + ) { + const component = chore.$host$; + // TODO optimize + const qrlChore = choreQueue.find( + (c) => c.$type$ === ChoreType.QRL_RESOLVE && c.$host$ === component + ); + if (qrlChore) { + qrlChore.$blockedChores$ ||= []; + qrlChore.$blockedChores$.push(chore); + blocked = true; + } + } + + if (chore.$type$ === ChoreType.NODE_DIFF || chore.$type$ === ChoreType.NODE_PROP) { + // blocked by its component chore + const component = chore.$host$; + // TODO: better way to find the component chore + const componentChore = choreQueue.find( + (c) => c.$type$ === ChoreType.COMPONENT && c.$host$ === component + ); + if (componentChore) { + componentChore.$blockedChores$ ||= []; + componentChore.$blockedChores$.push(chore); + blocked = true; + } + } else if (chore.$type$ === ChoreType.TASK) { + // Tasks are blocking other tasks in the same component + // They should be executed in the order of declaration + if (isNumber(chore.$idx$) && chore.$idx$ > 0) { + // For tasks with index > 0, they are probably blocked by a previous task + const component = chore.$host$; + const elementSeq = container.getHostProp(component, ELEMENT_SEQ); + if (elementSeq) { + let currentTaskFound = false; + for (let i = chore.$idx$; i > 0; i--) { + const task = elementSeq[i]; + if (task && chore.$payload$ === task) { + currentTaskFound = true; + continue; + } else if (currentTaskFound && task instanceof Task && task.$flags$ & TaskFlags.TASK) { + // Find the chore for the task + const taskChore = choreQueue.find((c) => c.$payload$ === task); + if (taskChore) { + // Add the chore to the blocked chores of the previous task + taskChore.$blockedChores$ ||= []; + taskChore.$blockedChores$.push(chore); + blocked = true; + } + } + } + } + } + } + chore.$state$ = blocked ? ChoreState.BLOCKED : ChoreState.NONE; + chore = sortedInsert( + choreQueue, + chore, + (container as DomContainer).rootVNode || null + ) as Chore; + DEBUG && debugTrace('schedule', chore, choreQueue); + + if (isServer && type === ChoreType.COMPONENT && !isDraining) { + immediateDrain(); } else { - return drainUpTo(chore, isServer); + drainInNextTick(); } + return chore; } - /** Execute all of the chores up to and including the given chore. */ - function drainUpTo(runUptoChore: Chore, isServer: boolean): ValueOrPromise { - let maxRetries = 5000; - while (choreQueue.length) { - if (maxRetries-- < 0) { - throw new Error('drainUpTo: max retries reached'); - } + function immediateDrain() { + drainScheduled = true; + drainChoreQueue(); + } - if (currentChore) { - // Already running chore, which means we're waiting for async completion - return getPromise(currentChore) - .then(() => drainUpTo(runUptoChore, isServer)) - .catch((e) => { - container.handleError(e, currentChore?.$host$ as any); - }); - } + function drainChoreQueue(): void { + isDraining = true; + const isServer = isServerPlatform(); + const startTime = performance.now(); + let now = 0; - const nextChore = choreQueue[0]; + const shouldApplyJournalFlush = () => { + return !isServer && now - startTime > FREQUENCY_MS; + }; + + const applyJournalFlush = () => { + journalFlush(); + DEBUG && debugTrace('journalFlush.DONE', null, choreQueue); + }; - if (nextChore.$executed$) { - choreQueue.shift(); - if (nextChore === runUptoChore) { - break; + const maybeFinishDrain = () => { + if (choreQueue.length) { + return drainChoreQueue(); + } + if (runningChoresCount || !drainScheduled) { + if (shouldApplyJournalFlush()) { + // apply journal flush even if we are not finished draining the queue + applyJournalFlush(); } - continue; + return; } + currentChore = null; + drainScheduled = false; + isDraining = false; + applyJournalFlush(); + drainChore?.$resolve$!(null); + drainChore = null; + DEBUG && debugTrace('drain.DONE', drainChore, choreQueue); + }; - if ( - vNodeAlreadyDeleted(nextChore) && - // we need to process cleanup tasks for deleted nodes - nextChore.$type$ !== ChoreType.CLEANUP_VISIBLE - ) { - DEBUG && debugTrace('skip chore', nextChore, currentChore, choreQueue); - choreQueue.shift(); - continue; + const scheduleBlockedChoresAndDrainIfNeeded = (chore: Chore) => { + if (chore.$blockedChores$) { + for (const blockedChore of chore.$blockedChores$) { + blockedChore.$state$ = ChoreState.NONE; + } + chore.$blockedChores$ = null; } + drainInNextTick(); + }; + + let currentChore: Chore | null = null; + + try { + while (choreQueue.length) { + now = performance.now(); - executeChore(nextChore, isServer); + const chore = (currentChore = choreQueue.shift()!); + if (chore.$state$ !== ChoreState.NONE) { + continue; + } + + if ( + vNodeAlreadyDeleted(chore) && + // we need to process cleanup tasks for deleted nodes + chore.$type$ !== ChoreType.CLEANUP_VISIBLE + ) { + // skip deleted chore + DEBUG && debugTrace('skip chore', chore, choreQueue); + continue; + } + // TODO: check if chore is blocked by another chore + // Note that this never throws + const result = executeChore(chore, isServer); + chore.$returnValue$ = result; + if (isPromise(result)) { + runningChoresCount++; + chore.$state$ = ChoreState.RUNNING; + + result + .then((value) => { + chore.$returnValue$ = value; + chore.$state$ = ChoreState.DONE; + DEBUG && debugTrace('execute.DONE', chore, choreQueue); + // If we used the result as promise, this won't exist + chore.$resolve$?.(value); + }) + .catch((e) => { + if (chore.$state$ !== ChoreState.RUNNING) { + // we already handled the error + return; + } + handleError(chore, e); + }) + .finally(() => { + runningChoresCount--; + // Note that we ignore failed chores so the app keeps working + // TODO decide if this is ok and document it + scheduleBlockedChoresAndDrainIfNeeded(chore); + maybeFinishDrain(); + }); + } else { + DEBUG && debugTrace('execute.DONE', chore, choreQueue); + chore.$state$ = ChoreState.DONE; + chore.$resolve$?.(result); + scheduleBlockedChoresAndDrainIfNeeded(chore); + } + + if (shouldApplyJournalFlush()) { + applyJournalFlush(); + drainInNextTick(); + return; + } + } + } catch (e) { + DEBUG && debugTrace('execute.ERROR sync', currentChore, choreQueue); + container.handleError(e, currentChore?.$host$ || null); + scheduleBlockedChoresAndDrainIfNeeded(currentChore!); + } finally { + maybeFinishDrain(); } - return runUptoChore.$returnValue$; } - function executeChore(chore: Chore, isServer: boolean) { + function handleError(chore: Chore, e: any) { + chore.$state$ = ChoreState.FAILED; + DEBUG && debugTrace('execute.ERROR', chore, choreQueue); + // If we used the result as promise, this won't exist + chore.$reject$?.(e); + container.handleError(e, chore.$host$); + } + + function executeChore( + chore: Chore, + isServer: boolean + ): ValueOrPromise> { const host = chore.$host$; - DEBUG && debugTrace('execute', chore, currentChore, choreQueue); - assertEqual(currentChore, null, 'Chore already running.'); - currentChore = chore; - let returnValue: ValueOrPromise | unknown = null; - try { - switch (chore.$type$) { - case ChoreType.WAIT_FOR_ALL: - { - if (isServer) { - drainScheduled = false; - } - } - break; - case ChoreType.JOURNAL_FLUSH: - { - returnValue = journalFlush(); - drainScheduled = false; - } - break; - case ChoreType.COMPONENT: - { - returnValue = safeCall( - () => - executeComponent( - container, - host, - host, - chore.$target$ as QRLInternal>, - chore.$payload$ as Props | null - ), - (jsx) => { - if (isServer) { - return jsx; - } else { - const styleScopedId = container.getHostProp(host, QScopedStyle); - return retryOnPromise(() => - vnode_diff( - container as ClientContainer, - jsx, - host as VirtualVNode, - addComponentStylePrefix(styleScopedId) - ) - ); - } - }, - (err: any) => container.handleError(err, host) - ); - } - break; - case ChoreType.RUN_QRL: - { - const fn = (chore.$target$ as QRLInternal<(...args: unknown[]) => unknown>).getFn(); - const result = retryOnPromise(() => fn(...(chore.$payload$ as unknown[]))); - if (isPromise(result)) { - const handled = result - .finally(() => { - qrlRuns.splice(qrlRuns.indexOf(handled), 1); - }) - .catch((error) => { - container.handleError(error, chore.$host$); - }); - // Don't wait for the promise to resolve - // TODO come up with a better solution, we also want concurrent signal handling with tasks but serial tasks - qrlRuns.push(handled); - DEBUG && - debugTrace('execute.DONE (but still running)', chore, currentChore, choreQueue); - chore.$returnValue$ = handled; - chore.$resolve$?.(handled); - currentChore = null; - chore.$executed$ = true; - // early out so we don't call after() - return; - } - returnValue = null; - } - break; - case ChoreType.TASK: - case ChoreType.VISIBLE: - { - const payload = chore.$payload$ as DescriptorBase; - if (payload.$flags$ & TaskFlags.RESOURCE) { - const result = runResource(payload as ResourceDescriptor, container, host); - // Don't await the return value of the resource, because async resources should not be awaited. - // The reason for this is that we should be able to update for example a node with loading - // text. If we await the resource, the loading text will not be displayed until the resource - // is loaded. - // Awaiting on the client also causes a deadlock. - // In any case, the resource will never throw. - returnValue = isServer ? result : null; - } else { - returnValue = runTask(payload as Task, container, host); + DEBUG && debugTrace('execute', chore, choreQueue); + let returnValue: ValueOrPromise>; + switch (chore.$type$) { + case ChoreType.COMPONENT: + { + returnValue = safeCall( + () => + executeComponent( + container, + host, + host, + chore.$target$ as QRLInternal>, + chore.$payload$ as Props | null + ), + (jsx) => { + if (isServer) { + return jsx; + } else { + const styleScopedId = container.getHostProp(host, QScopedStyle); + return retryOnPromise(() => + vnode_diff( + container as ClientContainer, + jsx, + host as VirtualVNode, + addComponentStylePrefix(styleScopedId) + ) + ); + } + }, + (err: any) => { + handleError(chore, err); } + ) as ValueOrPromise>; + } + break; + case ChoreType.RUN_QRL: + { + const fn = (chore.$target$ as QRLInternal<(...args: unknown[]) => unknown>).getFn(); + returnValue = retryOnPromise(() => + fn(...(chore.$payload$ as unknown[])) + ) as ValueOrPromise>; + } + break; + case ChoreType.TASK: + case ChoreType.VISIBLE: + { + const payload = chore.$payload$ as DescriptorBase; + if (payload.$flags$ & TaskFlags.RESOURCE) { + returnValue = runResource( + payload as ResourceDescriptor, + container, + host + ) as ValueOrPromise>; + } else { + returnValue = runTask( + payload as Task, + container, + host + ) as ValueOrPromise>; } - break; - case ChoreType.CLEANUP_VISIBLE: - { - const task = chore.$payload$ as Task; - cleanupTask(task); - } - break; - case ChoreType.NODE_DIFF: - { - const parentVirtualNode = chore.$target$ as VirtualVNode; - let jsx = chore.$payload$ as JSXOutput; - if (isSignal(jsx)) { - jsx = jsx.value as any; - } - returnValue = retryOnPromise(() => - vnode_diff(container as DomContainer, jsx, parentVirtualNode, null) - ); + } + break; + case ChoreType.CLEANUP_VISIBLE: + { + const task = chore.$payload$ as Task; + cleanupTask(task); + } + break; + case ChoreType.NODE_DIFF: + { + const parentVirtualNode = chore.$target$ as VirtualVNode; + let jsx = chore.$payload$ as JSXOutput; + if (isSignal(jsx)) { + jsx = jsx.value as any; } - break; - case ChoreType.NODE_PROP: - { - const virtualNode = chore.$host$ as unknown as ElementVNode; - const payload = chore.$payload$ as NodePropPayload; - let value: Signal | string = payload.$value$; - if (isSignal(value)) { - value = value.value as any; - } - const isConst = payload.$isConst$; - const journal = (container as DomContainer).$journal$; - const property = chore.$idx$ as string; - const serializedValue = serializeAttribute( - property, - value, - payload.$scopedStyleIdPrefix$ - ); - if (isConst) { - const element = virtualNode[ElementVNodeProps.element] as Element; - journal.push(VNodeJournalOpCode.SetAttribute, element, property, serializedValue); - } else { - vnode_setAttr(journal, virtualNode, property, serializedValue); - } + returnValue = retryOnPromise(() => + vnode_diff(container as DomContainer, jsx, parentVirtualNode, null) + ) as ValueOrPromise>; + } + break; + case ChoreType.NODE_PROP: + { + const virtualNode = chore.$host$ as unknown as ElementVNode; + const payload = chore.$payload$ as NodePropPayload; + let value: Signal | string = payload.$value$; + if (isSignal(value)) { + value = value.value as any; } - break; - case ChoreType.QRL_RESOLVE: { - { - const target = chore.$target$ as QRLInternal; - returnValue = !target.resolved ? target.resolve() : null; + const isConst = payload.$isConst$; + const journal = (container as DomContainer).$journal$; + const property = chore.$idx$ as string; + const serializedValue = serializeAttribute( + property, + value, + payload.$scopedStyleIdPrefix$ + ); + if (isConst) { + const element = virtualNode[ElementVNodeProps.element] as Element; + journal.push(VNodeJournalOpCode.SetAttribute, element, property, serializedValue); + } else { + vnode_setAttr(journal, virtualNode, property, serializedValue); } - break; + returnValue = undefined as ValueOrPromise>; } - case ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS: { - { - const target = chore.$target$ as - | SignalImpl - | ComputedSignalImpl - | WrappedSignalImpl - | StoreHandler; - - const effects = chore.$payload$ as Set; + break; + case ChoreType.QRL_RESOLVE: { + { + const target = chore.$target$ as QRLInternal; + returnValue = (!target.resolved ? target.resolve() : null) as ValueOrPromise< + ChoreReturnValue + >; + } + break; + } + case ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS: { + { + const target = chore.$target$ as + | SignalImpl + | ComputedSignalImpl + | WrappedSignalImpl + | StoreHandler; + + const effects = chore.$payload$ as Set; + if (!target.$effects$?.size) { + break; + } + if (target instanceof AsyncComputedSignalImpl) { + // TODO: it should be triggered only when value is changed only + returnValue = retryOnPromise(() => { + triggerEffects(container, target, effects); + }) as ValueOrPromise>; + } else if (target instanceof ComputedSignalImpl || target instanceof WrappedSignalImpl) { + const forceRunEffects = target.$forceRunEffects$; + target.$forceRunEffects$ = false; const ctx = newInvokeContext(); ctx.$container$ = container; - if (target instanceof ComputedSignalImpl || target instanceof WrappedSignalImpl) { - const forceRunEffects = target.$forceRunEffects$; - target.$forceRunEffects$ = false; - if (!effects?.size && !forceRunEffects) { - break; - } - // needed for computed signals and throwing QRLs - returnValue = maybeThen( - retryOnPromise(() => invoke.call(target, ctx, target.$computeIfNeeded$)), - (didChange) => { - if (didChange || forceRunEffects) { - return retryOnPromise(() => triggerEffects(container, target, effects)); - } + // needed for computed signals and throwing QRLs + returnValue = maybeThen( + retryOnPromise(() => invoke.call(target, ctx, target.$computeIfNeeded$)), + (didChange) => { + if (didChange || forceRunEffects) { + return retryOnPromise(() => triggerEffects(container, target, effects)); } - ); - } else { - returnValue = retryOnPromise(() => triggerEffects(container, target, effects)); - } + } + ) as ValueOrPromise>; + } else { + returnValue = retryOnPromise(() => { + triggerEffects(container, target, effects); + }) as ValueOrPromise>; } - break; } + break; } - } catch (e) { - returnValue = Promise.reject(e); - } - - const after = (value?: any, error?: Error) => { - currentChore = null; - chore.$executed$ = true; - if (error) { - DEBUG && debugTrace('execute.ERROR', chore, currentChore, choreQueue); - container.handleError(error, host); - } else { - chore.$returnValue$ = value; - DEBUG && debugTrace('execute.DONE', chore, currentChore, choreQueue); - chore.$resolve$?.(value); - } - }; - - if (isPromise(returnValue)) { - chore.$promise$ = returnValue.then(after, (error) => after(undefined, error)); - chore.$resolve$?.(chore.$promise$); - chore.$resolve$ = undefined; - } else { - after(returnValue); } + return returnValue as any; } /** @@ -601,11 +745,6 @@ export const createScheduler = ( return 1; } - // If the chore is the same as the current chore, we will run it again - if (b === currentChore) { - return 1; - } - // The chores are the same and will run only once return 0; } @@ -655,9 +794,6 @@ export const createScheduler = ( if (existing.$payload$ !== value.$payload$) { existing.$payload$ = value.$payload$; } - if (existing.$executed$) { - existing.$executed$ = false; - } return existing; } }; @@ -685,38 +821,33 @@ function debugChoreTypeToString(type: ChoreType): string { [ChoreType.NODE_PROP]: 'NODE_PROP', [ChoreType.COMPONENT]: 'COMPONENT', [ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS]: 'RECOMPUTE_SIGNAL', - [ChoreType.JOURNAL_FLUSH]: 'JOURNAL_FLUSH', [ChoreType.VISIBLE]: 'VISIBLE', [ChoreType.CLEANUP_VISIBLE]: 'CLEANUP_VISIBLE', - [ChoreType.WAIT_FOR_ALL]: 'WAIT_FOR_ALL', + [ChoreType.WAIT_FOR_QUEUE]: 'WAIT_FOR_QUEUE', } as Record )[type] || 'UNKNOWN: ' + type ); } function debugChoreToString(chore: Chore): string { const type = debugChoreTypeToString(chore.$type$); + const state = chore.$state$ ? `[${ChoreState[chore.$state$]}] ` : ''; const host = String(chore.$host$).replaceAll(/\n.*/gim, ''); const qrlTarget = (chore.$target$ as QRLInternal)?.$symbol$; - return `Chore(${type} ${chore.$type$ === ChoreType.QRL_RESOLVE || chore.$type$ === ChoreType.RUN_QRL ? qrlTarget : host} ${chore.$idx$})`; + return `${state}Chore(${type} ${chore.$type$ === ChoreType.QRL_RESOLVE || chore.$type$ === ChoreType.RUN_QRL ? qrlTarget : host} ${chore.$idx$})`; } -function debugTrace( - action: string, - arg?: any | null, - currentChore?: Chore | null, - queue?: Chore[] -) { +function debugTrace(action: string, arg?: any | null, queue?: Chore[]) { const lines = ['===========================\nScheduler: ' + action]; if (arg && !('$type$' in arg)) { lines.push(' arg: ' + String(arg).replaceAll(/\n.*/gim, '')); + } else if (arg && !queue?.length) { + lines.push(' arg: ' + debugChoreToString(arg)); } if (queue) { queue.forEach((chore) => { const active = chore === arg ? '>>>' : ' '; lines.push( - ` ${active} > ` + - (chore === currentChore ? '[running] ' : '') + - debugChoreToString(chore) + ` ${active} > ` + (chore.$state$ ? '[running] ' : '') + debugChoreToString(chore) ); }); } diff --git a/packages/qwik/src/core/shared/scheduler.unit.tsx b/packages/qwik/src/core/shared/scheduler.unit.tsx index 04a9272f19e..981da1dc30c 100644 --- a/packages/qwik/src/core/shared/scheduler.unit.tsx +++ b/packages/qwik/src/core/shared/scheduler.unit.tsx @@ -1,7 +1,7 @@ -import { $, type OnRenderFn, type QRL } from '@qwik.dev/core'; +import { $, _jsxSorted, type JSXOutput, type OnRenderFn, type QRL } from '@qwik.dev/core'; -import { createDocument } from '@qwik.dev/core/testing'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { createDocument, getTestPlatform } from '@qwik.dev/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getDomContainer } from '../client/dom-container'; import type { ElementVNode, VNode, VirtualVNode } from '../client/types'; import { @@ -19,11 +19,18 @@ import { ChoreType } from './util-chore-type'; import type { HostElement } from './types'; import { QContainerAttr } from './utils/markers'; import { _EFFECT_BACK_REF } from '../reactive-primitives/types'; +import { MAX_RETRY_ON_PROMISE_COUNT } from './utils/promises'; declare global { let testLog: string[]; } +vi.mock('../client/vnode-diff', () => ({ + vnode_diff: vi.fn().mockImplementation(() => { + testLog.push('vnode-diff'); + }), +})); + describe('scheduler', () => { let scheduler: ReturnType = null!; let document: ReturnType = null!; @@ -33,16 +40,22 @@ describe('scheduler', () => { let vB: ElementVNode = null!; let vBHost1: VirtualVNode = null!; let vBHost2: VirtualVNode = null!; + let handleError: (err: any, host: HostElement | null) => void; + + async function waitForDrain() { + const chore = scheduler.schedule(ChoreType.WAIT_FOR_QUEUE); + getTestPlatform().flush(); + await chore.$returnValue$; + } + beforeEach(() => { + vi.clearAllMocks(); (globalThis as any as { testLog: string[] }).testLog = []; document = createDocument(); document.body.setAttribute(QContainerAttr, 'paused'); const container = getDomContainer(document.body); - scheduler = createScheduler( - container, - () => null, - () => testLog.push('journalFlush') - ); + handleError = container.handleError = vi.fn(); + scheduler = createScheduler(container, () => testLog.push('journalFlush')); document.body.innerHTML = ''; vBody = vnode_newUnMaterializedElement(document.body); vA = vnode_locate(vBody, document.querySelector('a') as Element) as ElementVNode; @@ -59,10 +72,13 @@ describe('scheduler', () => { }); it('should execute sort tasks', async () => { - scheduler(ChoreType.TASK, mockTask(vBHost1, { index: 2, qrl: $(() => testLog.push('b1.2')) })); - scheduler(ChoreType.TASK, mockTask(vAHost, { qrl: $(() => testLog.push('a1')) })); - scheduler(ChoreType.TASK, mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0')) })); - await scheduler(ChoreType.WAIT_FOR_ALL); + scheduler.schedule( + ChoreType.TASK, + mockTask(vBHost1, { index: 2, qrl: $(() => testLog.push('b1.2')) }) + ); + scheduler.schedule(ChoreType.TASK, mockTask(vAHost, { qrl: $(() => testLog.push('a1')) })); + scheduler.schedule(ChoreType.TASK, mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0')) })); + await waitForDrain(); expect(testLog).toEqual([ 'a1', // DepthFirst a host component is before b host component. 'b1.0', // Same component but smaller index. @@ -71,12 +87,15 @@ describe('scheduler', () => { ]); }); it('should execute visible tasks after journal flush', async () => { - scheduler( + scheduler.schedule( ChoreType.TASK, mockTask(vBHost2, { index: 2, qrl: $(() => testLog.push('b2.2: Task')) }) ); - scheduler(ChoreType.TASK, mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0: Task')) })); - scheduler( + scheduler.schedule( + ChoreType.TASK, + mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0: Task')) }) + ); + scheduler.schedule( ChoreType.VISIBLE, mockTask(vBHost2, { index: 2, @@ -84,7 +103,7 @@ describe('scheduler', () => { visible: true, }) ); - scheduler( + scheduler.schedule( ChoreType.VISIBLE, mockTask(vBHost1, { qrl: $(() => { @@ -93,22 +112,132 @@ describe('scheduler', () => { visible: true, }) ); - scheduler( + scheduler.schedule( ChoreType.COMPONENT, vBHost1 as HostElement, $(() => testLog.push('b1: Render')) as unknown as QRLInternal>, {} as Props ); - await scheduler(ChoreType.WAIT_FOR_ALL); + await waitForDrain(); + // TODO: is it right? expect(testLog).toEqual([ 'b1.0: Task', 'b1: Render', + 'vnode-diff', 'b2.2: Task', - 'journalFlush', 'b1.0: VisibleTask', 'b2.2: VisibleTask', + 'journalFlush', ]); }); + + it('should execute chore', async () => { + scheduler.schedule(ChoreType.TASK, mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0')) })); + await waitForDrain(); + expect(testLog).toEqual(['b1.0', 'journalFlush']); + }); + + it('should execute chore with promise', async () => { + vi.useFakeTimers(); + scheduler.schedule( + ChoreType.TASK, + mockTask(vBHost1, { + qrl: $( + () => + new Promise((resolve) => + setTimeout(() => { + testLog.push('b1.0'); + resolve(); + }, 100) + ) + ), + }) + ); + vi.advanceTimersByTimeAsync(100); + await waitForDrain(); + expect(testLog).toEqual(['b1.0', 'journalFlush']); + vi.useRealTimers(); + }); + + it('should execute multiple chores', async () => { + scheduler.schedule(ChoreType.TASK, mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0')) })); + scheduler.schedule( + ChoreType.TASK, + mockTask(vBHost1, { qrl: $(() => testLog.push('b1.1')), index: 1 }) + ); + await waitForDrain(); + expect(testLog).toEqual(['b1.0', 'b1.1', 'journalFlush']); + }); + + it('should execute chore with promise and schedule blocked vnode-diff chores', async () => { + scheduler.schedule( + ChoreType.COMPONENT, + vBHost1 as HostElement, + $(() => testLog.push('component')) as unknown as QRLInternal>, + {} + ); + scheduler.schedule( + ChoreType.NODE_DIFF, + vBHost1 as HostElement, + vBHost1 as HostElement, + _jsxSorted('div', null, null, null, 0, null) as JSXOutput + ); + await waitForDrain(); + expect(testLog).toEqual([ + // component + component vnode-diff + 'component', + // vnode-diff chore + 'vnode-diff', + 'journalFlush', + ]); + }); + + it('should execute chores in two ticks', async () => { + scheduler.schedule(ChoreType.TASK, mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0')) })); + await waitForDrain(); + scheduler.schedule(ChoreType.TASK, mockTask(vBHost1, { qrl: $(() => testLog.push('b1.1')) })); + await waitForDrain(); + expect(testLog).toEqual(['b1.0', 'journalFlush', 'b1.1', 'journalFlush']); + }); + + it('should not go into infinity loop on thrown promise', async () => { + (globalThis as any).executionCounter = vi.fn(); + + scheduler.schedule( + ChoreType.COMPONENT, + vBHost1 as HostElement, + $(() => { + (globalThis as any).executionCounter(); + throw Promise.resolve(null); + }) as unknown as QRLInternal>, + {} + ); + await waitForDrain(); + expect((globalThis as any).executionCounter).toHaveBeenCalledTimes( + MAX_RETRY_ON_PROMISE_COUNT + 1 + ); + expect(handleError).toHaveBeenCalledTimes(1); + (globalThis as any).executionCounter = undefined; + }); + + it('should not go into infinity loop on thrown promise', async () => { + (globalThis as any).counter = 0; + scheduler.schedule( + ChoreType.COMPONENT, + vBHost1 as HostElement, + $(() => { + testLog.push('component'); + (globalThis as any).counter++; + if ((globalThis as any).counter === 1) { + throw Promise.resolve(null); + } + }) as unknown as QRLInternal>, + {} + ); + await waitForDrain(); + expect(testLog).toEqual(['component', 'component', 'vnode-diff', 'journalFlush']); + (globalThis as any).counter = undefined; + }); }); function mockTask(host: VNode, opts: { index?: number; qrl?: QRL; visible?: boolean }): Task { @@ -120,5 +249,5 @@ function mockTask(host: VNode, opts: { index?: number; qrl?: QRL; visible?: bool $state$: null!, $destroy$: null!, [_EFFECT_BACK_REF]: null, - }; + } as Task; } diff --git a/packages/qwik/src/core/shared/shared-container.ts b/packages/qwik/src/core/shared/shared-container.ts index 14cc690d1b1..b4514abae88 100644 --- a/packages/qwik/src/core/shared/shared-container.ts +++ b/packages/qwik/src/core/shared/shared-container.ts @@ -4,8 +4,7 @@ import { version } from '../version'; import type { SubscriptionData } from '../reactive-primitives/subscription-data'; import type { Signal } from '../reactive-primitives/signal.public'; import type { StreamWriter, SymbolToChunkResolver } from '../ssr/ssr-types'; -import type { Scheduler } from './scheduler'; -import { createScheduler } from './scheduler'; +import { createScheduler, Scheduler } from './scheduler'; import { createSerializationContext, type SerializationContext } from './shared-serialization'; import type { Container, HostElement, ObjToProxyMap } from './types'; @@ -23,12 +22,7 @@ export abstract class _SharedContainer implements Container { $instanceHash$: string | null = null; $buildBase$: string | null = null; - constructor( - scheduleDrain: () => void, - journalFlush: () => void, - serverData: Record, - locale: string - ) { + constructor(journalFlush: () => void, serverData: Record, locale: string) { this.$serverData$ = serverData; this.$locale$ = locale; this.$version$ = version; @@ -37,7 +31,7 @@ export abstract class _SharedContainer implements Container { throw Error('Not implemented'); }; - this.$scheduler$ = createScheduler(this, scheduleDrain, journalFlush); + this.$scheduler$ = createScheduler(this, journalFlush); } trackSignalValue( @@ -71,7 +65,7 @@ export abstract class _SharedContainer implements Container { } abstract ensureProjectionResolved(host: HostElement): void; - abstract handleError(err: any, $host$: HostElement): void; + abstract handleError(err: any, $host$: HostElement | null): void; abstract getParentHost(host: HostElement): HostElement | null; abstract setContext(host: HostElement, context: ContextId, value: T): void; abstract resolveContext(host: HostElement, contextId: ContextId): T | undefined; diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 80869d94692..e649eed24a6 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -346,7 +346,7 @@ const inflate = ( */ // try to download qrl in this tick computed.$computeQrl$.resolve(); - (container as DomContainer).$scheduler$?.( + (container as DomContainer).$scheduler$?.schedule( ChoreType.QRL_RESOLVE, null, computed.$computeQrl$ diff --git a/packages/qwik/src/core/shared/types.ts b/packages/qwik/src/core/shared/types.ts index 82a28e43383..bf388d3d576 100644 --- a/packages/qwik/src/core/shared/types.ts +++ b/packages/qwik/src/core/shared/types.ts @@ -27,7 +27,7 @@ export interface Container { $currentUniqueId$: number; $buildBase$: string | null; - handleError(err: any, $host$: HostElement): void; + handleError(err: any, $host$: HostElement | null): void; getParentHost(host: HostElement): HostElement | null; setContext(host: HostElement, context: ContextId, value: T): void; resolveContext(host: HostElement, contextId: ContextId): T | undefined; diff --git a/packages/qwik/src/core/shared/util-chore-type.ts b/packages/qwik/src/core/shared/util-chore-type.ts index e0f76992ea9..890869eb57e 100644 --- a/packages/qwik/src/core/shared/util-chore-type.ts +++ b/packages/qwik/src/core/shared/util-chore-type.ts @@ -13,11 +13,9 @@ export const enum ChoreType { COMPONENT, RECOMPUTE_AND_SCHEDULE_EFFECTS, // Next macro level - JOURNAL_FLUSH /* ******************** */ = 16, + VISIBLE /* ************************** */ = 16, // Next macro level - VISIBLE /* ************************** */ = 32, + CLEANUP_VISIBLE /* ****************** */ = 32, // Next macro level - CLEANUP_VISIBLE /* ****************** */ = 48, - // Next macro level - WAIT_FOR_ALL /* ********************* */ = 255, + WAIT_FOR_QUEUE /* ********************** */ = 255, } diff --git a/packages/qwik/src/core/shared/utils/types.ts b/packages/qwik/src/core/shared/utils/types.ts index 00562c86155..31a7ee4e63e 100644 --- a/packages/qwik/src/core/shared/utils/types.ts +++ b/packages/qwik/src/core/shared/utils/types.ts @@ -20,6 +20,10 @@ export const isString = (v: unknown): v is string => { return typeof v === 'string'; }; +export const isNumber = (v: unknown): v is number => { + return typeof v === 'number'; +}; + export const isFunction = any>(v: unknown): v is T => { return typeof v === 'function'; }; diff --git a/packages/qwik/src/core/ssr/ssr-render-component.ts b/packages/qwik/src/core/ssr/ssr-render-component.ts index 51fc8a0f92a..626659e943a 100644 --- a/packages/qwik/src/core/ssr/ssr-render-component.ts +++ b/packages/qwik/src/core/ssr/ssr-render-component.ts @@ -4,9 +4,10 @@ import type { QRLInternal } from '../shared/qrl/qrl-class'; import { ELEMENT_KEY, ELEMENT_PROPS, OnRenderProp } from '../shared/utils/markers'; import { type ISsrNode, type SSRContainer } from './ssr-types'; import { executeComponent } from '../shared/component-execution'; -import { ChoreType } from '../shared/util-chore-type'; import type { ValueOrPromise } from '../shared/utils/types'; import type { JSXOutput } from '../shared/jsx/types/jsx-node'; +import { ChoreType } from '../shared/util-chore-type'; +import { getChorePromise } from '../shared/scheduler'; export const applyInlineComponent = ( ssr: SSRContainer, @@ -29,11 +30,16 @@ export const applyQwikComponentBody = ( if (srcProps && srcProps.children) { delete srcProps.children; } - const scheduler = ssr.$scheduler$; host.setProp(OnRenderProp, componentQrl); host.setProp(ELEMENT_PROPS, srcProps); if (jsx.key !== null) { host.setProp(ELEMENT_KEY, jsx.key); } - return scheduler(ChoreType.COMPONENT, host, componentQrl, srcProps); + const componentChore = ssr.$scheduler$.schedule( + ChoreType.COMPONENT, + host, + componentQrl, + srcProps + ); + return getChorePromise(componentChore); }; diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts index 8ec0a5ab80a..b1096faf503 100644 --- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts +++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts @@ -1,5 +1,5 @@ import { isDev } from '@qwik.dev/core/build'; -import { queueQRL } from '../client/queue-qrl'; +import { _run } from '../client/queue-qrl'; import { isQwikComponent } from '../shared/component.public'; import { Fragment, directGetPropsProxyProp } from '../shared/jsx/jsx-runtime'; import { Slot } from '../shared/jsx/slot.public'; @@ -470,7 +470,7 @@ function setEvent( * For internal qrls (starting with `_`) we assume that they do the right thing. */ if (!qrl.$symbol$.startsWith('_') && (qrl.$captureRef$ || qrl.$capture$)) { - qrl = createQRL(null, '_run', queueQRL, null, null, [qrl]); + qrl = createQRL(null, '_run', _run, null, null, [qrl]); } return qrlToString(serializationCtx, qrl); }; diff --git a/packages/qwik/src/core/tests/component.spec.tsx b/packages/qwik/src/core/tests/component.spec.tsx index 36cfd073c59..d5fbb5a27db 100644 --- a/packages/qwik/src/core/tests/component.spec.tsx +++ b/packages/qwik/src/core/tests/component.spec.tsx @@ -830,7 +830,19 @@ describe.each([ const update = $(() => store2.count++); return ( - ); diff --git a/packages/qwik/src/core/tests/ref.spec.tsx b/packages/qwik/src/core/tests/ref.spec.tsx index 41d89b2b902..6136c43948e 100644 --- a/packages/qwik/src/core/tests/ref.spec.tsx +++ b/packages/qwik/src/core/tests/ref.spec.tsx @@ -22,7 +22,8 @@ describe.each([ { render: domRender }, // ])('$render.name: ref', ({ render }) => { describe('useVisibleTask$', () => { - it('should handle ref prop', async () => { + // TODO this probably never worked + it.todo('should handle ref prop', async () => { const Cmp = component$(() => { const v = useSignal(); useVisibleTask$(() => { diff --git a/packages/qwik/src/core/tests/render-promise.spec.tsx b/packages/qwik/src/core/tests/render-promise.spec.tsx index ff5fb3658fc..d5b9257ae2c 100644 --- a/packages/qwik/src/core/tests/render-promise.spec.tsx +++ b/packages/qwik/src/core/tests/render-promise.spec.tsx @@ -29,7 +29,7 @@ describe.each([ const Child = component$(() => { const signal = useSignal(0); if (signal.value === 0) { - throw new Promise((r) => r(signal.value++)); + throw Promise.resolve(signal.value++); } return 'child'; }); diff --git a/packages/qwik/src/core/tests/use-resource.spec.tsx b/packages/qwik/src/core/tests/use-resource.spec.tsx index 8fcc8a93382..9a2fe8d5c49 100644 --- a/packages/qwik/src/core/tests/use-resource.spec.tsx +++ b/packages/qwik/src/core/tests/use-resource.spec.tsx @@ -13,7 +13,6 @@ import { } from '@qwik.dev/core'; import { domRender, getTestPlatform, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; import { describe, expect, it } from 'vitest'; -import '../../testing/vdom-diff.unit-util'; const debug = false; //true; Error.stackTraceLimit = 100; diff --git a/packages/qwik/src/core/tests/use-task.spec.tsx b/packages/qwik/src/core/tests/use-task.spec.tsx index be61bce7bf1..b8412f46cf0 100644 --- a/packages/qwik/src/core/tests/use-task.spec.tsx +++ b/packages/qwik/src/core/tests/use-task.spec.tsx @@ -57,7 +57,7 @@ describe.each([ }); const { vNode } = await render(, { debug }); - expect(log).toEqual(['Counter', 'render', 'task', 'resolved']); + expect(log).toEqual(['Counter', 'task', 'resolved', 'Counter', 'render']); expect(vNode).toMatchVDOM( @@ -133,7 +133,16 @@ describe.each([ }); const { vNode } = await render(, { debug }); - expect(log).toEqual(['Counter', 'render', '1:task', '1:resolved', '2:task', '2:resolved']); + expect(log).toEqual([ + 'Counter', + '1:task', + '1:resolved', + 'Counter', + '2:task', + '2:resolved', + 'Counter', // + 'render', + ]); expect(vNode).toMatchVDOM( @@ -390,7 +399,7 @@ describe.each([ }); const { vNode, document } = await render(, { debug }); - expect((globalThis as any).log).toEqual(['Counter', 'quadruple', 'double', 'quadruple']); + expect((globalThis as any).log).toEqual(['quadruple', 'double', 'Counter', 'quadruple']); expect(vNode).toMatchVDOM( + + ); }); - const { vNode } = await render(, { debug }); - expect(vNode).toMatchVDOM(1 2 3 4 7 8 9); + const { vNode, document } = await render(, { debug }); + expect(vNode).toMatchVDOM( + + + 1 2 3 4 7 8 9 + + + + ); + + await trigger(document.body, 'button', 'click'); + expect(vNode).toMatchVDOM( + + + 4 3 2 1 7 8 9 + + + + ); + + await trigger(document.body, 'button', 'click'); + expect(vNode).toMatchVDOM( + + + 1 2 3 4 7 8 9 + + + + ); }); it('catch the ', async () => { @@ -865,13 +907,6 @@ describe.each([ return
1
; }); - const Cmp1 = component$(() => { - useTask$(() => { - throw error; - }); - - return
1
; - }); try { await render( @@ -882,6 +917,13 @@ describe.each([ } catch (e: unknown) { expect((e as Error).message).toBeTruthy; } + const Cmp1 = component$(() => { + useTask$(() => { + throw error; + }); + + return
1
; + }); try { await render( diff --git a/packages/qwik/src/core/tests/use-visible-task.spec.tsx b/packages/qwik/src/core/tests/use-visible-task.spec.tsx index e2d6fdfc0d7..bb715b12c75 100644 --- a/packages/qwik/src/core/tests/use-visible-task.spec.tsx +++ b/packages/qwik/src/core/tests/use-visible-task.spec.tsx @@ -213,8 +213,8 @@ describe.each([ 'Counter', 'render', '1:task', - '1:resolved', '2:task', + '1:resolved', '2:resolved', ]); expect(vNode).toMatchVDOM( diff --git a/packages/qwik/src/core/use/use-resource.ts b/packages/qwik/src/core/use/use-resource.ts index ab22ac36578..8cf6a194320 100644 --- a/packages/qwik/src/core/use/use-resource.ts +++ b/packages/qwik/src/core/use/use-resource.ts @@ -13,7 +13,6 @@ import { StoreFlags } from '../reactive-primitives/types'; import { isSignal } from '../reactive-primitives/utils'; import { assertDefined } from '../shared/error/assert'; import type { JSXOutput } from '../shared/jsx/types/jsx-node'; -import { ChoreType } from '../shared/util-chore-type'; import { ResourceEvent } from '../shared/utils/markers'; import { delay, isPromise, retryOnPromise, safeCall } from '../shared/utils/promises'; import { isObject } from '../shared/utils/types'; @@ -106,8 +105,8 @@ export const useResourceQrl = ( resource, null ) as ResourceDescriptor; - container.$scheduler$(ChoreType.TASK, task); set(resource); + runResource(task, container, el); return resource; }; @@ -232,7 +231,7 @@ export const _createResourceReturn = (opts?: ResourceOptions): ResourceReturn const resource: ResourceReturnInternal = { __brand: 'resource', value: undefined as never, - loading: isServerPlatform() ? false : true, + loading: !isServerPlatform(), _resolved: undefined as never, _error: undefined as never, _state: 'pending', diff --git a/packages/qwik/src/core/use/use-task.ts b/packages/qwik/src/core/use/use-task.ts index fb4188edebe..5e54d52571d 100644 --- a/packages/qwik/src/core/use/use-task.ts +++ b/packages/qwik/src/core/use/use-task.ts @@ -156,10 +156,9 @@ export const useTaskQrl = (qrl: QRL): void => { // deleted and we need to be able to release the task subscriptions. set(task); const container = iCtx.$container$; - const promise = container.$scheduler$(ChoreType.TASK, task); - if (isPromise(promise)) { - // TODO: should we handle this differently? - promise.catch(() => {}); + const result = runTask(task, container, iCtx.$hostElement$); + if (isPromise(result)) { + throw result; } }; @@ -178,7 +177,7 @@ export const runTask = ( const [cleanup] = cleanupFn(task, (reason: unknown) => container.handleError(reason, host)); const taskApi: TaskCtx = { track, cleanup }; - const result: ValueOrPromise = safeCall( + return safeCall( () => taskFn(taskApi), cleanup, (err: unknown) => { @@ -190,7 +189,6 @@ export const runTask = ( } } ); - return result; }; export const cleanupTask = (task: Task) => { @@ -234,5 +232,5 @@ export const scheduleTask = (_event: Event, element: Element) => { const [task] = useLexicalScope<[Task]>(); const type = task.$flags$ & TaskFlags.VISIBLE_TASK ? ChoreType.VISIBLE : ChoreType.TASK; const container = getDomContainer(element); - container.$scheduler$(type, task); + container.$scheduler$.schedule(type, task); }; diff --git a/packages/qwik/src/core/use/use-visible-task.ts b/packages/qwik/src/core/use/use-visible-task.ts index d6d289099fb..58f1e646535 100644 --- a/packages/qwik/src/core/use/use-visible-task.ts +++ b/packages/qwik/src/core/use/use-visible-task.ts @@ -43,7 +43,7 @@ export const useVisibleTaskQrl = (qrl: QRL, opts?: OnVisibleTaskOptions) useRunTask(task, eagerness); if (!isServerPlatform()) { (qrl as QRLInternal).resolve(iCtx.$element$); - iCtx.$container$.$scheduler$(ChoreType.VISIBLE, task); + iCtx.$container$.$scheduler$.schedule(ChoreType.VISIBLE, task); } }; diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts index 0f863ee44b0..be6b0f83e82 100644 --- a/packages/qwik/src/server/ssr-container.ts +++ b/packages/qwik/src/server/ssr-container.ts @@ -10,7 +10,6 @@ import { import { isDev } from '@qwik.dev/core/build'; import type { ResolvedManifest } from '@qwik.dev/core/optimizer'; import { - ChoreType, DEBUG_TYPE, ELEMENT_ID, ELEMENT_KEY, @@ -211,18 +210,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { // Temporary flag to find missing roots after the state was serialized private $noMoreRoots$ = false; constructor(opts: Required) { - super( - () => { - try { - return this.$scheduler$(ChoreType.WAIT_FOR_ALL); - } catch (e) { - this.handleError(e, null!); - } - }, - () => null, - opts.renderOptions.serverData ?? EMPTY_OBJ, - opts.locale - ); + super(() => null, opts.renderOptions.serverData ?? EMPTY_OBJ, opts.locale); this.symbolToChunkResolver = (symbol: string): string => { const idx = symbol.lastIndexOf('_'); const chunk = this.resolvedManifest.mapper[idx == -1 ? symbol : symbol.substring(idx + 1)]; @@ -248,7 +236,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { ensureProjectionResolved(_host: HostElement): void {} - handleError(err: any, _$host$: HostElement): void { + handleError(err: any, _$host$: null): void { throw err; } diff --git a/packages/qwik/src/server/ssr-render.ts b/packages/qwik/src/server/ssr-render.ts index 3f385815a27..a718d4ad455 100644 --- a/packages/qwik/src/server/ssr-render.ts +++ b/packages/qwik/src/server/ssr-render.ts @@ -1,5 +1,10 @@ import { getSymbolHash, setServerPlatform } from './platform'; -import { FLUSH_COMMENT, STREAM_BLOCK_END_COMMENT, STREAM_BLOCK_START_COMMENT } from './qwik-copy'; +import { + ChoreType, + FLUSH_COMMENT, + STREAM_BLOCK_END_COMMENT, + STREAM_BLOCK_START_COMMENT, +} from './qwik-copy'; import type { JSXOutput, ResolvedManifest, @@ -84,6 +89,7 @@ export const renderToStream = async ( await setServerPlatform(opts, resolvedManifest); await ssrContainer.render(jsx); + await ssrContainer.$scheduler$.schedule(ChoreType.WAIT_FOR_QUEUE).$returnValue$; // Flush remaining chunks in the buffer flush(); diff --git a/packages/qwik/src/testing/element-fixture.ts b/packages/qwik/src/testing/element-fixture.ts index 33267f018b4..03330b94bf8 100644 --- a/packages/qwik/src/testing/element-fixture.ts +++ b/packages/qwik/src/testing/element-fixture.ts @@ -1,4 +1,4 @@ -import { getDomContainer } from '@qwik.dev/core'; +import { getDomContainer, type ClientContainer } from '@qwik.dev/core'; import { vi } from 'vitest'; import { assertDefined } from '../core/shared/error/assert'; import type { QElement, QwikLoaderEventScope } from '../core/shared/types'; @@ -9,6 +9,7 @@ import { invokeApply, newInvokeContextFromTuple } from '../core/use/use-core'; import { createWindow } from './document'; import { getTestPlatform } from './platform'; import type { MockDocument, MockWindow } from './types'; +import { ChoreType } from '../core/shared/util-chore-type'; /** * Creates a simple DOM structure for testing components. @@ -89,10 +90,14 @@ export async function trigger( typeof queryOrElement === 'string' ? Array.from(root.querySelectorAll(queryOrElement)) : [queryOrElement]; + let container: ClientContainer | null = null; for (const element of elements) { if (!element) { continue; } + if (!container) { + container = getDomContainer(element as HTMLElement); + } let scope: QwikLoaderEventScope = ''; if (eventName.startsWith(':')) { @@ -112,7 +117,11 @@ export async function trigger( const attrName = prefix + fromCamelToKebabCase(eventName); await dispatch(element, attrName, event, scope); } + const waitForQueueChore = container?.$scheduler$.schedule(ChoreType.WAIT_FOR_QUEUE); await getTestPlatform().flush(); + if (waitForQueueChore) { + await waitForQueueChore.$returnValue$; + } } const PREVENT_DEFAULT = 'preventdefault:'; diff --git a/packages/qwik/src/testing/platform.ts b/packages/qwik/src/testing/platform.ts index 628473326a2..1654068f100 100644 --- a/packages/qwik/src/testing/platform.ts +++ b/packages/qwik/src/testing/platform.ts @@ -11,20 +11,8 @@ function createPlatform() { } let render: Queue | null = null; - let resolveNextTickImmediate = false; const moduleCache = new Map(); - const flushNextTick = async () => { - await Promise.resolve(); - if (render) { - try { - render.resolve(await render.fn()); - } catch (e) { - render.reject(e); - } - render = null; - } - }; const testPlatform: TestPlatform = { isServer: false, importSymbol(containerEl, url, symbolName) { @@ -63,10 +51,6 @@ function createPlatform() { render!.resolve = resolve; render!.reject = reject; }); - if (resolveNextTickImmediate) { - resolveNextTickImmediate = false; - await flushNextTick(); - } } else if (renderMarked !== render.fn) { // TODO(misko): proper error and test throw new Error( @@ -85,9 +69,14 @@ function createPlatform() { }); }, flush: async () => { - await flushNextTick(); - if (!render) { - resolveNextTickImmediate = true; + await Promise.resolve(); + if (render) { + try { + render.resolve(await render.fn()); + } catch (e) { + render.reject(e); + } + render = null; } }, chunkForSymbol() { diff --git a/packages/qwik/src/testing/rendering.unit-util.tsx b/packages/qwik/src/testing/rendering.unit-util.tsx index 7f60463523f..0d959c723ab 100644 --- a/packages/qwik/src/testing/rendering.unit-util.tsx +++ b/packages/qwik/src/testing/rendering.unit-util.tsx @@ -279,7 +279,7 @@ export async function rerenderComponent(element: HTMLElement, flush?: boolean) { const host = getHostVNode(vElement) as HostElement; const qrl = container.getHostProp>>(host, OnRenderProp)!; const props = container.getHostProp(host, ELEMENT_PROPS); - container.$scheduler$(ChoreType.COMPONENT, host, qrl, props); + container.$scheduler$.schedule(ChoreType.COMPONENT, host, qrl, props); if (flush) { // Note that this can deadlock await getTestPlatform().flush(); From 6d9f3cf8c17a26bc059b37d96e183fd2d2ddee50 Mon Sep 17 00:00:00 2001 From: Varixo Date: Wed, 2 Jul 2025 17:39:14 +0200 Subject: [PATCH 02/35] fix: schedule store effects --- packages/qwik/src/core/shared/scheduler.ts | 13 ++++++++++++- packages/qwik/src/core/use/use-resource.ts | 20 +++++++------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index 259a53a5920..37b5be13c48 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -102,7 +102,7 @@ import { vnode_diff } from '../client/vnode-diff'; import { AsyncComputedSignalImpl } from '../reactive-primitives/impl/async-computed-signal-impl'; import { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; import { SignalImpl } from '../reactive-primitives/impl/signal-impl'; -import type { StoreHandler } from '../reactive-primitives/impl/store'; +import { StoreHandler } from '../reactive-primitives/impl/store'; import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; import { isSignal, type Signal } from '../reactive-primitives/signal.public'; import type { NodePropPayload } from '../reactive-primitives/subscription-data'; @@ -745,6 +745,17 @@ export const createScheduler = (container: Container, journalFlush: () => void) return 1; } + // TODO: this is a hack to ensure that the effect chores are scheduled for the same target + if ( + a.$type$ === ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS && + b.$type$ === ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS && + a.$target$ instanceof StoreHandler && + b.$target$ instanceof StoreHandler && + a.$payload$ !== b.$payload$ + ) { + return 1; + } + // The chores are the same and will run only once return 0; } diff --git a/packages/qwik/src/core/use/use-resource.ts b/packages/qwik/src/core/use/use-resource.ts index 8cf6a194320..b94bcec0e11 100644 --- a/packages/qwik/src/core/use/use-resource.ts +++ b/packages/qwik/src/core/use/use-resource.ts @@ -185,10 +185,10 @@ export const Resource = (props: ResourceProps): JSXOutput => { function getResourceValueAsPromise(props: ResourceProps): Promise | JSXOutput { const resource = props.value as ResourceReturnInternal | Promise | Signal; if (isResourceReturn(resource)) { + // create a subscription for the resource._state changes + const state = resource._state; const isBrowser = !isServerPlatform(); if (isBrowser) { - // create a subscription for the resource._state changes - const state = resource._state; DEBUG && debugLog(`RESOURCE_CMP.${state}`, 'VALUE: ' + untrack(() => resource._resolved)); if (state === 'pending' && props.onPending) { @@ -203,16 +203,10 @@ function getResourceValueAsPromise(props: ResourceProps): Promise resource.value).then( + useBindInvokeContext(props.onResolved), + useBindInvokeContext(props.onRejected) + ); } else if (isPromise(resource)) { return resource.then( useBindInvokeContext(props.onResolved), @@ -350,7 +344,7 @@ export const runResource = ( }); const promise: ValueOrPromise = safeCall( - () => Promise.resolve(taskFn(opts)), + () => taskFn(opts), (value) => { setState(true, value); }, From 46808d1ef1592a73fd38771d400af8e182a0d12a Mon Sep 17 00:00:00 2001 From: Varixo Date: Wed, 2 Jul 2025 18:41:14 +0200 Subject: [PATCH 03/35] fix test to be correct --- packages/qwik/src/core/shared/scheduler.ts | 11 ++++++++--- packages/qwik/src/core/shared/scheduler.unit.tsx | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index 37b5be13c48..cad9bfd1c6e 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -150,7 +150,6 @@ enum ChoreState { RUNNING = 1, FAILED = 2, DONE = 3, - BLOCKED = 4, } type ChoreReturnValue = T extends @@ -194,6 +193,7 @@ export const getChorePromise = (chore: Chore) => export const createScheduler = (container: Container, journalFlush: () => void) => { const choreQueue: Chore[] = []; + const blockedChores = new Set(); let drainChore: Chore | null = null; let drainScheduled = false; @@ -315,6 +315,7 @@ export const createScheduler = (container: Container, journalFlush: () => void) } let blocked = false; + // TODO: find chores in blockedChores if ( chore.$type$ === ChoreType.RUN_QRL || chore.$type$ === ChoreType.TASK || @@ -372,7 +373,10 @@ export const createScheduler = (container: Container, journalFlush: () => void) } } } - chore.$state$ = blocked ? ChoreState.BLOCKED : ChoreState.NONE; + if (blocked) { + blockedChores.add(chore); + return chore; + } chore = sortedInsert( choreQueue, chore, @@ -431,7 +435,8 @@ export const createScheduler = (container: Container, journalFlush: () => void) const scheduleBlockedChoresAndDrainIfNeeded = (chore: Chore) => { if (chore.$blockedChores$) { for (const blockedChore of chore.$blockedChores$) { - blockedChore.$state$ = ChoreState.NONE; + blockedChores.delete(blockedChore); + sortedInsert(choreQueue, blockedChore, (container as DomContainer).rootVNode || null); } chore.$blockedChores$ = null; } diff --git a/packages/qwik/src/core/shared/scheduler.unit.tsx b/packages/qwik/src/core/shared/scheduler.unit.tsx index 981da1dc30c..279745b697f 100644 --- a/packages/qwik/src/core/shared/scheduler.unit.tsx +++ b/packages/qwik/src/core/shared/scheduler.unit.tsx @@ -186,6 +186,7 @@ describe('scheduler', () => { expect(testLog).toEqual([ // component + component vnode-diff 'component', + 'vnode-diff', // vnode-diff chore 'vnode-diff', 'journalFlush', From e611c0d0c332c4b26a5d4cfca24ada94901e8ac9 Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 19 Jul 2025 19:14:21 +0200 Subject: [PATCH 04/35] fix: ref test --- packages/qwik/src/core/tests/ref.spec.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/qwik/src/core/tests/ref.spec.tsx b/packages/qwik/src/core/tests/ref.spec.tsx index 6136c43948e..cf6fc655783 100644 --- a/packages/qwik/src/core/tests/ref.spec.tsx +++ b/packages/qwik/src/core/tests/ref.spec.tsx @@ -7,7 +7,6 @@ import { useContextProvider, useSignal, useStore, - useTask$, useVisibleTask$, } from '@qwik.dev/core'; import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; @@ -241,7 +240,7 @@ describe.each([ const element = useSignal(); const signal = useSignal(0); - useTask$(({ track }) => { + useVisibleTask$(({ track }) => { track(element); signal.value++; }); @@ -254,13 +253,16 @@ describe.each([ ); }); - const { vNode } = await render(, { debug }); + const { vNode, document } = await render(, { debug }); + if (render === ssrRenderToDom) { + await trigger(document.body, 'div', 'qvisible'); + } expect(vNode).toMatchVDOM(
Test
- 1 + 1
); From 90a80837f5f56671c08c03ab6752566d9900ce99 Mon Sep 17 00:00:00 2001 From: Varixo Date: Sun, 20 Jul 2025 17:51:51 +0200 Subject: [PATCH 05/35] WIP: render done promise # Conflicts: # packages/qwik-router/src/runtime/src/qwik-router-component.tsx --- .../src/runtime/src/link-component.tsx | 4 +-- .../src/runtime/src/qwik-router-component.tsx | 28 +++++++++---------- .../runtime/src/router-outlet-component.tsx | 21 ++++++++++---- .../qwik/src/core/client/dom-container.ts | 20 ++----------- packages/qwik/src/core/client/types.ts | 1 + packages/qwik/src/core/client/vnode-diff.ts | 19 ++++++++----- packages/qwik/src/core/qwik.core.api.md | 6 ++-- packages/qwik/src/core/shared/scheduler.ts | 6 ++-- packages/qwik/src/core/use/use-core.ts | 14 +++++++--- 9 files changed, 64 insertions(+), 55 deletions(-) diff --git a/packages/qwik-router/src/runtime/src/link-component.tsx b/packages/qwik-router/src/runtime/src/link-component.tsx index 7995a26c453..3097406cdee 100644 --- a/packages/qwik-router/src/runtime/src/link-component.tsx +++ b/packages/qwik-router/src/runtime/src/link-component.tsx @@ -70,12 +70,12 @@ export const Link = component$((props) => { }) : undefined; const handleClick = clientNavPath - ? $(async (event: Event, elm: HTMLAnchorElement) => { + ? $((event: Event, elm: HTMLAnchorElement) => { if (event.defaultPrevented) { // If default was prevented, than it is up to us to make client side navigation. if (elm.href) { elm.setAttribute('aria-pressed', 'true'); - await nav(elm.href, { forceReload: reload, replaceState, scroll }); + nav(elm.href, { forceReload: reload, replaceState, scroll }); elm.removeAttribute('aria-pressed'); } } diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index f2df27ced37..c35bc119fac 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -20,8 +20,8 @@ import { _getContextContainer, _getContextElement, _getQContainerElement, - _UNINITIALIZED, _waitUntilRendered, + _UNINITIALIZED, SerializerSymbol, type _ElementVNode, type AsyncComputedReadonlySignal, @@ -71,7 +71,7 @@ import type { import { loadClientData } from './use-endpoint'; import { useQwikRouterEnv } from './use-functions'; import { createLoaderSignal, isSameOrigin, isSamePath, toUrl } from './utils'; -import { startViewTransition } from './view-transition'; +// import { startViewTransition } from './view-transition'; /** * @deprecated Use `QWIK_ROUTER_SCROLLER` instead (will be removed in V3) @@ -723,18 +723,18 @@ export const useQwikRouter = (props?: QwikRouterProps) => { }; const _waitNextPage = () => { - if (isServer || props?.viewTransition === false) { - return navigate(); - } else { - const viewTransition = startViewTransition({ - update: navigate, - types: ['qwik-navigation'], - }); - if (!viewTransition) { - return Promise.resolve(); - } - return viewTransition.ready; - } + // if (isServer || props?.viewTransition === false) { + return navigate(); + // } else { + // const viewTransition = startViewTransition({ + // update: navigate, + // types: ['qwik-navigation'], + // }); + // if (!viewTransition) { + // return Promise.resolve(); + // } + // return viewTransition.ready; + // } }; _waitNextPage().then(() => { const container = _getQContainerElement(elm as _ElementVNode)!; diff --git a/packages/qwik-router/src/runtime/src/router-outlet-component.tsx b/packages/qwik-router/src/runtime/src/router-outlet-component.tsx index 9f9a0977f47..2d381503ac4 100644 --- a/packages/qwik-router/src/runtime/src/router-outlet-component.tsx +++ b/packages/qwik-router/src/runtime/src/router-outlet-component.tsx @@ -6,7 +6,9 @@ import { sync$, useContext, useServerData, + useVisibleTask$, } from '@qwik.dev/core'; +import { _getContextElement, _getDomContainer } from '@qwik.dev/core/internal'; import { ContentInternalContext } from './contexts'; import type { ClientSPAWindow } from './qwik-router-component'; @@ -20,13 +22,22 @@ export const RouterOutlet = component$(() => { throw new Error('PrefetchServiceWorker component must be rendered on the server.'); } - const { value } = useContext(ContentInternalContext); - if (value && value.length > 0) { - const contentsLen = value.length; + const internalContext = useContext(ContentInternalContext); + + useVisibleTask$(({ track }) => { + track(internalContext); + const element = _getContextElement(); + _getDomContainer(element as Element).resolveRenderDone?.(); + }); + + const contents = internalContext.value; + + if (contents && contents.length > 0) { + const contentsLen = contents.length; let cmp: JSXNode | null = null; for (let i = contentsLen - 1; i >= 0; i--) { - if (value[i].default) { - cmp = jsx(value[i].default as any, { + if (contents[i].default) { + cmp = jsx(contents[i].default as any, { children: cmp, }); } diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index 2849f558185..3d9fa0d9afb 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -3,7 +3,7 @@ import { assertTrue } from '../shared/error/assert'; import { QError, qError } from '../shared/error/error'; import { ERROR_CONTEXT, isRecoverable } from '../shared/error/error-handling'; -import { emitEvent, type QRLInternal } from '../shared/qrl/qrl-class'; +import { type QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; import { ChoreType } from '../shared/util-chore-type'; import { _SharedContainer } from '../shared/shared-container'; @@ -112,6 +112,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { public document: QDocument; public $journal$: VNodeJournal; public renderDone: Promise | null = null; + public resolveRenderDone: (() => void) | null = null; public $rawStateData$: unknown[]; public $storeProxyMap$: ObjToProxyMap = new WeakMap(); public $qFuncs$: Array<(...args: unknown[]) => unknown>; @@ -122,7 +123,6 @@ export class DomContainer extends _SharedContainer implements IClientContainer { private $stateData$: unknown[]; private $styleIds$: Set | null = null; - private $renderCount$ = 0; constructor(element: ContainerElement) { super(() => vnode_applyJournal(this.$journal$), {}, element.getAttribute(QLocaleAttr)!); @@ -272,22 +272,6 @@ export class DomContainer extends _SharedContainer implements IClientContainer { return vnode_getProp(vNode, name, getObjectById); } - // TODO: call this in _wait - scheduleRender() { - if (!this.renderDone) { - const chore = this.$scheduler$.schedule(ChoreType.WAIT_FOR_QUEUE); - this.renderDone = chore.$returnValue$!.finally(() => { - this.$renderCount$++; - this.renderDone = null; - emitEvent('qrender', { - instanceHash: this.$instanceHash$, - renderCount: this.$renderCount$, - }); - }); - } - return this.renderDone; - } - ensureProjectionResolved(vNode: VirtualVNode): void { if ((vNode[VNodeProps.flags] & VNodeFlags.Resolved) === 0) { vNode[VNodeProps.flags] |= VNodeFlags.Resolved; diff --git a/packages/qwik/src/core/client/types.ts b/packages/qwik/src/core/client/types.ts index b0b14d3097e..37511982ca0 100644 --- a/packages/qwik/src/core/client/types.ts +++ b/packages/qwik/src/core/client/types.ts @@ -18,6 +18,7 @@ export interface ClientContainer extends Container { rootVNode: ElementVNode; $journal$: VNodeJournal; renderDone: Promise | null; + resolveRenderDone: (() => void) | null; $forwardRefs$: Array | null; $initialQRLsIndexes$: Array | null; parseQRL(qrl: string): QRL; diff --git a/packages/qwik/src/core/client/vnode-diff.ts b/packages/qwik/src/core/client/vnode-diff.ts index b43af0ff745..6af7bf3a413 100644 --- a/packages/qwik/src/core/client/vnode-diff.ts +++ b/packages/qwik/src/core/client/vnode-diff.ts @@ -105,6 +105,7 @@ import { EffectProperty } from '../reactive-primitives/types'; import { SubscriptionData } from '../reactive-primitives/subscription-data'; import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; import { _CONST_PROPS, _VAR_PROPS } from '../internal'; +import { isSyncQrl } from '../shared/qrl/qrl-utils'; export const vnode_diff = ( container: ClientContainer, @@ -774,13 +775,17 @@ export const vnode_diff = ( let returnValue = false; qrls.flat(2).forEach((qrl) => { if (qrl) { - const value = container.$scheduler$.schedule( - ChoreType.RUN_QRL, - vNode, - qrl as QRLInternal<(...args: unknown[]) => unknown>, - [event, element] - ) as unknown; - returnValue = returnValue || value === true; + if (isSyncQrl(qrl)) { + qrl(event, element); + } else { + const value = container.$scheduler$.schedule( + ChoreType.RUN_QRL, + vNode, + qrl as QRLInternal<(...args: unknown[]) => unknown>, + [event, element] + ) as unknown; + returnValue = returnValue || value === true; + } } }); return returnValue; diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 1a71f820948..de16c0b41db 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -60,6 +60,8 @@ export interface ClientContainer extends Container { // (undocumented) renderDone: Promise | null; // (undocumented) + resolveRenderDone: (() => void) | null; + // (undocumented) rootVNode: _ElementVNode; } @@ -260,9 +262,9 @@ class DomContainer extends _SharedContainer implements ClientContainer { // (undocumented) resolveContext(host: HostElement, contextId: ContextId): T | undefined; // (undocumented) - rootVNode: _ElementVNode; + resolveRenderDone: (() => void) | null; // (undocumented) - scheduleRender(): Promise; + rootVNode: _ElementVNode; // (undocumented) setContext(host: HostElement, context: ContextId, value: T): void; // (undocumented) diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index cad9bfd1c6e..158ef011a1c 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -854,10 +854,10 @@ function debugChoreToString(chore: Chore): string { function debugTrace(action: string, arg?: any | null, queue?: Chore[]) { const lines = ['===========================\nScheduler: ' + action]; - if (arg && !('$type$' in arg)) { - lines.push(' arg: ' + String(arg).replaceAll(/\n.*/gim, '')); - } else if (arg && !queue?.length) { + if (arg && '$type$' in arg) { lines.push(' arg: ' + debugChoreToString(arg)); + } else { + lines.push(' arg: ' + String(arg).replaceAll(/\n.*/gim, '')); } if (queue) { queue.forEach((chore) => { diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index 98114a86874..22587bdf719 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -276,10 +276,16 @@ export const _jsxBranch = (input?: T) => { /** @internal */ export const _waitUntilRendered = (elm: Element) => { - const containerEl = _getQContainerElement(elm); - if (!containerEl) { + const container = (_getQContainerElement(elm) as ContainerElement | undefined)?.qContainer; + if (!container) { return Promise.resolve(); } - const container = (containerEl as ContainerElement).qContainer; - return container?.renderDone ?? Promise.resolve(); + + if (!container.renderDone) { + container.renderDone = new Promise((resolve) => { + container.resolveRenderDone = resolve; + }); + } + + return container.renderDone; }; From 699fdf1908bf10404ec85b7ebeb11140559230e5 Mon Sep 17 00:00:00 2001 From: Varixo Date: Mon, 21 Jul 2025 19:37:51 +0200 Subject: [PATCH 06/35] fix: use correct qinit event name --- .../src/core/shared/component-execution.ts | 4 +-- .../qwik/src/core/shared/utils/event-names.ts | 4 +-- .../src/core/tests/use-visible-task.spec.tsx | 32 ++++++++++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/qwik/src/core/shared/component-execution.ts b/packages/qwik/src/core/shared/component-execution.ts index 094648e1179..0b6ab8977bd 100644 --- a/packages/qwik/src/core/shared/component-execution.ts +++ b/packages/qwik/src/core/shared/component-execution.ts @@ -182,13 +182,13 @@ function addUseOnEvents( } if (targetElement) { if (targetElement.type === 'script' && key === qVisibleEvent) { - eventKey = 'document:onQinit$'; + eventKey = 'document:onQInit$'; logWarn( 'You are trying to add an event "' + key + '" using `useVisibleTask$` hook, ' + 'but a node to which you can add an event is not found. ' + - 'Using document:onQinit$ instead.' + 'Using document:onQInit$ instead.' ); } addUseOnEvent(targetElement, eventKey, useOnEvents[key]); diff --git a/packages/qwik/src/core/shared/utils/event-names.ts b/packages/qwik/src/core/shared/utils/event-names.ts index 5be22f365ab..bfaadd99839 100644 --- a/packages/qwik/src/core/shared/utils/event-names.ts +++ b/packages/qwik/src/core/shared/utils/event-names.ts @@ -78,12 +78,12 @@ export function htmlAttributeToJsxEvent(htmlAttr: string): string | null { if (isCaseSensitive && eventName !== DOMContentLoadedEvent) { prefix += '-'; // Add hyphen at the start if case-sensitive } - return eventNameToJsxEvent(eventName, prefix, idx); + return eventNameToJsxEvent(eventName, prefix); } return null; // Return null if not matching expected format } -export function eventNameToJsxEvent(eventName: string, prefix: string | null, startIdx = 0) { +export function eventNameToJsxEvent(eventName: string, prefix: string | null) { eventName = eventName.charAt(0).toUpperCase() + eventName.substring(1); return prefix + eventName + EVENT_SUFFIX; } diff --git a/packages/qwik/src/core/tests/use-visible-task.spec.tsx b/packages/qwik/src/core/tests/use-visible-task.spec.tsx index bb715b12c75..de941ea085e 100644 --- a/packages/qwik/src/core/tests/use-visible-task.spec.tsx +++ b/packages/qwik/src/core/tests/use-visible-task.spec.tsx @@ -367,7 +367,7 @@ describe.each([ }); }); - it('should add q:visible event if only script tag is present', async () => { + it('should add event if only script tag is present', async () => { (globalThis as any).counter = 0; const Cmp = component$(() => { useVisibleTask$(() => { @@ -405,6 +405,36 @@ describe.each([ (globalThis as any).counter = undefined; }); + it('should merge events if only script tag is present', async () => { + (globalThis as any).counter = 0; + const Cmp = component$(() => { + useVisibleTask$(() => { + (globalThis as any).counter++; + }); + return ( +