diff --git a/packages/docs/src/routes/api/qwik-testing/api.json b/packages/docs/src/routes/api/qwik-testing/api.json index 937d9a03847..518d477b50e 100644 --- a/packages/docs/src/routes/api/qwik-testing/api.json +++ b/packages/docs/src/routes/api/qwik-testing/api.json @@ -209,7 +209,7 @@ } ], "kind": "Function", - "content": "Trigger an event in unit tests on an element.\n\nFuture deprecation candidate.\n\n\n```typescript\nexport declare function trigger(root: Element, queryOrElement: string | Element | keyof HTMLElementTagNameMap | null, eventName: string, eventPayload?: any): Promise;\n```\n\n\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nroot\n\n\n\n\nElement\n\n\n\n\n\n
\n\nqueryOrElement\n\n\n\n\nstring \\| Element \\| keyof HTMLElementTagNameMap \\| null\n\n\n\n\n\n
\n\neventName\n\n\n\n\nstring\n\n\n\n\n\n
\n\neventPayload\n\n\n\n\nany\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nPromise<void>", + "content": "Trigger an event in unit tests on an element.\n\nFuture deprecation candidate.\n\n\n```typescript\nexport declare function trigger(root: Element, queryOrElement: string | Element | keyof HTMLElementTagNameMap | null, eventName: string, eventPayload?: any, options?: {\n waitForIdle?: boolean;\n}): Promise;\n```\n\n\n\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nroot\n\n\n\n\nElement\n\n\n\n\n\n
\n\nqueryOrElement\n\n\n\n\nstring \\| Element \\| keyof HTMLElementTagNameMap \\| null\n\n\n\n\n\n
\n\neventName\n\n\n\n\nstring\n\n\n\n\n\n
\n\neventPayload\n\n\n\n\nany\n\n\n\n\n_(Optional)_\n\n\n
\n\noptions\n\n\n\n\n{ waitForIdle?: boolean; }\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nPromise<void>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/testing/element-fixture.ts", "mdFile": "core.trigger.md" }, diff --git a/packages/docs/src/routes/api/qwik-testing/index.mdx b/packages/docs/src/routes/api/qwik-testing/index.mdx index fdcbddff51c..59e65ff31ee 100644 --- a/packages/docs/src/routes/api/qwik-testing/index.mdx +++ b/packages/docs/src/routes/api/qwik-testing/index.mdx @@ -507,6 +507,9 @@ export declare function trigger( queryOrElement: string | Element | keyof HTMLElementTagNameMap | null, eventName: string, eventPayload?: any, + options?: { + waitForIdle?: boolean; + }, ): Promise; ``` @@ -568,6 +571,19 @@ any _(Optional)_ + + + +options + + + +\{ waitForIdle?: boolean; } + + + +_(Optional)_ + diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index dbdf930d9bc..b7f177a7ed0 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -672,6 +672,20 @@ "content": "Use this to force running subscribers, for example when the calculated value mutates but remains the same object.\n\n\n```typescript\nforce(): void;\n```\n**Returns:**\n\nvoid", "mdFile": "core.computedsignal.force.md" }, + { + "name": "forceStoreEffects", + "id": "forcestoreeffects", + "hierarchy": [ + { + "name": "forceStoreEffects", + "id": "forcestoreeffects" + } + ], + "kind": "Function", + "content": "Force a store to recompute and schedule effects.\n\n\n```typescript\nforceStoreEffects: (value: StoreTarget, prop: keyof StoreTarget) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nvalue\n\n\n\n\nStoreTarget\n\n\n\n\n\n
\n\nprop\n\n\n\n\nkeyof StoreTarget\n\n\n\n\n\n
\n\n**Returns:**\n\nvoid", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/impl/store.ts", + "mdFile": "core.forcestoreeffects.md" + }, { "name": "Fragment", "id": "fragment", diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index 0fae10b649e..bbd1704c95f 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -1318,6 +1318,57 @@ force(): void; void +## forceStoreEffects + +Force a store to recompute and schedule effects. + +```typescript +forceStoreEffects: (value: StoreTarget, prop: keyof StoreTarget) => void +``` + + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +value + + + +StoreTarget + + + +
+ +prop + + + +keyof StoreTarget + + + +
+ +**Returns:** + +void + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/impl/store.ts) + ## Fragment ```typescript diff --git a/packages/qwik-router/src/buildtime/build-layout.unit.ts b/packages/qwik-router/src/buildtime/build-layout.unit.ts index 2fa80fc2995..c64e44ec5a1 100644 --- a/packages/qwik-router/src/buildtime/build-layout.unit.ts +++ b/packages/qwik-router/src/buildtime/build-layout.unit.ts @@ -4,7 +4,7 @@ const test = testAppSuite('Build Layout'); test('total layouts', ({ ctx: { layouts } }) => { // $ find starters/apps/qwikrouter-test/src/routes -name layout*tsx | wc -l - assert.equal(layouts.length, 12, JSON.stringify(layouts, null, 2)); + assert.equal(layouts.length, 13, JSON.stringify(layouts, null, 2)); }); test('nested named layout', ({ assertLayout }) => { diff --git a/packages/qwik-router/src/runtime/src/link-component.tsx b/packages/qwik-router/src/runtime/src/link-component.tsx index 7995a26c453..9a60ef8c18c 100644 --- a/packages/qwik-router/src/runtime/src/link-component.tsx +++ b/packages/qwik-router/src/runtime/src/link-component.tsx @@ -70,13 +70,14 @@ 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 }); - elm.removeAttribute('aria-pressed'); + nav(elm.href, { forceReload: reload, replaceState, scroll }).then(() => { + 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..a5afed2e899 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -20,12 +20,14 @@ import { _getContextContainer, _getContextElement, _getQContainerElement, - _UNINITIALIZED, _waitUntilRendered, + _UNINITIALIZED, SerializerSymbol, type _ElementVNode, type AsyncComputedReadonlySignal, type SerializationStrategy, + forceStoreEffects, + _hasStoreEffects, } from '@qwik.dev/core/internal'; import { clientNavigate } from './client-navigate'; import { CLIENT_DATA_CACHE, DEFAULT_LOADERS_SERIALIZATION_STRATEGY, Q_ROUTE } from './constants'; @@ -156,15 +158,13 @@ export const useQwikRouter = (props?: QwikRouterProps) => { } const url = new URL(urlEnv); - const routeLocation = useStore( - { - url, - params: env.params, - isNavigating: false, - prevUrl: undefined, - }, - { deep: false } - ); + const routeLocationTarget: MutableRouteLocation = { + url, + params: env.params, + isNavigating: false, + prevUrl: undefined, + }; + const routeLocation = useStore(routeLocationTarget, { deep: false }); const navResolver: { r?: () => void } = {}; const container = _getContextContainer(); const getSerializationStrategy = (loaderId: string): SerializationStrategy => { @@ -470,14 +470,30 @@ export const useQwikRouter = (props?: QwikRouterProps) => { if (navigation.dest.search && !!isSamePath(trackUrl, prevUrl)) { trackUrl.search = navigation.dest.search; } - + let shouldForcePrevUrl = false; + let shouldForceUrl = false; + let shouldForceParams = false; // Update route location if (!isSamePath(trackUrl, prevUrl)) { - routeLocation.prevUrl = prevUrl; + if (_hasStoreEffects(routeLocation, 'prevUrl')) { + shouldForcePrevUrl = true; + } + routeLocationTarget.prevUrl = prevUrl; + } + + if (routeLocationTarget.url !== trackUrl) { + if (_hasStoreEffects(routeLocation, 'url')) { + shouldForceUrl = true; + } + routeLocationTarget.url = trackUrl; } - routeLocation.url = trackUrl; - routeLocation.params = { ...params }; + if (routeLocationTarget.params !== params) { + if (_hasStoreEffects(routeLocation, 'params')) { + shouldForceParams = true; + } + routeLocationTarget.params = params; + } (routeInternal as any).untrackedValue = { type: navType, dest: trackUrl }; @@ -746,6 +762,15 @@ export const useQwikRouter = (props?: QwikRouterProps) => { callRestoreScrollOnDocument(); } + if (shouldForcePrevUrl) { + forceStoreEffects(routeLocation, 'prevUrl'); + } + if (shouldForceUrl) { + forceStoreEffects(routeLocation, 'url'); + } + if (shouldForceParams) { + forceStoreEffects(routeLocation, 'params'); + } routeLocation.isNavigating = false; navResolver.r?.(); }); 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..cae675e7dd3 100644 --- a/packages/qwik-router/src/runtime/src/router-outlet-component.tsx +++ b/packages/qwik-router/src/runtime/src/router-outlet-component.tsx @@ -7,6 +7,7 @@ import { useContext, useServerData, } 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 +21,16 @@ 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); + + 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 e30d1708fbd..ee1ae4a8cd8 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -3,8 +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 { getPlatform } from '../shared/platform/platform'; -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'; @@ -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 { @@ -60,15 +58,16 @@ import { import { VNodeJournalOpCode, vnode_applyJournal, - vnode_getDOMChildNodes, + vnode_createErrorDiv, vnode_getDomParent, + vnode_getNextSibling, vnode_getParent, vnode_getProp, vnode_getProps, vnode_insertBefore, + vnode_isElementVNode, vnode_isVirtualVNode, vnode_locate, - vnode_newElement, vnode_newUnMaterializedElement, vnode_setProp, type VNodeJournal, @@ -113,7 +112,6 @@ export class DomContainer extends _SharedContainer implements IClientContainer { public rootVNode: ElementVNode; public document: QDocument; public $journal$: VNodeJournal; - public renderDone: Promise | null = null; public $rawStateData$: unknown[]; public $storeProxyMap$: ObjToProxyMap = new WeakMap(); public $qFuncs$: Array<(...args: unknown[]) => unknown>; @@ -124,12 +122,13 @@ export class DomContainer extends _SharedContainer implements IClientContainer { private $stateData$: unknown[]; private $styleIds$: Set | null = null; - private $renderCount$ = 0; constructor(element: ContainerElement) { super( - () => this.scheduleRender(), - () => vnode_applyJournal(this.$journal$), + () => { + this.$flushEpoch$++; + vnode_applyJournal(this.$journal$); + }, {}, element.getAttribute(QLocaleAttr)! ); @@ -181,24 +180,19 @@ 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') { const vHost = host as VirtualVNode; - const errorDiv = document.createElement('errored-host'); - if (err && err instanceof Error) { - (errorDiv as any).props = { error: err }; - } - errorDiv.setAttribute('q:key', '_error_'); const journal: VNodeJournal = []; - - const vErrorDiv = vnode_newElement(errorDiv, 'errored-host'); - - vnode_getDOMChildNodes(journal, vHost, true).forEach((child) => { - vnode_insertBefore(journal, vErrorDiv, child, null); - }); - vnode_insertBefore(journal, vHost, vErrorDiv, null); + const vHostParent = vnode_getParent(vHost) as VirtualVNode | ElementVNode | undefined; + const vHostNextSibling = vnode_getNextSibling(vHost); + const vErrorDiv = vnode_createErrorDiv(document, vHost, err, journal); + // If the host is an element node, we need to insert the error div into its parent. + const insertHost = vnode_isElementVNode(vHost) ? vHostParent || vHost : vHost; + // If the host is different then we need to insert errored-host in the same position as the host. + const insertBefore = insertHost === vHost ? null : vHostNextSibling; + vnode_insertBefore(journal, insertHost, vErrorDiv, insertBefore); vnode_applyJournal(journal); } @@ -279,33 +273,6 @@ export class DomContainer extends _SharedContainer implements IClientContainer { return vnode_getProp(vNode, name, getObjectById); } - 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); - } - this.renderDone = null; - }); - } - if (renderCount !== this.$renderCount$) { - this.processChores(); - return; - } - this.renderDone = null; - } - ensureProjectionResolved(vNode: VirtualVNode): void { if ((vNode[VNodeProps.flags] & VNodeFlags.Resolved) === 0) { vNode[VNodeProps.flags] |= VNodeFlags.Resolved; diff --git a/packages/qwik/src/core/client/dom-render.ts b/packages/qwik/src/core/client/dom-render.ts index 890589931c8..b93a3a38e21 100644 --- a/packages/qwik/src/core/client/dom-render.ts +++ b/packages/qwik/src/core/client/dom-render.ts @@ -43,7 +43,7 @@ export const render = async ( 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); + await container.$scheduler$(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..94c641fa1cb 100644 --- a/packages/qwik/src/core/client/queue-qrl.ts +++ b/packages/qwik/src/core/client/queue-qrl.ts @@ -1,6 +1,8 @@ import { QError, qError } from '../shared/error/error'; import type { QRLInternal } from '../shared/qrl/qrl-class'; +import { getChorePromise } from '../shared/scheduler'; import { ChoreType } from '../shared/util-chore-type'; +import type { ValueOrPromise } from '../shared/utils/types'; import { getInvokeContext } from '../use/use-core'; import { useLexicalScope } from '../use/use-lexical-scope.public'; import { getDomContainer } from './dom-container'; @@ -10,7 +12,7 @@ import { getDomContainer } from './dom-container'; * * @internal */ -export const queueQRL = (...args: unknown[]) => { +export const _run = (...args: unknown[]): ValueOrPromise => { // This will already check container const [runQrl] = useLexicalScope<[QRLInternal<(...args: unknown[]) => unknown>]>(); const context = getInvokeContext(); @@ -28,5 +30,7 @@ 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 + const chore = scheduler(ChoreType.RUN_QRL, hostElement, runQrl, args); + return getChorePromise(chore); }; diff --git a/packages/qwik/src/core/client/types.ts b/packages/qwik/src/core/client/types.ts index b0b14d3097e..b73f25f5448 100644 --- a/packages/qwik/src/core/client/types.ts +++ b/packages/qwik/src/core/client/types.ts @@ -17,9 +17,9 @@ export interface ClientContainer extends Container { qManifestHash: string; rootVNode: ElementVNode; $journal$: VNodeJournal; - renderDone: Promise | null; $forwardRefs$: Array | null; $initialQRLsIndexes$: Array | null; + $flushEpoch$: number; parseQRL(qrl: string): QRL; $setRawState$(id: number, vParent: ElementVNode | VirtualVNode): void; } diff --git a/packages/qwik/src/core/client/vnode-diff.ts b/packages/qwik/src/core/client/vnode-diff.ts index a00c2992365..4ec65ee8458 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, @@ -182,6 +183,12 @@ export const vnode_diff = ( vNewNode = null; vCurrent = vnode_getFirstChild(vStartNode); stackPush(jsxNode, true); + + if (vParent[VNodeProps.flags] & VNodeFlags.Deleted) { + // Ignore diff if the parent is deleted. + return; + } + while (stack.length) { while (jsxIdx < jsxCount) { assertFalse(vParent === vCurrent, "Parent and current can't be the same"); @@ -774,13 +781,17 @@ export const vnode_diff = ( let returnValue = false; qrls.flat(2).forEach((qrl) => { if (qrl) { - const value = container.$scheduler$( - 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$( + 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/client/vnode-diff.unit.tsx b/packages/qwik/src/core/client/vnode-diff.unit.tsx index 7ecaa539567..0a7803af565 100644 --- a/packages/qwik/src/core/client/vnode-diff.unit.tsx +++ b/packages/qwik/src/core/client/vnode-diff.unit.tsx @@ -7,6 +7,7 @@ import type { QElement } from '../shared/types'; import { createSignal } from '../reactive-primitives/signal-api'; import { QError, qError } from '../shared/error/error'; import type { VirtualVNode } from './types'; +import { VNodeFlags, VNodeProps } from './types'; describe('vNode-diff', () => { it('should find no difference', () => { @@ -841,4 +842,18 @@ describe('vNode-diff', () => { }); }); }); + + describe('deleted parent', () => { + it('should ignore diff when parent is deleted', () => { + const { vNode, vParent, container } = vnode_fromJSX(
Hello
); + + vParent[VNodeProps.flags] |= VNodeFlags.Deleted; + + vnode_diff(container,
World
, vParent, null); + + expect(container.$journal$.length).toEqual(0); + + expect((vnode_getNode(vNode) as Element).outerHTML).toEqual('
Hello
'); + }); + }); }); diff --git a/packages/qwik/src/core/client/vnode.ts b/packages/qwik/src/core/client/vnode.ts index dd60321fdda..177a6d8680c 100644 --- a/packages/qwik/src/core/client/vnode.ts +++ b/packages/qwik/src/core/client/vnode.ts @@ -791,6 +791,26 @@ const indexOfAlphanumeric = (id: string, length: number): number => { return length; }; +export const vnode_createErrorDiv = ( + document: Document, + host: VNode, + err: Error, + journal: VNodeJournal +) => { + const errorDiv = document.createElement('errored-host'); + if (err && err instanceof Error) { + (errorDiv as any).props = { error: err }; + } + errorDiv.setAttribute('q:key', '_error_'); + + const vErrorDiv = vnode_newElement(errorDiv, 'errored-host'); + + vnode_getDOMChildNodes(journal, host, true).forEach((child) => { + vnode_insertBefore(journal, vErrorDiv, child, null); + }); + return vErrorDiv; +}; + ////////////////////////////////////////////////////////////////////////////////////////////////////// export const vnode_journalToString = (journal: VNodeJournal): string => { @@ -1072,31 +1092,36 @@ export const vnode_insertBefore = ( vnode_remove(journal, newChildCurrentParent, newChild, false); } - let adjustedInsertBefore: VNode | null = null; - if (insertBefore == null) { - if (vnode_isVirtualVNode(parent)) { - // If `insertBefore` is null, than we need to insert at the end of the list. - // Well, not quite. If the parent is a virtual node, our "last node" is not the same - // as the DOM "last node". So in that case we need to look for the "next node" from - // our parent. - adjustedInsertBefore = vnode_getDomSibling(parent, true, false); - } - } else if (vnode_isVirtualVNode(insertBefore)) { - // If the `insertBefore` is virtual, than we need to descend into the virtual and find e actual - adjustedInsertBefore = vnode_getDomSibling(insertBefore, true, true); - } else { - adjustedInsertBefore = insertBefore; - } - adjustedInsertBefore && vnode_ensureInflatedIfText(journal, adjustedInsertBefore); + const parentIsDeleted = parent[VNodeProps.flags] & VNodeFlags.Deleted; - // Here we know the insertBefore node - if (domChildren && domChildren.length) { - journal.push( - VNodeJournalOpCode.Insert, - parentNode, - vnode_getNode(adjustedInsertBefore), - ...domChildren - ); + // if the parent is deleted, then we don't need to insert the new child + if (!parentIsDeleted) { + let adjustedInsertBefore: VNode | null = null; + if (insertBefore == null) { + if (vnode_isVirtualVNode(parent)) { + // If `insertBefore` is null, than we need to insert at the end of the list. + // Well, not quite. If the parent is a virtual node, our "last node" is not the same + // as the DOM "last node". So in that case we need to look for the "next node" from + // our parent. + adjustedInsertBefore = vnode_getDomSibling(parent, true, false); + } + } else if (vnode_isVirtualVNode(insertBefore)) { + // If the `insertBefore` is virtual, than we need to descend into the virtual and find e actual + adjustedInsertBefore = vnode_getDomSibling(insertBefore, true, true); + } else { + adjustedInsertBefore = insertBefore; + } + adjustedInsertBefore && vnode_ensureInflatedIfText(journal, adjustedInsertBefore); + + // Here we know the insertBefore node + if (domChildren && domChildren.length) { + journal.push( + VNodeJournalOpCode.Insert, + parentNode, + vnode_getNode(adjustedInsertBefore), + ...domChildren + ); + } } // link newChild into the previous/next list @@ -1117,6 +1142,10 @@ export const vnode_insertBefore = ( newChild[VNodeProps.previousSibling] = vPrevious; newChild[VNodeProps.nextSibling] = vNext; newChild[VNodeProps.parent] = parent; + if (parentIsDeleted) { + // if the parent is deleted, then the new child is also deleted + newChild[VNodeProps.flags] |= VNodeFlags.Deleted; + } }; export const vnode_getDomParent = (vnode: VNode): Element | Text | null => { @@ -1721,6 +1750,17 @@ export const vnode_getParent = (vnode: VNode): VNode | null => { return vnode[VNodeProps.parent] || null; }; +export const vnode_isDescendantOf = (vnode: VNode, ancestor: VNode): boolean => { + let parent = vnode_getParent(vnode); + while (parent) { + if (parent === ancestor) { + return true; + } + parent = vnode_getParent(parent); + } + return false; +}; + export const vnode_getNode = (vnode: VNode | null): Element | Text | null => { if (vnode === null || vnode_isVirtualVNode(vnode)) { return null; diff --git a/packages/qwik/src/core/client/vnode.unit.tsx b/packages/qwik/src/core/client/vnode.unit.tsx index 0df34263269..065b3fc7a9d 100644 --- a/packages/qwik/src/core/client/vnode.unit.tsx +++ b/packages/qwik/src/core/client/vnode.unit.tsx @@ -3,13 +3,16 @@ import { createDocument } from '../../testing/document'; import { Fragment } from '@qwik.dev/core'; import '../../testing/vdom-diff.unit-util'; -import type { - ContainerElement, - ElementVNode, - QDocument, - TextVNode, - VNode, - VirtualVNode, +import { + ElementVNodeProps, + VNodeFlags, + VNodeProps, + type ContainerElement, + type ElementVNode, + type QDocument, + type TextVNode, + type VNode, + type VirtualVNode, } from './types'; import { vnode_applyJournal, @@ -26,6 +29,7 @@ import { vnode_remove, vnode_setAttr, vnode_setText, + vnode_walkVNode, type VNodeJournal, } from './vnode'; @@ -2774,4 +2778,410 @@ describe('vnode', () => { }); }); }); + + describe('parentIsDeleted logic', () => { + let parent: ContainerElement; + let document: QDocument; + let vParent: ElementVNode; + let journal: VNodeJournal; + let vChild1: ElementVNode; + let vChild2: ElementVNode; + let vVirtual: VirtualVNode; + + beforeEach(() => { + document = createDocument() as QDocument; + document.qVNodeData = new WeakMap(); + parent = document.createElement('test') as ContainerElement; + parent.qVNodeRefs = new Map(); + vParent = vnode_newUnMaterializedElement(parent); + journal = []; + + // Create test vnodes + vChild1 = vnode_newElement(document.createElement('div'), 'div'); + vChild2 = vnode_newElement(document.createElement('span'), 'span'); + vVirtual = vnode_newVirtual(); + }); + + afterEach(() => { + parent = null!; + document = null!; + vParent = null!; + vChild1 = null!; + vChild2 = null!; + vVirtual = null!; + }); + + describe('vnode_insertBefore with deleted parent', () => { + it('should skip DOM insertion when parent is deleted', () => { + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Try to insert child into deleted parent + vnode_insertBefore(journal, vParent, vChild1, null); + + // Verify child is linked into tree structure + expect(vParent[ElementVNodeProps.firstChild]).toBe(vChild1); + expect(vChild1[VNodeProps.parent]).toBe(vParent); + + // Verify child is marked as deleted (inherited from parent) + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Verify no DOM insertion journal entries + expect(journal.length).toBe(0); + }); + + it('should skip DOM insertion but maintain tree structure for virtual parent', () => { + // Mark virtual parent and its tree as deleted + markVNodeTreeAsDeleted(vVirtual); + + // Try to insert child into deleted virtual parent + vnode_insertBefore(journal, vVirtual, vChild1, null); + + // Verify child is linked into tree structure + expect(vVirtual[ElementVNodeProps.firstChild]).toBe(vChild1); + expect(vChild1[VNodeProps.parent]).toBe(vVirtual); + + // Verify child is marked as deleted (inherited from parent) + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Verify no DOM insertion journal entries + expect(journal.length).toBe(0); + }); + + it('should handle insertion between existing children when parent is deleted', () => { + // Set up existing children + vnode_insertBefore(journal, vParent, vChild1, null); + vnode_insertBefore(journal, vParent, vChild2, null); + + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + const journalOperations = journal.length; + + // Create new child to insert between existing ones + const vNewChild = vnode_newElement(document.createElement('p'), 'p'); + vnode_insertBefore(journal, vParent, vNewChild, vChild2); + + // Verify correct tree structure + expect(vParent[ElementVNodeProps.firstChild]).toBe(vChild1); + expect(vChild1[VNodeProps.nextSibling]).toBe(vNewChild); + expect(vNewChild[VNodeProps.previousSibling]).toBe(vChild1); + expect(vNewChild[VNodeProps.nextSibling]).toBe(vChild2); + expect(vChild2[VNodeProps.previousSibling]).toBe(vNewChild); + expect(vParent[ElementVNodeProps.lastChild]).toBe(vChild2); + + // Verify new child is marked as deleted (inherited from parent) + expect(vNewChild[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Verify no additional DOM insertion journal entries + expect(journal.length).toBe(journalOperations); + }); + + it('should handle insertion at end when parent is deleted', () => { + // Set up existing child + vnode_insertBefore(journal, vParent, vChild1, null); + + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Create new child to insert at end + const vNewChild = vnode_newElement(document.createElement('p'), 'p'); + vnode_insertBefore(journal, vParent, vNewChild, null); + + // Verify correct tree structure + expect(vParent[ElementVNodeProps.firstChild]).toBe(vChild1); + expect(vChild1[VNodeProps.nextSibling]).toBe(vNewChild); + expect(vNewChild[VNodeProps.previousSibling]).toBe(vChild1); + expect(vParent[ElementVNodeProps.lastChild]).toBe(vNewChild); + + // Verify new child is marked as deleted (inherited from parent) + expect(vNewChild[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + }); + + it('should handle insertion at beginning when parent is deleted', () => { + // Set up existing child + vnode_insertBefore(journal, vParent, vChild1, null); + + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Create new child to insert at beginning + const vNewChild = vnode_newElement(document.createElement('p'), 'p'); + vnode_insertBefore(journal, vParent, vNewChild, vChild1); + + // Verify correct tree structure + expect(vParent[ElementVNodeProps.firstChild]).toBe(vNewChild); + expect(vNewChild[VNodeProps.nextSibling]).toBe(vChild1); + expect(vChild1[VNodeProps.previousSibling]).toBe(vNewChild); + expect(vParent[ElementVNodeProps.lastChild]).toBe(vChild1); + + // Verify new child is marked as deleted (inherited from parent) + expect(vNewChild[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + }); + + it('should handle text node insertion when parent is deleted', () => { + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Create text vnode + const vText = vnode_newText(document.createTextNode('test'), 'test'); + vnode_insertBefore(journal, vParent, vText, null); + + // Verify text node is linked into tree structure + expect(vParent[ElementVNodeProps.firstChild]).toBe(vText); + expect(vText[VNodeProps.parent]).toBe(vParent); + + // Verify text node is marked as deleted (inherited from parent) + expect(vText[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Verify no DOM insertion journal entries + expect(journal.length).toBe(0); + }); + + it('should handle virtual node insertion when parent is deleted', () => { + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Create virtual vnode + const vNewVirtual = vnode_newVirtual(); + vnode_insertBefore(journal, vParent, vNewVirtual, null); + + // Verify virtual node is linked into tree structure + expect(vParent[ElementVNodeProps.firstChild]).toBe(vNewVirtual); + expect(vNewVirtual[VNodeProps.parent]).toBe(vParent); + + // Verify virtual node is marked as deleted (inherited from parent) + expect(vNewVirtual[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Verify no DOM insertion journal entries + expect(journal.length).toBe(0); + }); + + it('should handle complex nested structure when parent is deleted', () => { + // Create nested structure + vnode_insertBefore(journal, vParent, vChild1, null); + vnode_insertBefore(journal, vChild1, vVirtual, null); + vnode_insertBefore(journal, vVirtual, vChild2, null); + + // Mark parent and its entire tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Create new child to insert into the nested structure + const vNewChild = vnode_newElement(document.createElement('p'), 'p'); + vnode_insertBefore(journal, vVirtual, vNewChild, vChild2); + + // Verify correct tree structure + expect(vVirtual[ElementVNodeProps.firstChild]).toBe(vNewChild); + expect(vNewChild[VNodeProps.nextSibling]).toBe(vChild2); + expect(vChild2[VNodeProps.previousSibling]).toBe(vNewChild); + expect(vVirtual[ElementVNodeProps.lastChild]).toBe(vChild2); + + // Verify new child is marked as deleted (inherited from parent) + expect(vNewChild[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Verify existing children are also marked as deleted (from tree traversal) + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + expect(vVirtual[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + expect(vChild2[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + }); + + it('should handle insertion when parent becomes deleted during operation', () => { + // Set up initial structure + vnode_insertBefore(journal, vParent, vChild1, null); + + // Create new child + const vNewChild = vnode_newElement(document.createElement('p'), 'p'); + + // Mark parent and its tree as deleted AFTER initial setup but BEFORE new insertion + markVNodeTreeAsDeleted(vParent); + + const journalOperations = journal.length; + + // Try to insert new child + vnode_insertBefore(journal, vParent, vNewChild, vChild1); + + // Verify correct tree structure + expect(vParent[ElementVNodeProps.firstChild]).toBe(vNewChild); + expect(vNewChild[VNodeProps.nextSibling]).toBe(vChild1); + expect(vChild1[VNodeProps.previousSibling]).toBe(vNewChild); + expect(vParent[ElementVNodeProps.lastChild]).toBe(vChild1); + + // Verify new child is marked as deleted (inherited from parent) + expect(vNewChild[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Verify no DOM insertion journal entries + expect(journal.length).toBe(journalOperations); + }); + + it('should handle multiple insertions into deleted parent', () => { + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Insert multiple children + vnode_insertBefore(journal, vParent, vChild1, null); + vnode_insertBefore(journal, vParent, vChild2, null); + const vChild3 = vnode_newElement(document.createElement('p'), 'p'); + vnode_insertBefore(journal, vParent, vChild3, null); + + // Verify all children are linked correctly + expect(vParent[ElementVNodeProps.firstChild]).toBe(vChild1); + expect(vChild1[VNodeProps.nextSibling]).toBe(vChild2); + expect(vChild2[VNodeProps.nextSibling]).toBe(vChild3); + expect(vChild3[VNodeProps.previousSibling]).toBe(vChild2); + expect(vParent[ElementVNodeProps.lastChild]).toBe(vChild3); + + // Verify all children are marked as deleted (inherited from parent) + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + expect(vChild2[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + expect(vChild3[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Verify no DOM insertion journal entries + expect(journal.length).toBe(0); + }); + + it('should handle insertion with null insertBefore when parent is deleted', () => { + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Insert child with null insertBefore (insert at end) + vnode_insertBefore(journal, vParent, vChild1, null); + + // Verify child is linked correctly + expect(vParent[ElementVNodeProps.firstChild]).toBe(vChild1); + expect(vParent[ElementVNodeProps.lastChild]).toBe(vChild1); + expect(vChild1[VNodeProps.previousSibling]).toBe(null); + expect(vChild1[VNodeProps.nextSibling]).toBe(null); + + // Verify child is marked as deleted (inherited from parent) + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + }); + + it('should handle insertion with virtual insertBefore when parent is deleted', () => { + // Set up virtual node as insertBefore reference + vnode_insertBefore(journal, vParent, vVirtual, null); + vnode_insertBefore(journal, vParent, vChild1, null); + + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Insert new child before virtual node + const vNewChild = vnode_newElement(document.createElement('p'), 'p'); + vnode_insertBefore(journal, vParent, vNewChild, vVirtual); + + // Verify correct tree structure + expect(vParent[ElementVNodeProps.firstChild]).toBe(vNewChild); + expect(vNewChild[VNodeProps.nextSibling]).toBe(vVirtual); + expect(vVirtual[VNodeProps.previousSibling]).toBe(vNewChild); + expect(vVirtual[VNodeProps.nextSibling]).toBe(vChild1); + expect(vChild1[VNodeProps.previousSibling]).toBe(vVirtual); + expect(vParent[ElementVNodeProps.lastChild]).toBe(vChild1); + + // Verify new child is marked as deleted (inherited from parent) + expect(vNewChild[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + }); + }); + + describe('edge cases and error conditions', () => { + it('should handle insertion when child already has a parent', () => { + // Set up child with existing parent + vnode_insertBefore(journal, vParent, vChild1, null); + + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Try to insert child1 again (should unlink and relink) + vnode_insertBefore(journal, vParent, vChild1, null); + + // Verify child is still properly linked + expect(vParent[ElementVNodeProps.firstChild]).toBe(vChild1); + expect(vChild1[VNodeProps.parent]).toBe(vParent); + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + }); + + it('should handle insertion when insertBefore is the same as newChild', () => { + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Try to insert child before itself (invalid operation) + vnode_insertBefore(journal, vParent, vChild1, vChild1); + + // Should handle gracefully - child should be at the end + expect(vParent[ElementVNodeProps.firstChild]).toBe(vChild1); + expect(vParent[ElementVNodeProps.lastChild]).toBe(vChild1); + expect(vChild1[VNodeProps.previousSibling]).toBe(null); + expect(vChild1[VNodeProps.nextSibling]).toBe(null); + }); + + it('should handle insertion when parent is not deleted initially but becomes deleted', () => { + // Insert child normally first + vnode_insertBefore(journal, vParent, vChild1, null); + + // Verify child is not deleted initially + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(0); + + // Mark parent and its tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Insert another child + vnode_insertBefore(journal, vParent, vChild2, null); + + // Verify new child is marked as deleted (inherited from parent) + expect(vChild2[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Verify existing child is also marked as deleted (from tree traversal) + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + }); + + it('should demonstrate that marking parent as deleted affects the entire tree', () => { + // Set up existing children + vnode_insertBefore(journal, vParent, vChild1, null); + vnode_insertBefore(journal, vParent, vChild2, null); + + // Verify children are not deleted initially + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(0); + expect(vChild2[VNodeProps.flags] & VNodeFlags.Deleted).toBe(0); + + // Mark parent and its entire tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Verify existing children are now marked as deleted (from tree traversal) + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + expect(vChild2[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + + // Only new children inserted after parent is marked as deleted get the deleted flag + const vNewChild = vnode_newElement(document.createElement('p'), 'p'); + vnode_insertBefore(journal, vParent, vNewChild, null); + expect(vNewChild[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + }); + + it('should handle nested tree deletion correctly', () => { + // Create nested structure + vnode_insertBefore(journal, vParent, vChild1, null); + vnode_insertBefore(journal, vChild1, vVirtual, null); + vnode_insertBefore(journal, vVirtual, vChild2, null); + + // Verify no nodes are deleted initially + expect(vParent[VNodeProps.flags] & VNodeFlags.Deleted).toBe(0); + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(0); + expect(vVirtual[VNodeProps.flags] & VNodeFlags.Deleted).toBe(0); + expect(vChild2[VNodeProps.flags] & VNodeFlags.Deleted).toBe(0); + + // Mark parent and its entire tree as deleted + markVNodeTreeAsDeleted(vParent); + + // Verify all nodes in the tree are marked as deleted + expect(vParent[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + expect(vChild1[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + expect(vVirtual[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + expect(vChild2[VNodeProps.flags] & VNodeFlags.Deleted).toBe(VNodeFlags.Deleted); + }); + }); + }); }); + +function markVNodeTreeAsDeleted(vNode: VNode) { + // simulate the cleanup traversal from vnode_diff + vnode_walkVNode(vNode, (vChild) => { + vChild[VNodeProps.flags] |= VNodeFlags.Deleted; + }); +} diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index 7f65ca8ec20..f89906281d4 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -98,7 +98,7 @@ export type { SerializationStrategy } from './shared/types'; // use API ////////////////////////////////////////////////////////////////////////////////////////// export { useLexicalScope } from './use/use-lexical-scope.public'; -export { useStore, unwrapStore } from './use/use-store.public'; +export { useStore, unwrapStore, forceStoreEffects } from './use/use-store.public'; export { untrack } from './use/use-core'; export { useId } from './use/use-id'; export { useContext, useContextProvider, createContextId } from './use/use-context'; diff --git a/packages/qwik/src/core/internal.ts b/packages/qwik/src/core/internal.ts index 97d61dc1636..a5d5ca6df5d 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, @@ -37,6 +37,7 @@ export { export { _wrapProp, _wrapSignal } from './reactive-primitives/internal-api'; export { SubscriptionData as _SubscriptionData } from './reactive-primitives/subscription-data'; export { _EFFECT_BACK_REF } from './reactive-primitives/types'; +export { _hasStoreEffects } from './reactive-primitives/impl/store'; export { isStringifiable as _isStringifiable, type Stringifiable as _Stringifiable, diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index d59671e6980..5b868d59ff3 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; @@ -36,6 +35,8 @@ export type ClassList = string | undefined | null | false | Record | null; // (undocumented) @@ -59,8 +60,6 @@ export interface ClientContainer extends Container { // (undocumented) qManifestHash: string; // (undocumented) - renderDone: Promise | null; - // (undocumented) rootVNode: _ElementVNode; } @@ -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) @@ -257,14 +256,10 @@ class DomContainer extends _SharedContainer implements ClientContainer { // (undocumented) qManifestHash: string; // (undocumented) - renderDone: Promise | null; - // (undocumented) resolveContext(host: HostElement, contextId: ContextId): T | undefined; // (undocumented) rootVNode: _ElementVNode; // (undocumented) - scheduleRender(): Promise; - // (undocumented) setContext(host: HostElement, context: ContextId, value: T): void; // (undocumented) setHostProp(host: HostElement, name: string, value: T): void; @@ -330,6 +325,11 @@ export const eventQrl: (qrl: QRL) => QRL; // @internal (undocumented) export const _fnSignal: any>(fn: T, args: Parameters, fnStr?: string) => WrappedSignalImpl; +// Warning: (ae-forgotten-export) The symbol "StoreTarget" needs to be exported by the entry point index.d.ts +// +// @public +export const forceStoreEffects: (value: StoreTarget, prop: keyof StoreTarget) => void; + // @public (undocumented) export const Fragment: FunctionComponent<{ children?: any; @@ -381,6 +381,9 @@ function h, PROPS extends {} = {} export { h as createElement } export { h } +// @internal (undocumented) +export const _hasStoreEffects: (value: StoreTarget, prop: keyof StoreTarget) => boolean; + // Warning: (ae-forgotten-export) The symbol "HTMLAttributesBase" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "FilterBase" needs to be exported by the entry point index.d.ts // @@ -443,8 +446,6 @@ export interface ISsrComponentFrame { scopedStyleIds: Set; } -// Warning: (ae-forgotten-export) The symbol "StoreTarget" needs to be exported by the entry point index.d.ts -// // @internal (undocumented) export const _isStore: (value: StoreTarget) => boolean; @@ -903,7 +904,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[]) => ValueOrPromise; // @public (undocumented) export type SerializationStrategy = 'never' | 'always'; @@ -931,6 +932,8 @@ export abstract class _SharedContainer implements Container { // (undocumented) $currentUniqueId$: number; // (undocumented) + $flushEpoch$: number; + // (undocumented) readonly $getObjectById$: (id: number | string) => any; // (undocumented) $instanceHash$: string | null; @@ -946,7 +949,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 +957,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..a149e174554 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 @@ -1,15 +1,15 @@ import { qwikDebugToString } from '../../debug'; import type { Container } from '../../shared/types'; -import { ChoreType } from '../../shared/util-chore-type'; import { isPromise } from '../../shared/utils/promises'; import { cleanupFn, trackFn } from '../../use/utils/tracker'; import type { BackRef } from '../cleanup'; -import { AsyncComputeQRL, ComputedSignalFlags, EffectSubscription } from '../types'; +import { AsyncComputeQRL, SerializationSignalFlags, EffectSubscription } from '../types'; import { _EFFECT_BACK_REF, EffectProperty, NEEDS_COMPUTATION, SignalFlags } from '../types'; import { throwIfQRLNotResolved } from '../utils'; import { ComputedSignalImpl } from './computed-signal-impl'; import { setupSignalValueAccess } from './signal-impl'; import type { NoSerialize } from '../../shared/utils/serialize-utils'; +import { ChoreType } from '../../shared/util-chore-type'; const DEBUG = false; const log = (...args: any[]) => @@ -40,7 +40,7 @@ export class AsyncComputedSignalImpl constructor( container: Container | null, fn: AsyncComputeQRL, - flags: SignalFlags | ComputedSignalFlags = SignalFlags.INVALID + flags: SignalFlags | SerializationSignalFlags = SignalFlags.INVALID ) { super(container, fn, flags); } @@ -105,7 +105,7 @@ export class AsyncComputedSignalImpl $computeIfNeeded$() { if (!(this.$flags$ & SignalFlags.INVALID)) { - return false; + return; } const computeQrl = this.$computeQrl$; throwIfQRLNotResolved(computeQrl); @@ -140,6 +140,7 @@ export class AsyncComputedSignalImpl const didChange = untrackedValue !== this.$untrackedValue$; if (didChange) { + this.$flags$ |= SignalFlags.RUN_EFFECTS; this.$untrackedValue$ = untrackedValue; } return didChange; 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..be1f203ba47 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 @@ -8,7 +8,7 @@ import { tryGetInvokeContext } from '../../use/use-core'; import { throwIfQRLNotResolved } from '../utils'; import type { BackRef } from '../cleanup'; import { getSubscriber } from '../subscriber'; -import { ComputedSignalFlags, ComputeQRL, EffectSubscription } from '../types'; +import { SerializationSignalFlags, ComputeQRL, EffectSubscription } from '../types'; import { _EFFECT_BACK_REF, EffectProperty, NEEDS_COMPUTATION, SignalFlags } from '../types'; import { SignalImpl } from './signal-impl'; import type { QRLInternal } from '../../shared/qrl/qrl-class'; @@ -33,8 +33,7 @@ export class ComputedSignalImpl> * resolve the QRL during the mark dirty phase so that any call to it will be synchronous). ) */ $computeQrl$: S; - $flags$: SignalFlags | ComputedSignalFlags; - $forceRunEffects$: boolean = false; + $flags$: SignalFlags | SerializationSignalFlags; [_EFFECT_BACK_REF]: Map | null = null; constructor( @@ -42,8 +41,8 @@ export class ComputedSignalImpl> fn: S, // We need a separate flag to know when the computation needs running because // we need the old value to know if effects need running after computation - flags: SignalFlags | ComputedSignalFlags = SignalFlags.INVALID | - ComputedSignalFlags.SERIALIZATION_STRATEGY_ALWAYS + flags: SignalFlags | SerializationSignalFlags = SignalFlags.INVALID | + SerializationSignalFlags.SERIALIZATION_STRATEGY_ALWAYS ) { // The value is used for comparison when signals trigger, which can only happen // when it was calculated before. Therefore we can pass whatever we like. @@ -54,7 +53,6 @@ export class ComputedSignalImpl> invalidate() { this.$flags$ |= SignalFlags.INVALID; - this.$forceRunEffects$ = false; this.$container$?.$scheduler$( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, @@ -68,7 +66,7 @@ export class ComputedSignalImpl> * remained the same object */ force() { - this.$forceRunEffects$ = true; + this.$flags$ |= SignalFlags.RUN_EFFECTS; this.$container$?.$scheduler$( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, @@ -78,17 +76,14 @@ export class ComputedSignalImpl> } get untrackedValue() { - const didChange = this.$computeIfNeeded$(); - if (didChange) { - this.$forceRunEffects$ = didChange; - } + this.$computeIfNeeded$(); assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state'); return this.$untrackedValue$; } $computeIfNeeded$() { if (!(this.$flags$ & SignalFlags.INVALID)) { - return false; + return; } const computeQrl = this.$computeQrl$; throwIfQRLNotResolved(computeQrl); @@ -107,12 +102,14 @@ export class ComputedSignalImpl> DEBUG && log('Signal.$compute$', untrackedValue); this.$flags$ &= ~SignalFlags.INVALID; - const didChange = untrackedValue !== this.$untrackedValue$; if (didChange) { + // skip first computation when value is not changed + if (this.$untrackedValue$ !== NEEDS_COMPUTATION) { + this.$flags$ |= SignalFlags.RUN_EFFECTS; + } this.$untrackedValue$ = untrackedValue; } - return didChange; } finally { if (ctx) { ctx.$effectSubscriber$ = previousEffectSubscription; diff --git a/packages/qwik/src/core/reactive-primitives/impl/serializer-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/serializer-signal-impl.ts index 254fddb28be..265526b2fa6 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/serializer-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/serializer-signal-impl.ts @@ -5,7 +5,7 @@ import { trackSignal } from '../../use/use-core'; import { throwIfQRLNotResolved } from '../utils'; import type { SerializerArg } from '../types'; import { - ComputedSignalFlags, + SerializationSignalFlags, EffectProperty, NEEDS_COMPUTATION, SignalFlags, @@ -28,14 +28,14 @@ export class SerializerSignalImpl extends ComputedSignalImpl { super( container, argQrl as unknown as ComputeQRL, - SignalFlags.INVALID | ComputedSignalFlags.SERIALIZATION_STRATEGY_ALWAYS + SignalFlags.INVALID | SerializationSignalFlags.SERIALIZATION_STRATEGY_ALWAYS ); } $didInitialize$: boolean = false; - $computeIfNeeded$(): boolean { + $computeIfNeeded$() { if (!(this.$flags$ & SignalFlags.INVALID)) { - return false; + return; } throwIfQRLNotResolved(this.$computeQrl$); let arg = (this.$computeQrl$ as any as QRLInternal>).resolved!; @@ -62,8 +62,8 @@ export class SerializerSignalImpl extends ComputedSignalImpl { this.$flags$ &= ~SignalFlags.INVALID; this.$didInitialize$ = true; if (didChange) { + this.$flags$ |= SignalFlags.RUN_EFFECTS; this.$untrackedValue$ = untrackedValue as T; } - return didChange; } } 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..dd9f3fef70d 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$( + 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..9b9d74b8cfe 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$(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$(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..7d5729a01c0 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; @@ -31,6 +31,30 @@ export const getStoreTarget = (value: T): T | null => { return value?.[STORE_TARGET] || null; }; +/** + * Force a store to recompute and schedule effects. + * + * @public + */ +export const forceStoreEffects = (value: StoreTarget, prop: keyof StoreTarget): void => { + const handler = getStoreHandler(value); + if (handler) { + handler.force(prop); + } +}; + +/** + * @returns True if the store has effects for the given prop + * @internal + */ +export const _hasStoreEffects = (value: StoreTarget, prop: keyof StoreTarget): boolean => { + const handler = getStoreHandler(value); + if (handler) { + return (handler.$effects$?.get(prop)?.size ?? 0) > 0; + } + return false; +}; + /** * Get the original object that was wrapped by the store. Useful if you want to clone a store * (structuredClone, IndexedDB,...) @@ -82,7 +106,18 @@ export class StoreHandler implements ProxyHandler { return '[Store]'; } + force(prop: keyof StoreTarget): void { + const target = getStoreTarget(this)!; + this.$container$?.$scheduler$( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, + this, + getEffects(target, prop, this.$effects$) + ); + } + get(target: StoreTarget, prop: string | symbol) { + // TODO(perf): handle better `slice` calls if (typeof prop === 'symbol') { if (prop === STORE_TARGET) { return target; @@ -160,7 +195,16 @@ export class StoreHandler implements ProxyHandler { if (typeof prop != 'string' || !delete target[prop]) { return false; } - triggerEffects(this.$container$, this, getEffects(target, prop, this.$effects$)); + if (!Array.isArray(target)) { + // If the target is an array, we don't need to trigger effects. + // Changing the length property will trigger effects. + this.$container$?.$scheduler$( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, + this, + getEffects(target, prop, this.$effects$) + ); + } return true; } @@ -244,12 +288,15 @@ function setNewValueAndTriggerEffects>( currentStore: StoreHandler ): void { (target as any)[prop] = value; - // TODO: trigger effects through the scheduler - triggerEffects( - currentStore.$container$, - currentStore, - getEffects(target, prop, currentStore.$effects$) - ); + const effects = getEffects(target, prop, currentStore.$effects$); + if (effects) { + currentStore.$container$?.$scheduler$( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, + currentStore, + effects + ); + } } function getEffects>( 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..e4b1f2a292b 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$(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$(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..c74eec172b0 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 @@ -21,7 +21,6 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { $flags$: AllSignalFlags; $hostElement$: HostElement | null = null; - $forceRunEffects$: boolean = false; [_EFFECT_BACK_REF]: Map | null = null; constructor( @@ -42,7 +41,6 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { invalidate() { this.$flags$ |= SignalFlags.INVALID; - this.$forceRunEffects$ = false; this.$container$?.$scheduler$( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, this.$hostElement$, @@ -56,7 +54,7 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { * remained the same object. */ force() { - this.$forceRunEffects$ = true; + this.$flags$ |= SignalFlags.RUN_EFFECTS; this.$container$?.$scheduler$( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, this.$hostElement$, @@ -66,17 +64,14 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { } get untrackedValue() { - const didChange = this.$computeIfNeeded$(); - if (didChange) { - this.$forceRunEffects$ = didChange; - } + this.$computeIfNeeded$(); assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state'); return this.$untrackedValue$; } $computeIfNeeded$() { if (!(this.$flags$ & SignalFlags.INVALID)) { - return false; + return; } const untrackedValue = trackSignal( () => this.$func$(...this.$args$), @@ -84,13 +79,13 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { EffectProperty.VNODE, this.$container$! ); - // TODO: we should remove invalid flag here + // TODO: we should remove invalid flag here, but some tests are failing // this.$flags$ &= ~SignalFlags.INVALID; const didChange = untrackedValue !== this.$untrackedValue$; if (didChange) { + this.$flags$ |= SignalFlags.RUN_EFFECTS; this.$untrackedValue$ = untrackedValue; } - return didChange; } // Make this signal read-only set value(_: any) { diff --git a/packages/qwik/src/core/reactive-primitives/types.ts b/packages/qwik/src/core/reactive-primitives/types.ts index 46ba83715d6..33c0a3854ee 100644 --- a/packages/qwik/src/core/reactive-primitives/types.ts +++ b/packages/qwik/src/core/reactive-primitives/types.ts @@ -51,21 +51,22 @@ export interface ComputedOptions { export const enum SignalFlags { INVALID = 1, + RUN_EFFECTS = 2, } export const enum WrappedSignalFlags { // should subscribe to value and be unwrapped for PropsProxy - UNWRAP = 2, + UNWRAP = 4, } -export const enum ComputedSignalFlags { +export const enum SerializationSignalFlags { // TODO: implement this in the future - // SERIALIZATION_STRATEGY_AUTO = 4, - SERIALIZATION_STRATEGY_NEVER = 8, - SERIALIZATION_STRATEGY_ALWAYS = 16, + // SERIALIZATION_STRATEGY_AUTO = 8, + SERIALIZATION_STRATEGY_NEVER = 16, + SERIALIZATION_STRATEGY_ALWAYS = 32, } -export type AllSignalFlags = SignalFlags | WrappedSignalFlags | ComputedSignalFlags; +export type AllSignalFlags = SignalFlags | WrappedSignalFlags | SerializationSignalFlags; /** * Effect is something which needs to happen (side-effect) due to signal value change. diff --git a/packages/qwik/src/core/reactive-primitives/utils.ts b/packages/qwik/src/core/reactive-primitives/utils.ts index e0fc2351b2a..5caee1ea791 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'; @@ -18,7 +19,7 @@ import type { WrappedSignalImpl } from './impl/wrapped-signal-impl'; import type { Signal } from './signal.public'; import { SubscriptionData, type NodePropPayload } from './subscription-data'; import { - ComputedSignalFlags, + SerializationSignalFlags, EffectProperty, EffectSubscriptionProp, SignalFlags, @@ -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]; @@ -155,7 +156,7 @@ export const isSerializerObj = an export const getComputedSignalFlags = ( serializationStrategy: SerializationStrategy -): ComputedSignalFlags | SignalFlags => { +): SerializationSignalFlags | SignalFlags => { let flags = SignalFlags.INVALID; switch (serializationStrategy) { // TODO: implement this in the future @@ -163,10 +164,10 @@ export const getComputedSignalFlags = ( // flags |= ComputedSignalFlags.SERIALIZATION_STRATEGY_AUTO; // break; case 'never': - flags |= ComputedSignalFlags.SERIALIZATION_STRATEGY_NEVER; + flags |= SerializationSignalFlags.SERIALIZATION_STRATEGY_NEVER; break; case 'always': - flags |= ComputedSignalFlags.SERIALIZATION_STRATEGY_ALWAYS; + flags |= SerializationSignalFlags.SERIALIZATION_STRATEGY_ALWAYS; break; } return flags; 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/platform/next-tick.ts b/packages/qwik/src/core/shared/platform/next-tick.ts new file mode 100644 index 00000000000..d094aea45fc --- /dev/null +++ b/packages/qwik/src/core/shared/platform/next-tick.ts @@ -0,0 +1,27 @@ +// This can't be in platform.ts because it uses MessageChannel which cannot post messages with functions +// TODO: move this to platform.ts somehow +export const createNextTick = (fn: () => void) => { + let nextTick: () => void; + // according to the https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate#notes + if (typeof setImmediate === 'function') { + // setImmediate is the fastest way to schedule a task, but works only in node.js + nextTick = () => { + setImmediate(fn); + }; + } else if (typeof MessageChannel !== 'undefined') { + const channel = new MessageChannel(); + channel.port1.onmessage = () => { + fn(); + }; + nextTick = () => { + channel.port2.postMessage(null); + }; + } else { + // setTimeout is a fallback, creates 4ms delay + nextTick = () => { + setTimeout(fn); + }; + } + + return nextTick; +}; diff --git a/packages/qwik/src/core/shared/scheduler-document-position.spec.ts b/packages/qwik/src/core/shared/scheduler-document-position.unit.ts similarity index 100% rename from packages/qwik/src/core/shared/scheduler-document-position.spec.ts rename to packages/qwik/src/core/shared/scheduler-document-position.unit.ts diff --git a/packages/qwik/src/core/shared/scheduler-rules.ts b/packages/qwik/src/core/shared/scheduler-rules.ts new file mode 100644 index 00000000000..00556f09afe --- /dev/null +++ b/packages/qwik/src/core/shared/scheduler-rules.ts @@ -0,0 +1,220 @@ +import { vnode_isDescendantOf, vnode_isVNode } from '../client/vnode'; +import { Task, TaskFlags } from '../use/use-task'; +import type { QRLInternal } from './qrl/qrl-class'; +import type { Chore } from './scheduler'; +import type { Container, HostElement } from './types'; +import { ChoreType } from './util-chore-type'; +import { ELEMENT_SEQ } from './utils/markers'; +import { isNumber } from './utils/types'; + +type BlockingRule = { + blockedType: ChoreType; + blockingType: ChoreType; + match: (blocked: Chore, blocking: Chore, container: Container) => boolean; +}; + +/** + * Rules for determining if a chore is blocked by another chore. Some chores can block other chores. + * They cannot run until the blocking chore has completed. + * + * The match function is used to determine if the blocked chore is blocked by the blocking chore. + * The match function is called with the blocked chore, the blocking chore, and the container. + */ + +const VISIBLE_BLOCKING_RULES: BlockingRule[] = [ + // NODE_DIFF blocks VISIBLE on same host, + // if the blocked chore is a child of the blocking chore + // or the blocked chore is a sibling of the blocking chore + { + blockedType: ChoreType.VISIBLE, + blockingType: ChoreType.NODE_DIFF, + match: (blocked, blocking) => + isDescendant(blocked, blocking) || isDescendant(blocking, blocked), + }, + // COMPONENT blocks VISIBLE on same host + // if the blocked chore is a child of the blocking chore + // or the blocked chore is a sibling of the blocking chore + { + blockedType: ChoreType.VISIBLE, + blockingType: ChoreType.COMPONENT, + match: (blocked, blocking) => + isDescendant(blocked, blocking) || isDescendant(blocking, blocked), + }, +]; + +const BLOCKING_RULES: BlockingRule[] = [ + // QRL_RESOLVE blocks RUN_QRL, TASK, VISIBLE on same host + { + blockedType: ChoreType.RUN_QRL, + blockingType: ChoreType.QRL_RESOLVE, + match: (blocked, blocking) => { + const blockedQrl = blocked.$target$ as QRLInternal; + const blockingQrl = blocking.$target$ as QRLInternal; + return isSameHost(blocked, blocking) && isSameQrl(blockedQrl, blockingQrl); + }, + }, + { + blockedType: ChoreType.TASK, + blockingType: ChoreType.QRL_RESOLVE, + match: (blocked, blocking) => { + const blockedTask = blocked.$payload$ as Task; + const blockingQrl = blocking.$target$ as QRLInternal; + return isSameHost(blocked, blocking) && isSameQrl(blockedTask.$qrl$, blockingQrl); + }, + }, + { + blockedType: ChoreType.VISIBLE, + blockingType: ChoreType.QRL_RESOLVE, + match: (blocked, blocking) => { + const blockedTask = blocked.$payload$ as Task; + const blockingQrl = blocking.$target$ as QRLInternal; + return isSameHost(blocked, blocking) && isSameQrl(blockedTask.$qrl$, blockingQrl); + }, + }, + // COMPONENT blocks NODE_DIFF, NODE_PROP on same host + { + blockedType: ChoreType.NODE_DIFF, + blockingType: ChoreType.COMPONENT, + match: (blocked, blocking) => blocked.$host$ === blocking.$host$, + }, + { + blockedType: ChoreType.NODE_PROP, + blockingType: ChoreType.COMPONENT, + match: (blocked, blocking) => blocked.$host$ === blocking.$host$, + }, + ...VISIBLE_BLOCKING_RULES, + // TASK blocks subsequent TASKs in the same component + { + blockedType: ChoreType.TASK, + blockingType: ChoreType.TASK, + match: (blocked, blocking, container) => { + if (blocked.$host$ !== blocking.$host$) { + return false; + } + + const blockedIdx = blocked.$idx$ as number; + if (!isNumber(blockedIdx) || blockedIdx <= 0) { + return false; + } + const previousTask = findPreviousTaskInComponent(blocked.$host$, blockedIdx, container); + return previousTask === blocking.$payload$; + }, + }, +]; + +function isDescendant(descendantChore: Chore, ancestorChore: Chore): boolean { + const descendantHost = descendantChore.$host$; + const ancestorHost = ancestorChore.$host$; + if (!vnode_isVNode(descendantHost) || !vnode_isVNode(ancestorHost)) { + return false; + } + return vnode_isDescendantOf(descendantHost, ancestorHost); +} + +function isSameHost(a: Chore, b: Chore): boolean { + return a.$host$ === b.$host$; +} + +function isSameQrl(a: QRLInternal, b: QRLInternal): boolean { + return a.$symbol$ === b.$symbol$; +} + +function findBlockingChoreInQueue(chore: Chore, choreQueue: Chore[]): Chore | null { + for (const candidate of choreQueue) { + // everything after VISIBLE is not blocking. Visible task, task and resource should not block anything in this rule. + if (candidate.$type$ >= ChoreType.VISIBLE || candidate.$type$ === ChoreType.TASK) { + continue; + } + if (isDescendant(chore, candidate)) { + return candidate; + } + } + return null; +} + +export function findBlockingChore( + chore: Chore, + choreQueue: Chore[], + blockedChores: Set, + runningChores: Set, + container: Container +): Chore | null { + const blockingChoreInChoreQueue = findBlockingChoreInQueue(chore, choreQueue); + if (blockingChoreInChoreQueue) { + return blockingChoreInChoreQueue; + } + const blockingChoreInBlockedChores = findBlockingChoreInQueue(chore, Array.from(blockedChores)); + if (blockingChoreInBlockedChores) { + return blockingChoreInBlockedChores; + } + const blockingChoreInRunningChores = findBlockingChoreInQueue(chore, Array.from(runningChores)); + if (blockingChoreInRunningChores) { + return blockingChoreInRunningChores; + } + + for (const rule of BLOCKING_RULES) { + if (chore.$type$ !== rule.blockedType) { + continue; + } + + // Check in choreQueue + // TODO(perf): better to iterate in reverse order? + for (const candidate of choreQueue) { + if (candidate.$type$ === rule.blockingType && rule.match(chore, candidate, container)) { + return candidate; + } + } + // Check in blockedChores + for (const candidate of blockedChores) { + if (candidate.$type$ === rule.blockingType && rule.match(chore, candidate, container)) { + return candidate; + } + } + + // Check in runningChores + for (const candidate of runningChores) { + if (candidate.$type$ === rule.blockingType && rule.match(chore, candidate, container)) { + return candidate; + } + } + } + return null; +} + +function findPreviousTaskInComponent( + host: HostElement, + currentTaskIdx: number, + container: Container +): Task | null { + const elementSeq = container.getHostProp(host, ELEMENT_SEQ); + if (!elementSeq || elementSeq.length <= currentTaskIdx) { + return null; + } + + for (let i = currentTaskIdx - 1; i >= 0; i--) { + const candidate = elementSeq[i]; + if (candidate instanceof Task && candidate.$flags$ & TaskFlags.TASK) { + return candidate; + } + } + return null; +} + +export function findBlockingChoreForVisible( + chore: Chore, + runningChores: Set, + container: Container +): Chore | null { + for (const rule of VISIBLE_BLOCKING_RULES) { + if (chore.$type$ !== rule.blockedType) { + continue; + } + + for (const candidate of runningChores) { + if (candidate.$type$ === rule.blockingType && rule.match(chore, candidate, container)) { + return candidate; + } + } + } + return null; +} diff --git a/packages/qwik/src/core/shared/scheduler-rules.unit.tsx b/packages/qwik/src/core/shared/scheduler-rules.unit.tsx new file mode 100644 index 00000000000..93c586c90c4 --- /dev/null +++ b/packages/qwik/src/core/shared/scheduler-rules.unit.tsx @@ -0,0 +1,920 @@ +import { describe, it, expect, vi } from 'vitest'; +import { findBlockingChore, findBlockingChoreForVisible } from './scheduler-rules'; +import { ChoreType } from './util-chore-type'; +import { addBlockedChore, type Chore } from './scheduler'; +import { Task, TaskFlags } from '../use/use-task'; +import { ELEMENT_SEQ } from './utils/markers'; +import type { Container } from './types'; +import { VNodeProps } from '../client/types'; +import { vnode_newVirtual } from '../client/vnode'; +import { $, type QRL } from './qrl/qrl.public'; +import { createQRL } from './qrl/qrl-class'; + +const createMockChore = ( + type: ChoreType, + host: object, + idx: number | string = 0, + payload: any = null, + target: any = null +): Chore => ({ + $type$: type, + $host$: host as any, + $idx$: idx, + $payload$: payload, + $target$: target, + $state$: 0, + $blockedChores$: null, + $returnValue$: null, + $startTime$: undefined, + $endTime$: undefined, + $resolve$: undefined, + $reject$: undefined, +}); + +const createMockContainer = (elementSeqMap: Map) => + ({ + getHostProp: vi.fn((host, prop) => { + if (prop === ELEMENT_SEQ) { + return elementSeqMap.get(host) || null; + } + return null; + }), + }) as unknown as Container; + +function createMockTask( + host: object, + opts: { index?: number; qrl?: QRL; visible?: boolean } +): Task { + return new Task( + opts.visible ? TaskFlags.VISIBLE_TASK : TaskFlags.TASK, + opts.index || 0, + host as any, + opts.qrl || ($(() => null) as any), + null!, + null! + ); +} + +function createMockQRL(symbol: string): QRL { + return createQRL(null, symbol, null, null, null, null) as QRL; +} + +describe('findBlockingChore', () => { + const host1 = { el: 'host1' }; + const host2 = { el: 'host2' }; + + describe('QRL_RESOLVE blocking', () => { + const blockingChore = createMockChore( + ChoreType.QRL_RESOLVE, + host1, + 0, + null, + createMockQRL('qrl1') + ); + const container = createMockContainer(new Map()); + + it.each([ChoreType.RUN_QRL, ChoreType.TASK, ChoreType.VISIBLE])( + 'should block %s on the same host with the same qrl', + (blockedType) => { + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + let newChore; + if (blockedType === ChoreType.VISIBLE || blockedType === ChoreType.TASK) { + newChore = createMockChore( + blockedType, + host1, + 0, + createMockTask(host1, { + qrl: createMockQRL('qrl1'), + visible: blockedType === ChoreType.VISIBLE, + }) + ); + } else { + newChore = createMockChore(blockedType, host1, 0, null, createMockQRL('qrl1')); + } + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + } + ); + + it('should NOT block on a different host with the same qrl', () => { + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.RUN_QRL, host2, 0, null, createMockQRL('qrl1')); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + + it('should NOT block on a different host with a different qrl', () => { + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.RUN_QRL, host2, 0, null, createMockQRL('qrl2')); + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + + it('should NOT block on a the same host with a different qrl', () => { + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.RUN_QRL, host1, 0, null, createMockQRL('qrl2')); + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + + it('should find blocking chore in blockedChores set with the same qrl', () => { + const choreQueue: Chore[] = []; + const blockedChores = new Set([blockingChore]); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.RUN_QRL, host1, 0, null, createMockQRL('qrl1')); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + }); + + it('should find blocking chore in runningChores set with the same qrl', () => { + const choreQueue: Chore[] = []; + const blockedChores = new Set(); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.RUN_QRL, host1, 0, null, createMockQRL('qrl1')); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + }); + + it('should block VISIBLE on the same host with the same qrl', () => { + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore( + ChoreType.VISIBLE, + host1, + 0, + createMockTask(host1, { qrl: createMockQRL('qrl1'), visible: true }) + ); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + }); + }); + + describe('COMPONENT and NODE_DIFF blocking VISIBLE', () => { + const parentVNode = vnode_newVirtual(); + const childVNode = vnode_newVirtual(); + childVNode[VNodeProps.parent] = parentVNode; + const siblingVNode = vnode_newVirtual(); + siblingVNode[VNodeProps.parent] = parentVNode; + + const container = createMockContainer(new Map()); + + it.each([ChoreType.COMPONENT, ChoreType.NODE_DIFF])( + 'should block VISIBLE chore if it is a child of a %s chore', + (blockingType) => { + const blockingChore = createMockChore(blockingType, parentVNode); + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.VISIBLE, childVNode); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + } + ); + + it.each([ChoreType.COMPONENT, ChoreType.NODE_DIFF])( + 'should block VISIBLE chore if it is a parent of a %s chore', + (blockingType) => { + const blockingChore = createMockChore(blockingType, childVNode); + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + } + ); + + it.each([ChoreType.COMPONENT, ChoreType.NODE_DIFF])( + 'should NOT block VISIBLE chore if it is a sibling of a %s chore', + (blockingType) => { + const blockingChore = createMockChore(blockingType, siblingVNode); + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.VISIBLE, childVNode); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + } + ); + + it.each([ChoreType.COMPONENT, ChoreType.NODE_DIFF])( + 'should NOT block VISIBLE chore if it is on a different branch than a %s chore', + (blockingType) => { + const otherVNode = vnode_newVirtual(); + const blockingChore = createMockChore(blockingType, otherVNode); + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + } + ); + + it('should handle non-VNode hosts gracefully', () => { + const nonVNodeHost = { el: 'not-a-vnode' }; + const blockingChore = createMockChore(ChoreType.COMPONENT, nonVNodeHost as any); + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + }); + + describe('COMPONENT blocking', () => { + const blockingChore = createMockChore(ChoreType.COMPONENT, host1); + const container = createMockContainer(new Map()); + + it.each([ChoreType.NODE_DIFF, ChoreType.NODE_PROP])( + 'should block %s on the same host', + (blockedType) => { + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(blockedType, host1); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + } + ); + + it('should NOT block on a different host', () => { + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.NODE_DIFF, host2); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + + it('should find blocking chore in blockedChores set', () => { + const choreQueue: Chore[] = []; + const blockedChores = new Set([blockingChore]); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.NODE_DIFF, host1); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + }); + + it('should find blocking chore in runningChores set', () => { + const choreQueue: Chore[] = []; + const blockedChores = new Set(); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.NODE_DIFF, host1); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + }); + }); + + describe('TASK blocking', () => { + // Mock tasks and signals in a component's sequence + const task1 = new Task(TaskFlags.TASK, 0, host1 as any, {} as any, undefined, null); + const signal1 = { type: 'signal' }; // A non-task hook + const task2 = new Task(TaskFlags.TASK, 2, host1 as any, {} as any, undefined, null); + const task3_otherHost = new Task(TaskFlags.TASK, 0, host2 as any, {} as any, undefined, null); + + const blockingChore = createMockChore(ChoreType.TASK, host1, 0, task1); + const blockingChoreOtherHost = createMockChore(ChoreType.TASK, host2, 0, task3_otherHost); + + const elementSeqMap = new Map(); + elementSeqMap.set(host1, [task1, signal1, task2]); + elementSeqMap.set(host2, [task3_otherHost]); + + const container = createMockContainer(elementSeqMap); + + it('should block a subsequent TASK on the same host', () => { + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.TASK, host1, 2, task2); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + }); + + it('should NOT block a preceding TASK on the same host', () => { + const choreQueue = [createMockChore(ChoreType.TASK, host1, 2, task2)]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.TASK, host1, 0, task1); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + + it('should NOT block a TASK on a different host', () => { + const choreQueue = [blockingChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.TASK, host2, 0, task3_otherHost); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + + it('should find blocking TASK in blockedChores set', () => { + const choreQueue: Chore[] = []; + const blockedChores = new Set([blockingChore]); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.TASK, host1, 2, task2); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + }); + + it('should find blocking TASK in runningChores set', () => { + const choreQueue: Chore[] = []; + const blockedChores = new Set(); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.TASK, host1, 2, task2); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(blockingChore); + }); + + it('should not block if it is the first task (index 0)', () => { + const choreQueue = [blockingChoreOtherHost]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.TASK, host1, 0, task1); + + const result = findBlockingChore( + newChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + }); + + it('should return null if no blocking chore is found', () => { + const container = createMockContainer(new Map()); + const choreQueue = [createMockChore(ChoreType.COMPONENT, host1)]; + const blockedChores = new Set(); + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.RUN_QRL, host1); // Not blocked by COMPONENT + + const result = findBlockingChore(newChore, choreQueue, blockedChores, runningChores, container); + expect(result).toBeNull(); + }); + describe('Ancestor blocking', () => { + const parentVNode = vnode_newVirtual(); + const childVNode = vnode_newVirtual(); + childVNode[VNodeProps.parent] = parentVNode; + const unrelatedVNode = vnode_newVirtual(); + const container = createMockContainer(new Map()); + const ancestorChore = createMockChore(ChoreType.NODE_DIFF, parentVNode); + const descendantChore = createMockChore(ChoreType.VISIBLE, childVNode); + + it('should block if an ancestor is in the choreQueue', () => { + const choreQueue = [ancestorChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const result = findBlockingChore( + descendantChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(ancestorChore); + }); + + it('should block if an ancestor is in the blockedChores', () => { + const choreQueue: Chore[] = []; + const blockedChores = new Set([ancestorChore]); + const runningChores = new Set(); + const result = findBlockingChore( + descendantChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(ancestorChore); + }); + + it('should block if an ancestor is in the runningChores', () => { + const choreQueue: Chore[] = []; + const blockedChores = new Set(); + const runningChores = new Set([ancestorChore]); + const result = findBlockingChore( + descendantChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBe(ancestorChore); + }); + + it('should not block if candidate is a descendant, not ancestor', () => { + const choreQueue = [descendantChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const result = findBlockingChore( + ancestorChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + + it('should not block for unrelated chores', () => { + const unrelatedChore = createMockChore(ChoreType.NODE_DIFF, unrelatedVNode); + const choreQueue = [unrelatedChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const result = findBlockingChore( + descendantChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + + it('should not block if candidate chore type is VISIBLE or greater', () => { + const ancestorVisibleChore = createMockChore(ChoreType.VISIBLE, parentVNode); + const choreQueue = [ancestorVisibleChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const result = findBlockingChore( + descendantChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + + it('should not block if candidate chore type is TASK', () => { + const ancestorTaskChore = createMockChore(ChoreType.TASK, parentVNode); + const choreQueue = [ancestorTaskChore]; + const blockedChores = new Set(); + const runningChores = new Set(); + const result = findBlockingChore( + descendantChore, + choreQueue, + blockedChores, + runningChores, + container + ); + expect(result).toBeNull(); + }); + }); +}); + +describe('findBlockingChoreForVisible', () => { + const host1 = { el: 'host1' }; + const host2 = { el: 'host2' }; + const parentVNode = vnode_newVirtual(); + const childVNode = vnode_newVirtual(); + childVNode[VNodeProps.parent] = parentVNode; + const unrelatedVNode = vnode_newVirtual(); + + const container = createMockContainer(new Map()); + it.each([ + ['parent', ChoreType.NODE_DIFF, parentVNode, childVNode], + ['child', ChoreType.NODE_DIFF, childVNode, parentVNode], + ['parent', ChoreType.COMPONENT, parentVNode, childVNode], + ['child', ChoreType.COMPONENT, childVNode, parentVNode], + ])( + 'should block VISIBLE chore if %s is a %s of VISIBLE', + (_, blockingType, blockingHost, visibleHost) => { + const blockingChore = createMockChore(blockingType, blockingHost); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, visibleHost); + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBe(blockingChore); + } + ); + + it('should NOT block VISIBLE chore if NODE_DIFF is on a different branch', () => { + const blockingChore = createMockChore(ChoreType.NODE_DIFF, unrelatedVNode); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should handle non-VNode hosts gracefully for NODE_DIFF', () => { + const nonVNodeHost = { el: 'not-a-vnode' }; + const blockingChore = createMockChore(ChoreType.NODE_DIFF, nonVNodeHost as any); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should handle non-VNode hosts gracefully for VISIBLE', () => { + const blockingChore = createMockChore(ChoreType.NODE_DIFF, childVNode); + const runningChores = new Set([blockingChore]); + const nonVNodeHost = { el: 'not-a-vnode' }; + const newChore = createMockChore(ChoreType.VISIBLE, nonVNodeHost as any); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should NOT block VISIBLE chore if COMPONENT is on a different branch', () => { + const blockingChore = createMockChore(ChoreType.COMPONENT, unrelatedVNode); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should handle non-VNode hosts gracefully for COMPONENT', () => { + const nonVNodeHost = { el: 'not-a-vnode' }; + const blockingChore = createMockChore(ChoreType.COMPONENT, nonVNodeHost as any); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + describe('multiple blocking chores', () => { + it('should return the first matching blocking chore when multiple exist', () => { + const blockingChore1 = createMockChore(ChoreType.NODE_DIFF, parentVNode); + const blockingChore2 = createMockChore(ChoreType.COMPONENT, parentVNode); + const unrelatedChore = createMockChore(ChoreType.TASK, host1); + + const runningChores = new Set([unrelatedChore, blockingChore1, blockingChore2]); + const newChore = createMockChore(ChoreType.VISIBLE, childVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + // Should return the first one found in the iteration order + expect(result).toBeDefined(); + expect([blockingChore1, blockingChore2]).toContain(result); + }); + + it('should return null when no blocking chores exist', () => { + const unrelatedChore1 = createMockChore(ChoreType.TASK, host1); + const unrelatedChore2 = createMockChore(ChoreType.RUN_QRL, host2); + + const runningChores = new Set([unrelatedChore1, unrelatedChore2]); + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + }); + + describe('chore type filtering', () => { + it('should only check VISIBLE chores', () => { + const blockingChore = createMockChore(ChoreType.NODE_DIFF, childVNode); + const runningChores = new Set([blockingChore]); + + // Test with different chore types that should NOT be blocked + const nonVisibleChores = [ + createMockChore(ChoreType.TASK, parentVNode), + createMockChore(ChoreType.RUN_QRL, parentVNode), + createMockChore(ChoreType.NODE_DIFF, parentVNode), + createMockChore(ChoreType.NODE_PROP, parentVNode), + createMockChore(ChoreType.COMPONENT, parentVNode), + createMockChore(ChoreType.QRL_RESOLVE, parentVNode), + ]; + + nonVisibleChores.forEach((chore) => { + const result = findBlockingChoreForVisible(chore, runningChores, container); + expect(result).toBeNull(); + }); + }); + + it('should only consider NODE_DIFF and COMPONENT as blocking types', () => { + const runningChores = new Set([ + createMockChore(ChoreType.TASK, childVNode), + createMockChore(ChoreType.RUN_QRL, childVNode), + createMockChore(ChoreType.NODE_PROP, childVNode), + createMockChore(ChoreType.QRL_RESOLVE, childVNode), + ]); + + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + }); + + describe('deep hierarchy', () => { + const grandparentVNode = vnode_newVirtual(); + const parentVNode = vnode_newVirtual(); + const childVNode = vnode_newVirtual(); + const grandchildVNode = vnode_newVirtual(); + + parentVNode[VNodeProps.parent] = grandparentVNode; + childVNode[VNodeProps.parent] = parentVNode; + grandchildVNode[VNodeProps.parent] = childVNode; + + const container = createMockContainer(new Map()); + + it('should block VISIBLE at grandchild level when NODE_DIFF is at grandparent level', () => { + const blockingChore = createMockChore(ChoreType.NODE_DIFF, grandparentVNode); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, grandchildVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBe(blockingChore); + }); + + it('should block VISIBLE at child level when COMPONENT is at parent level', () => { + const blockingChore = createMockChore(ChoreType.COMPONENT, parentVNode); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, childVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBe(blockingChore); + }); + + it('should block VISIBLE at grandparent level when NODE_DIFF is at grandchild level', () => { + const blockingChore = createMockChore(ChoreType.NODE_DIFF, grandchildVNode); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, grandparentVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBe(blockingChore); + }); + + it('should block VISIBLE at grandparent level when COMPONENT is at grandchild level', () => { + const blockingChore = createMockChore(ChoreType.COMPONENT, grandchildVNode); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, grandparentVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBe(blockingChore); + }); + }); + + describe('edge cases', () => { + it('should handle empty runningChores set', () => { + const runningChores = new Set(); + const newChore = createMockChore(ChoreType.VISIBLE, host1); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should handle null/undefined hosts gracefully', () => { + const blockingChore = createMockChore(ChoreType.NODE_DIFF, null as any); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, host1); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should handle null/undefined hosts for VISIBLE chore', () => { + const blockingChore = createMockChore(ChoreType.NODE_DIFF, host1); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, null as any); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should NOT block VISIBLE when NODE_DIFF is on a different host', () => { + const blockingChore = createMockChore(ChoreType.NODE_DIFF, host2); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, host1); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should NOT block VISIBLE when COMPONENT is on a different host', () => { + const blockingChore = createMockChore(ChoreType.COMPONENT, host2); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, host1); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should handle undefined VNode parent gracefully', () => { + const parentVNode = vnode_newVirtual(); + const childVNode = vnode_newVirtual(); + // Don't set parent relationship + + const blockingChore = createMockChore(ChoreType.NODE_DIFF, childVNode); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, parentVNode); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBeNull(); + }); + + it('should handle circular parent references gracefully', () => { + const vnode1 = vnode_newVirtual(); + const vnode2 = vnode_newVirtual(); + vnode1[VNodeProps.parent] = vnode2; + vnode2[VNodeProps.parent] = vnode1; // Circular reference + + const blockingChore = createMockChore(ChoreType.NODE_DIFF, vnode2); + const runningChores = new Set([blockingChore]); + const newChore = createMockChore(ChoreType.VISIBLE, vnode1); + + const result = findBlockingChoreForVisible(newChore, runningChores, container); + expect(result).toBe(blockingChore); + }); + }); +}); + +describe('addBlockedChore', () => { + it('should add blocked chore to blocking chore and blockedChores set', () => { + const blockedChore = createMockChore(ChoreType.VISIBLE, { el: 'host1' }); + const blockingChore = createMockChore(ChoreType.NODE_DIFF, { el: 'host2' }); + const blockedChores = new Set(); + + addBlockedChore(blockedChore, blockingChore, blockedChores); + + expect(blockingChore.$blockedChores$).toContain(blockedChore); + expect(blockedChores.has(blockedChore)).toBe(true); + }); + + it('should initialize $blockedChores$ array if it does not exist', () => { + const blockedChore = createMockChore(ChoreType.VISIBLE, { el: 'host1' }); + const blockingChore = createMockChore(ChoreType.NODE_DIFF, { el: 'host2' }); + blockingChore.$blockedChores$ = null; + const blockedChores = new Set(); + + addBlockedChore(blockedChore, blockingChore, blockedChores); + + expect(blockingChore.$blockedChores$).toEqual([blockedChore]); + expect(blockedChores.has(blockedChore)).toBe(true); + }); + + it('should append to existing $blockedChores$ array', () => { + const blockedChore1 = createMockChore(ChoreType.VISIBLE, { el: 'host1' }); + const blockedChore2 = createMockChore(ChoreType.TASK, { el: 'host2' }); + const blockingChore = createMockChore(ChoreType.NODE_DIFF, { el: 'host3' }); + blockingChore.$blockedChores$ = [blockedChore1]; + const blockedChores = new Set([blockedChore1]); + + addBlockedChore(blockedChore2, blockingChore, blockedChores); + + expect(blockingChore.$blockedChores$).toEqual([blockedChore1, blockedChore2]); + expect(blockedChores.has(blockedChore1)).toBe(true); + expect(blockedChores.has(blockedChore2)).toBe(true); + }); +}); diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index 6e4fbf28b18..f0fc2a453c2 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -80,7 +80,7 @@ * declaration order within component. */ -import { isDomContainer, type DomContainer } from '../client/dom-container'; +import { type DomContainer } from '../client/dom-container'; import { ElementVNodeProps, VNodeFlags, @@ -91,14 +91,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 { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; +import { SignalImpl } from '../reactive-primitives/impl/signal-impl'; +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'; import { + SignalFlags, 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 +115,61 @@ 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 { 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 { 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 { type ValueOrPromise } from './utils/types'; import { invoke, newInvokeContext } from '../use/use-core'; +import { findBlockingChore, findBlockingChoreForVisible } from './scheduler-rules'; +import { createNextTick } from './platform/next-tick'; +import { AsyncComputedSignalImpl } from '../reactive-primitives/impl/async-computed-signal-impl'; // 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, +} + +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; - $resolve$?: (value: any) => void; - $promise$?: Promise; - $returnValue$: any; - $executed$: boolean; + $state$: ChoreState; + $blockedChores$: Chore[] | null; + $startTime$: number | undefined; + $endTime$: number | undefined; + + $resolve$: ((value: any) => void) | undefined; + $reject$: ((reason?: any) => void) | undefined; + $returnValue$: ValueOrPromise>; } export type Scheduler = ReturnType; @@ -155,21 +180,36 @@ 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 + journalFlush: () => void, + choreQueue: Chore[] = [], + blockedChores: Set = new Set(), + runningChores: Set = new Set() ) => { - const choreQueue: Chore[] = []; - const qrlRuns: Promise[] = []; - - let currentChore: Chore | null = null; - let drainScheduled: boolean = false; + let drainChore: Chore | null = null; + let drainScheduled = false; + let isDraining = false; + let isJournalFlushRunning = false; + const nextTick = createNextTick(drainChoreQueue); + + function drainInNextTick() { + if (!drainScheduled) { + drainScheduled = true; + nextTick(); + } + } + // 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; @@ -180,9 +220,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 +234,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 +291,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, + $startTime$: undefined, + $endTime$: undefined, + $resolve$: undefined, + $reject$: undefined, + $returnValue$: null!, }; - chore = sortedInsert(choreQueue, chore, (container as DomContainer).rootVNode || null); + if (type === ChoreType.WAIT_FOR_QUEUE) { + getChorePromise(chore); + drainChore = chore as Chore; + drainInNextTick(); + 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)}`, + chore, + choreQueue, + blockedChores + ); + // Mark skipped client-only chores as completed on the server + finishChore(chore, undefined); + return chore; } - // TODO figure out what to do with chore errors - if (runLater) { - return getPromise(chore); + + const blockingChore = findBlockingChore( + chore, + choreQueue, + blockedChores, + runningChores, + container + ); + if (blockingChore) { + addBlockedChore(chore, blockingChore, blockedChores); + return chore; + } + chore = sortedInsert( + choreQueue, + chore, + (container as DomContainer).rootVNode || null + ) as Chore; + DEBUG && debugTrace('schedule', chore, choreQueue, blockedChores); + + const runImmediately = (isServer && type === ChoreType.COMPONENT) || type === ChoreType.RUN_QRL; + + if (runImmediately && !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 shouldApplyJournalFlush = () => { + return !isServer && now - startTime > FREQUENCY_MS; + }; - const nextChore = choreQueue[0]; + const applyJournalFlush = () => { + if (!isJournalFlushRunning) { + // prevent multiple journal flushes from running at the same time + isJournalFlushRunning = true; + journalFlush(); + isJournalFlushRunning = false; + DEBUG && debugTrace('journalFlush.DONE', null, choreQueue, blockedChores); + } + }; - if (nextChore.$executed$) { - choreQueue.shift(); - if (nextChore === runUptoChore) { - break; + const maybeFinishDrain = () => { + if (choreQueue.length) { + return drainChoreQueue(); + } + if (!drainScheduled || (drainChore && runningChores.size)) { + 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, blockedChores); + }; - 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$) { + const blockingChore = findBlockingChore( + blockedChore, + choreQueue, + blockedChores, + runningChores, + container + ); + if (blockingChore) { + addBlockedChore(blockedChore, blockingChore, blockedChores); + } else { + blockedChores.delete(blockedChore); + sortedInsert(choreQueue, blockedChore, (container as DomContainer).rootVNode || null); + } + } + chore.$blockedChores$ = null; } + drainInNextTick(); + }; + + let currentChore: Chore | null = null; - executeChore(nextChore, isServer); + try { + while (choreQueue.length) { + now = performance.now(); + + 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, blockedChores); + continue; + } + + if (chore.$type$ === ChoreType.VISIBLE) { + // ensure that the journal flush is applied before the visible chore is executed + // so that the visible chore can see the latest DOM changes + applyJournalFlush(); + const blockingChore = findBlockingChoreForVisible(chore, runningChores, container); + if (blockingChore && blockingChore.$state$ === ChoreState.RUNNING) { + addBlockedChore(chore, blockingChore, blockedChores); + continue; + } + } + + // Note that this never throws + chore.$startTime$ = performance.now(); + const result = executeChore(chore, isServer); + chore.$returnValue$ = result; + if (isPromise(result)) { + runningChores.add(chore); + chore.$state$ = ChoreState.RUNNING; + + result + .then((value) => { + DEBUG && debugTrace('execute.DONE', chore, choreQueue, blockedChores); + finishChore(chore, value); + }) + .catch((e) => { + if (chore.$state$ !== ChoreState.RUNNING) { + // we already handled the error + return; + } + handleError(chore, e); + }) + .finally(() => { + runningChores.delete(chore); + // Note that we ignore failed chores so the app keeps working + // TODO decide if this is ok and document it + scheduleBlockedChoresAndDrainIfNeeded(chore); + if (drainChore && !runningChores.size) { + maybeFinishDrain(); + } + }); + } else { + DEBUG && debugTrace('execute.DONE', chore, choreQueue, blockedChores); + finishChore(chore, result); + scheduleBlockedChoresAndDrainIfNeeded(chore); + } + + if (shouldApplyJournalFlush()) { + applyJournalFlush(); + drainInNextTick(); + return; + } + } + } catch (e) { + handleError(currentChore!, e); + scheduleBlockedChoresAndDrainIfNeeded(currentChore!); + } finally { + maybeFinishDrain(); } - return runUptoChore.$returnValue$; } - function executeChore(chore: Chore, isServer: boolean) { + function finishChore(chore: Chore, value: any) { + chore.$endTime$ = performance.now(); + chore.$state$ = ChoreState.DONE; + chore.$returnValue$ = value; + chore.$resolve$?.(value); + } + + function handleError(chore: Chore, e: any) { + chore.$endTime$ = performance.now(); + chore.$state$ = ChoreState.FAILED; + DEBUG && debugTrace('execute.ERROR', chore, choreQueue, blockedChores); + // 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, blockedChores); + 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; + 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 (!effects?.size) { + break; + } - const effects = chore.$payload$ as Set; + let shouldCompute = + target instanceof ComputedSignalImpl || target instanceof WrappedSignalImpl; + if (target instanceof AsyncComputedSignalImpl && effects !== target.$effects$) { + shouldCompute = false; + } + if (shouldCompute) { 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 as ComputedSignalImpl).$computeIfNeeded$) + ), + () => { + if ((target as ComputedSignalImpl).$flags$ & SignalFlags.RUN_EFFECTS) { + (target as ComputedSignalImpl).$flags$ &= ~SignalFlags.RUN_EFFECTS; + 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,8 +746,16 @@ export const createScheduler = ( return 1; } - // If the chore is the same as the current chore, we will run it again - if (b === currentChore) { + // ensure that the effect chores are scheduled for the same target + // TODO: can we do this better? + if ( + a.$type$ === ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS && + b.$type$ === ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS && + ((a.$target$ instanceof StoreHandler && b.$target$ instanceof StoreHandler) || + (a.$target$ instanceof AsyncComputedSignalImpl && + b.$target$ instanceof AsyncComputedSignalImpl)) && + a.$payload$ !== b.$payload$ + ) { return 1; } @@ -640,6 +793,16 @@ export const createScheduler = ( /// 1. Find a place where to insert into. const idx = sortedFindIndex(sortedArray, value, rootVNode); + if (idx < 0 && runningChores.size) { + // 1.1. Check if the chore is already running. + for (const chore of runningChores) { + const comp = choreComparator(value, chore, rootVNode); + if (comp === 0) { + return chore; + } + } + } + if (idx < 0) { /// 2. Insert the chore into the queue. sortedArray.splice(~idx, 0, value); @@ -655,9 +818,6 @@ export const createScheduler = ( if (existing.$payload$ !== value.$payload$) { existing.$payload$ = value.$payload$; } - if (existing.$executed$) { - existing.$executed$ = false; - } return existing; } }; @@ -674,6 +834,23 @@ function vNodeAlreadyDeleted(chore: Chore): boolean { ); } +export function addBlockedChore( + blockedChore: Chore, + blockingChore: Chore, + blockedChores: Set +) { + DEBUG && + debugTrace( + `blocked chore by ${debugChoreToString(blockingChore)}`, + blockedChore, + undefined, + blockedChores + ); + blockingChore.$blockedChores$ ||= []; + blockingChore.$blockedChores$.push(blockedChore); + blockedChores.add(blockedChore); +} + function debugChoreTypeToString(type: ChoreType): string { return ( ( @@ -685,41 +862,112 @@ 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[] -) { - const lines = ['===========================\nScheduler: ' + action]; - if (arg && !('$type$' in arg)) { - lines.push(' arg: ' + String(arg).replaceAll(/\n.*/gim, '')); +function debugTrace(action: string, arg?: any | null, queue?: Chore[], blockedChores?: Set) { + const lines: string[] = []; + + // Header + lines.push(`Scheduler: ${action}`); + + // Argument section + if (arg) { + lines.push(''); + if (arg && '$type$' in arg) { + const chore = arg as Chore; + const type = debugChoreTypeToString(chore.$type$); + const host = String(chore.$host$).replaceAll(/\n.*/gim, ''); + const qrlTarget = (chore.$target$ as QRLInternal)?.$symbol$; + const targetOrHost = + chore.$type$ === ChoreType.QRL_RESOLVE || chore.$type$ === ChoreType.RUN_QRL + ? qrlTarget + : host; + + lines.push(`🎯 Current Chore:`); + lines.push(` Type: ${type}`); + lines.push(` Host: ${targetOrHost}`); + + // Show execution time if available + if (chore.$startTime$ && chore.$endTime$) { + const executionTime = chore.$endTime$ - chore.$startTime$; + lines.push(` Time: ${executionTime.toFixed(2)}ms`); + } else if (chore.$startTime$) { + const elapsedTime = performance.now() - chore.$startTime$; + lines.push(` Time: ${elapsedTime.toFixed(2)}ms (running)`); + } + + // Show blocked chores for this chore + if (chore.$blockedChores$ && chore.$blockedChores$.length > 0) { + lines.push(` ⛔ Blocked Chores:`); + chore.$blockedChores$.forEach((blockedChore, index) => { + const blockedType = debugChoreTypeToString(blockedChore.$type$); + const blockedTarget = String(blockedChore.$host$).replaceAll(/\n.*/gim, ''); + lines.push(` ${index + 1}. ${blockedType} ${blockedTarget} ${blockedChore.$idx$}`); + }); + } + } else { + lines.push(`📝 Argument: ${String(arg).replaceAll(/\n.*/gim, '')}`); + } + } + + // Queue section + if (queue && queue.length > 0) { + lines.push(''); + lines.push(`📋 Queue (${queue.length} items):`); + + queue.forEach((chore, index) => { + const isActive = chore === arg; + const activeMarker = isActive ? `▶ ` : ' '; + 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$; + const target = + chore.$type$ === ChoreType.QRL_RESOLVE || chore.$type$ === ChoreType.RUN_QRL + ? qrlTarget + : host; + + const line = `${activeMarker}${state} ${type} ${target} ${chore.$idx$}`; + lines.push(line); + }); } - if (queue) { - queue.forEach((chore) => { - const active = chore === arg ? '>>>' : ' '; - lines.push( - ` ${active} > ` + - (chore === currentChore ? '[running] ' : '') + - debugChoreToString(chore) - ); + + // Blocked chores section + if (blockedChores && blockedChores.size > 0) { + lines.push(''); + lines.push(`🚫 Blocked Chores (${blockedChores.size} items):`); + + Array.from(blockedChores).forEach((chore, index) => { + const type = debugChoreTypeToString(chore.$type$); + const host = String(chore.$host$).replaceAll(/\n.*/gim, ''); + const qrlTarget = (chore.$target$ as QRLInternal)?.$symbol$; + const target = + chore.$type$ === ChoreType.QRL_RESOLVE || chore.$type$ === ChoreType.RUN_QRL + ? qrlTarget + : host; + + lines.push(` ${index + 1}. ${type} ${target} ${chore.$idx$}`); }); } + + // Footer + lines.push(''); + lines.push('─'.repeat(60)); + // eslint-disable-next-line no-console console.log(lines.join('\n') + '\n'); } diff --git a/packages/qwik/src/core/shared/scheduler.unit.tsx b/packages/qwik/src/core/shared/scheduler.unit.tsx index 04a9272f19e..2ecc4454a76 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 { @@ -11,19 +11,26 @@ import { vnode_newVirtual, vnode_setProp, } from '../client/vnode'; -import { TaskFlags, type Task } from '../use/use-task'; +import { Task, TaskFlags } from '../use/use-task'; import type { Props } from './jsx/jsx-runtime'; import type { QRLInternal } from './qrl/qrl-class'; -import { createScheduler } from './scheduler'; +import { createScheduler, type Chore } from './scheduler'; import { ChoreType } from './util-chore-type'; import type { HostElement } from './types'; -import { QContainerAttr } from './utils/markers'; +import { ELEMENT_SEQ, 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,15 +40,30 @@ describe('scheduler', () => { let vB: ElementVNode = null!; let vBHost1: VirtualVNode = null!; let vBHost2: VirtualVNode = null!; + let handleError: (err: any, host: HostElement | null) => void; + let choreQueue: Chore[]; + let blockedChores: Set; + + async function waitForDrain() { + const chore = scheduler(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); + handleError = container.handleError = vi.fn(); + choreQueue = []; + blockedChores = new Set(); scheduler = createScheduler( container, - () => null, - () => testLog.push('journalFlush') + () => testLog.push('journalFlush'), + choreQueue, + blockedChores ); document.body.innerHTML = ''; vBody = vnode_newUnMaterializedElement(document.body); @@ -62,7 +84,7 @@ describe('scheduler', () => { 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); + await waitForDrain(); expect(testLog).toEqual([ 'a1', // DepthFirst a host component is before b host component. 'b1.0', // Same component but smaller index. @@ -99,26 +121,153 @@ describe('scheduler', () => { $(() => 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', + 'journalFlush', 'b2.2: VisibleTask', + 'journalFlush', + ]); + }); + + it('should execute chore', async () => { + scheduler(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( + 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(ChoreType.TASK, mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0')) })); + scheduler(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( + ChoreType.COMPONENT, + vBHost1 as HostElement, + $(() => testLog.push('component')) as unknown as QRLInternal>, + {} + ); + scheduler( + 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', + // vnode-diff chore + 'vnode-diff', + 'journalFlush', ]); }); + + it('should execute chores in two ticks', async () => { + scheduler(ChoreType.TASK, mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0')) })); + await waitForDrain(); + scheduler(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( + 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( + 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; + }); + + it('should block tasks in the same component', async () => { + const task1 = mockTask(vBHost1, { qrl: $(() => testLog.push('b1.0')), index: 0 }); + const task2 = mockTask(vBHost1, { qrl: $(() => testLog.push('b1.1')), index: 1 }); + const task3 = mockTask(vBHost1, { qrl: $(() => testLog.push('b1.2')), index: 2 }); + + vnode_setProp(vBHost1, ELEMENT_SEQ, [task1, task2, task3]); + + scheduler(ChoreType.TASK, task1); + scheduler(ChoreType.TASK, task2); + scheduler(ChoreType.TASK, task3); + // schedule only first task + expect(choreQueue.length).toBe(1); + // block the rest + expect(blockedChores.size).toBe(2); + await waitForDrain(); + expect(testLog).toEqual(['b1.0', 'b1.1', 'b1.2', 'journalFlush']); + }); }); function mockTask(host: VNode, opts: { index?: number; qrl?: QRL; visible?: boolean }): Task { - return { - $flags$: opts.visible ? TaskFlags.VISIBLE_TASK : 0, - $index$: opts.index || 0, - $el$: host as any, - $qrl$: opts.qrl || ($(() => null) as any), - $state$: null!, - $destroy$: null!, - [_EFFECT_BACK_REF]: null, - }; + return new Task( + opts.visible ? TaskFlags.VISIBLE_TASK : TaskFlags.TASK, + opts.index || 0, + host as any, + opts.qrl || ($(() => null) as any), + null!, + null! + ); } diff --git a/packages/qwik/src/core/shared/shared-container.ts b/packages/qwik/src/core/shared/shared-container.ts index 14cc690d1b1..2baf09c32c7 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'; @@ -22,13 +21,9 @@ export abstract class _SharedContainer implements Container { $currentUniqueId$ = 0; $instanceHash$: string | null = null; $buildBase$: string | null = null; + $flushEpoch$: number = 0; - 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 +32,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 +66,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..5289da45a85 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -49,7 +49,7 @@ import { isPromise } from './utils/promises'; import { SerializerSymbol, fastSkipSerialize } from './utils/serialize-utils'; import { _EFFECT_BACK_REF, - ComputedSignalFlags, + SerializationSignalFlags, EffectSubscriptionProp, NEEDS_COMPUTATION, SignalFlags, @@ -346,11 +346,7 @@ const inflate = ( */ // try to download qrl in this tick computed.$computeQrl$.resolve(); - (container as DomContainer).$scheduler$?.( - ChoreType.QRL_RESOLVE, - null, - computed.$computeQrl$ - ); + (container as DomContainer).$scheduler$(ChoreType.QRL_RESOLVE, null, computed.$computeQrl$); } break; } @@ -1258,9 +1254,9 @@ async function serialize(serializationContext: SerializationContext): Promise { Number 3 ] Constant null - Number 3 + Number 5 Constant null ] 1 WrappedSignal [ @@ -524,7 +524,7 @@ describe('shared-serialization', () => { String "value" ] Constant null - Number 3 + Number 7 Constant null ] (74 chars)" 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/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/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..03e419a493a 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,11 @@ 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$(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/error-handling.spec.tsx b/packages/qwik/src/core/tests/error-handling.spec.tsx index 55883e26d13..c85dbef4899 100644 --- a/packages/qwik/src/core/tests/error-handling.spec.tsx +++ b/packages/qwik/src/core/tests/error-handling.spec.tsx @@ -7,7 +7,6 @@ import { Fragment as Projection, component$, useSignal, - useTask$, } from '@qwik.dev/core'; import { ErrorProvider } from '../../testing/rendering.unit-util'; @@ -21,13 +20,9 @@ describe.each([ it('should handle error in component with element wrapper', async () => { const Cmp = component$(() => { const counter = useSignal(0); - useTask$(async ({ track }) => { - const count = track(counter); - if (count === 0) { - return; - } + if (counter.value !== 0) { throw new Error('error'); - }); + } return (
{''} @@ -68,13 +63,9 @@ describe.each([ it('should handle error in component with virtual wrapper', async () => { const Cmp = component$(() => { const counter = useSignal(0); - useTask$(async ({ track }) => { - const count = track(counter); - if (count === 0) { - return; - } + if (counter.value !== 0) { throw new Error('error'); - }); + } return ( <> {''} @@ -110,4 +101,47 @@ describe.each([ ); }); + + it('should handle error in event handler', async () => { + const Cmp = component$(() => { + return ( + <> + +
some div
+ + ); + }); + + const { vNode, document } = await render( + + + , + { debug } + ); + // override globalThis.document to make moving elements logic work + globalThis.document = document; + + await trigger(document.body, 'button', 'click'); + + expect(vNode).toMatchVDOM( + + + + + + + +
some div
+
+
+
+
+ ); + }); }); diff --git a/packages/qwik/src/core/tests/ref.spec.tsx b/packages/qwik/src/core/tests/ref.spec.tsx index 41d89b2b902..ec867659b6d 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'; @@ -240,7 +239,7 @@ describe.each([ const element = useSignal(); const signal = useSignal(0); - useTask$(({ track }) => { + useVisibleTask$(({ track }) => { track(element); signal.value++; }); @@ -253,13 +252,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
); 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..e501a16a006 100644 --- a/packages/qwik/src/core/tests/use-resource.spec.tsx +++ b/packages/qwik/src/core/tests/use-resource.spec.tsx @@ -11,9 +11,9 @@ import { useStore, type ResourceReturn, } from '@qwik.dev/core'; -import { domRender, getTestPlatform, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; +import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; import { describe, expect, it } from 'vitest'; -import '../../testing/vdom-diff.unit-util'; +import { ChoreType } from '../shared/util-chore-type'; const debug = false; //true; Error.stackTraceLimit = 100; @@ -127,7 +127,7 @@ describe.each([ ); - await trigger(container.element, 'button', 'click'); + await trigger(container.element, 'button', 'click', {}, { waitForIdle: false }); 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..de941ea085e 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( @@ -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 ( +