diff --git a/.changeset/smart-buckets-slide.md b/.changeset/smart-buckets-slide.md new file mode 100644 index 00000000000..ce7cd5cddd7 --- /dev/null +++ b/.changeset/smart-buckets-slide.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: improve client resume responsiveness by splitting state processing into smaller tasks diff --git a/packages/docs/src/routes/api/qwik-server/api.json b/packages/docs/src/routes/api/qwik-server/api.json index c940ad06e20..54857208065 100644 --- a/packages/docs/src/routes/api/qwik-server/api.json +++ b/packages/docs/src/routes/api/qwik-server/api.json @@ -152,7 +152,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface RenderOptions extends SerializeDocumentOptions \n```\n**Extends:** [SerializeDocumentOptions](#serializedocumentoptions)\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nbase?\n\n\n\n\n\n\n\nstring \\| ((options: [RenderOptions](#renderoptions)) => string)\n\n\n\n\n_(Optional)_ Specifies the root of the JS files of the client build. Setting a base, will cause the render of the `q:base` attribute in the `q:container` element.\n\n\n
\n\ncontainerAttributes?\n\n\n\n\n\n\n\nRecord<string, string>\n\n\n\n\n_(Optional)_\n\n\n
\n\ncontainerTagName?\n\n\n\n\n\n\n\nstring\n\n\n\n\n_(Optional)_ When set, the app is serialized into a fragment. And the returned html is not a complete document. Defaults to `html`\n\n\n
\n\nlocale?\n\n\n\n\n\n\n\nstring \\| ((options: [RenderOptions](#renderoptions)) => string)\n\n\n\n\n_(Optional)_ Language to use when rendering the document.\n\n\n
\n\npreloader?\n\n\n\n\n\n\n\n[PreloaderOptions](#preloaderoptions) \\| false\n\n\n\n\n_(Optional)_ Specifies how preloading is handled. This ensures that code is instantly available when needed.\n\n\n
\n\nqwikLoader?\n\n\n\n\n\n\n\n[QwikLoaderOptions](#qwikloaderoptions)\n\n\n\n\n_(Optional)_ Specifies how the Qwik Loader is included in the document. This enables interactivity and lazy loading.\n\n`module`: Use a ` + + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + + while ( + container.$containerDataProcessState$ < ContainerDataProcessState.ProcessingVNodeDone + ) { + runNextTask(tasks); + } + + expect(isContainerReady(container)).not.toBe(true); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.PAUSED); + + let chunks = 0; + while (!isContainerReady(container)) { + runNextTask(tasks); + chunks++; + expect(chunks).toBeLessThan(50); + } + + await ready; + expect(chunks).toBeGreaterThan(1); + expect(container.$getObjectById$(1)).toBe(0); + expect((document.documentElement as ContainerElement).qVNodeRefs?.get(4)).toBe( + document.querySelector('button') + ); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.RESUMED); + }); + }); + + it('should keep below-threshold state lazy until first access', async () => { + const stateItems: unknown[] = [TypeIds.Object, [TypeIds.Plain, 'answer', TypeIds.Plain, 42]]; + for (let i = 1; i < 128; i++) { + stateItems.push(TypeIds.Plain, i); + } + const document = createDocument({ + html: ` + + + + + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + + while (!isContainerReady(container)) { + runNextTask(tasks); + } + + await ready; + const rawState = (container as any).$rawStateData$ as unknown[]; + expect(rawState[0]).toBe(TypeIds.Object); + expect(container.$getObjectById$(0)).toEqual({ answer: 42 }); + expect(rawState[0]).toBe(TypeIds.Plain); + }); + }); + + it('should eagerly deserialize opted-in large state before marking the container resumed', async () => { + const stateItems: unknown[] = [TypeIds.Object, [TypeIds.Plain, 'answer', TypeIds.Plain, 42]]; + for (let i = 1; i < 2048; i++) { + stateItems.push(TypeIds.Plain, i); + } + const document = createDocument({ + html: ` + + + + + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + let chunks = 0; + + while (!isContainerReady(container)) { + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.PAUSED); + runNextTask(tasks); + chunks++; + expect(chunks).toBeLessThan(300); + } + + await ready; + const rawState = (container as any).$rawStateData$ as unknown[]; + expect(chunks).toBeGreaterThan(1); + expect(rawState[0]).toBe(TypeIds.Plain); + expect(container.$getObjectById$(0)).toEqual({ answer: 42 }); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.RESUMED); + }); + }); + + it('should keep large state lazy by default', async () => { + const stateItems: unknown[] = [TypeIds.Object, [TypeIds.Plain, 'answer', TypeIds.Plain, 42]]; + for (let i = 1; i < 2048; i++) { + stateItems.push(TypeIds.Plain, i); + } + const document = createDocument({ + html: ` + + + + + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + + while (!isContainerReady(container)) { + runNextTask(tasks); + } + + await ready; + const rawState = (container as any).$rawStateData$ as unknown[]; + expect(rawState[0]).toBe(TypeIds.Object); + expect(container.$getObjectById$(0)).toEqual({ answer: 42 }); + expect(rawState[0]).toBe(TypeIds.Plain); + }); + }); + + it('should eagerly deserialize smaller state when state prewarm threshold is lowered', async () => { + const stateItems: unknown[] = [TypeIds.Object, [TypeIds.Plain, 'answer', TypeIds.Plain, 42]]; + for (let i = 1; i < 128; i++) { + stateItems.push(TypeIds.Plain, i); + } + const document = createDocument({ + html: ` + + + + + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + + while (!isContainerReady(container)) { + runNextTask(tasks); + } + + await ready; + const rawState = (container as any).$rawStateData$ as unknown[]; + expect(rawState[0]).toBe(TypeIds.Plain); + expect(container.$getObjectById$(0)).toEqual({ answer: 42 }); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.RESUMED); + }); + }); + it('should process shadow root container', async () => { const [, container] = await process(` @@ -433,14 +666,22 @@ async function process(html: string): Promise { template.remove(); } } - const ready = whenVNodeDataReady(document, () => undefined); - processVNodeData(document); - await ready; const containers: Element[] = []; findContainers(document, containers); - return containers.map(getDomContainer); + const domContainers = containers.map(getDomContainer); + + await Promise.all( + domContainers.map(async (container) => { + const domContainers = container as DomContainer; + processVNodeData(domContainers); + await whenVNodeDataReady(domContainers, () => undefined); + await whenContainerDataReady(container, () => undefined); + }) + ); + + return domContainers; } const findContainers = (element: Document | ShadowRoot, containers: Element[]) => { diff --git a/packages/qwik/src/core/client/run-qrl.ts b/packages/qwik/src/core/client/run-qrl.ts index 5435a8d5d02..52beb9192fd 100644 --- a/packages/qwik/src/core/client/run-qrl.ts +++ b/packages/qwik/src/core/client/run-qrl.ts @@ -12,8 +12,7 @@ import { retryOnPromise } from '../shared/utils/promises'; import type { ValueOrPromise } from '../shared/utils/types'; import type { ElementVNode } from '../shared/vnode/element-vnode'; import { invokeApply, newInvokeContextFromDOMReady, type InvokeContext } from '../use/use-core'; -import { getDomContainer } from './dom-container'; -import { whenVNodeDataReady } from './process-vnode-data'; +import { getDomContainer, whenContainerDataReady } from './dom-container'; import { VNodeFlags } from './types'; import { vnode_ensureElementInflated, vnode_getProp } from './vnode-utils'; @@ -33,7 +32,7 @@ export function runEventHandlerQRL( } if (!ctx) { const container = getDomContainer(element); - return whenVNodeDataReady(container.document, () => + return whenContainerDataReady(container, () => runEventHandlerQRL( handler, event, @@ -87,7 +86,7 @@ export function _run(this: string, event: Event, element: Element): ValueOrPromi return; } const container = getDomContainer(element); - return whenVNodeDataReady(container.document, () => { + return whenContainerDataReady(container, () => { const ctx = newInvokeContextFromDOMReady(event, element, container); if (typeof this === 'string') { setCaptures(deserializeCaptures(ctx.$container$!, this)); diff --git a/packages/qwik/src/core/client/run-qrl.unit.ts b/packages/qwik/src/core/client/run-qrl.unit.ts index e87d212f84a..1fe25ad8788 100644 --- a/packages/qwik/src/core/client/run-qrl.unit.ts +++ b/packages/qwik/src/core/client/run-qrl.unit.ts @@ -5,7 +5,6 @@ import * as useCore from '../use/use-core'; import * as vnodeUtils from './vnode-utils'; import * as promises from '../shared/utils/promises'; import * as domContainer from './dom-container'; -import * as processVNodeData from './process-vnode-data'; import { ITERATION_ITEM_MULTI, ITERATION_ITEM_SINGLE } from '../shared/utils/markers'; import { VNodeFlags } from './types'; @@ -39,15 +38,7 @@ vi.mock('./dom-container', async () => { return { ...actual, getDomContainer: vi.fn(), - }; -}); - -vi.mock('./process-vnode-data', async () => { - const actual = - await vi.importActual('./process-vnode-data'); - return { - ...actual, - whenVNodeDataReady: vi.fn((_document, callback) => callback()), + whenContainerDataReady: vi.fn((_container, callback) => callback()), }; }); @@ -113,7 +104,7 @@ describe('_run', () => { // Setup default mocks vi.mocked(domContainer.getDomContainer).mockReturnValue(mockContainer); vi.mocked(useCore.newInvokeContextFromDOMReady).mockReturnValue(mockContext); - vi.mocked(processVNodeData.whenVNodeDataReady).mockImplementation((_document, callback) => + vi.mocked(domContainer.whenContainerDataReady).mockImplementation((_container, callback) => callback() ); vi.mocked(qrlClass.deserializeCaptures).mockReturnValue([mockQrl]); @@ -253,10 +244,10 @@ describe('_run', () => { expect(qrlClass.setCaptures).toHaveBeenCalled(); }); - it('should wait for VNodeData readiness before creating context', async () => { + it('should wait for container data readiness before creating context', async () => { let ready!: () => void; - vi.mocked(processVNodeData.whenVNodeDataReady).mockImplementation( - (_document, callback: any) => + vi.mocked(domContainer.whenContainerDataReady).mockImplementation( + (_container, callback: any) => new Promise((resolve) => { ready = () => resolve(callback()); }) @@ -301,7 +292,7 @@ describe('runEventHandlerQRL', () => { vi.mocked(domContainer.getDomContainer).mockReturnValue(mockContainer); vi.mocked(useCore.newInvokeContextFromDOMReady).mockReturnValue(mockContext); - vi.mocked(processVNodeData.whenVNodeDataReady).mockImplementation((_document, callback) => + vi.mocked(domContainer.whenContainerDataReady).mockImplementation((_container, callback) => callback() ); vi.mocked(useCore.invokeApply).mockReturnValue(undefined); diff --git a/packages/qwik/src/core/client/types.ts b/packages/qwik/src/core/client/types.ts index 0f548877069..77840e2b5fc 100644 --- a/packages/qwik/src/core/client/types.ts +++ b/packages/qwik/src/core/client/types.ts @@ -40,6 +40,7 @@ export interface ContainerElement extends HTMLElement { /** String from ` + + + `, + }); + const firstInput = document.getElementById('first') as HTMLInputElement; + const secondInput = document.getElementById('second') as HTMLInputElement; + firstInput.value = 'first value'; + secondInput.value = 'second value'; + + await withQueuedMacroTasks(async (tasks) => { + const firstResult = _val.call('0', null, firstInput); + const secondResult = _val.call('1', null, secondInput); + + expect(tasks).toHaveLength(1); + drainTasks(tasks); + await Promise.all([Promise.resolve(firstResult), Promise.resolve(secondResult)]); + + const container = getDomContainer(firstInput); + expect((container.$getObjectById$(0) as { value: string }).value).toBe('first value'); + expect((container.$getObjectById$(1) as { value: string }).value).toBe('second value'); + }); + }); + it('should update signal with input value', () => { const document = createDocument(); document.body.setAttribute(QContainerAttr, 'paused'); @@ -109,3 +148,42 @@ describe('bind handlers', () => { }); }); }); + +async function withQueuedMacroTasks(callback: (tasks: Array<() => void>) => Promise) { + const tasks: Array<() => void> = []; + const originalMessageChannel = (globalThis as any).MessageChannel; + + class TestMessageChannel { + port1 = { + onmessage: null as null | (() => void), + close() {}, + }; + port2 = { + postMessage: () => { + tasks.push(() => this.port1.onmessage?.()); + }, + close() {}, + }; + } + + try { + Object.defineProperty(globalThis, 'MessageChannel', { + configurable: true, + value: TestMessageChannel, + }); + await callback(tasks); + } finally { + Object.defineProperty(globalThis, 'MessageChannel', { + configurable: true, + value: originalMessageChannel, + }); + } +} + +function drainTasks(tasks: Array<() => void>) { + let count = 0; + while (tasks.length > 0) { + tasks.shift()!(); + expect(++count).toBeLessThan(10); + } +} diff --git a/packages/qwik/src/core/shared/platform/next-tick.ts b/packages/qwik/src/core/shared/platform/next-tick.ts index 264a000c8ee..1f3bf402524 100644 --- a/packages/qwik/src/core/shared/platform/next-tick.ts +++ b/packages/qwik/src/core/shared/platform/next-tick.ts @@ -6,16 +6,34 @@ export const createMicroTask = (fn: () => void) => { return () => queueMicrotask(fn); }; +export interface MacroTask { + (): void; + $destroy$?: () => void; +} + /** * Creates a function that schedules `fn` to run as a macrotask. Macrotasks yield to the browser, * allowing paint and user input. Used for time-slicing to avoid blocking the main thread. */ -export const createMacroTask = (fn: () => void) => { - let macroTask: () => void; +export const createMacroTask = (fn: () => void): MacroTask => { + let macroTask: MacroTask; if (typeof MessageChannel !== 'undefined') { const channel = new MessageChannel(); + let active = true; channel.port1.onmessage = () => fn(); - macroTask = () => channel.port2.postMessage(null); + macroTask = () => { + if (active) { + channel.port2.postMessage(null); + } + }; + macroTask.$destroy$ = () => { + if (active) { + active = false; + channel.port1.onmessage = null; + channel.port1.close(); + channel.port2.close(); + } + }; } else { macroTask = () => setTimeout(fn); } diff --git a/packages/qwik/src/core/shared/platform/next-tick.unit.ts b/packages/qwik/src/core/shared/platform/next-tick.unit.ts new file mode 100644 index 00000000000..e4cc83a77cf --- /dev/null +++ b/packages/qwik/src/core/shared/platform/next-tick.unit.ts @@ -0,0 +1,46 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { createMacroTask } from './next-tick'; + +const originalMessageChannel = globalThis.MessageChannel; + +afterEach(() => { + Object.assign(globalThis, { + MessageChannel: originalMessageChannel, + }); +}); + +test('createMacroTask can close its MessageChannel', () => { + const channels: TestMessageChannel[] = []; + class TestMessageChannel { + port1 = { + onmessage: null as null | (() => void), + close: vi.fn(), + }; + port2 = { + postMessage: vi.fn(() => this.port1.onmessage?.()), + close: vi.fn(), + }; + constructor() { + channels.push(this); + } + } + Object.assign(globalThis, { + MessageChannel: TestMessageChannel, + }); + + const callback = vi.fn(); + const macroTask = createMacroTask(callback); + const channel = channels[0]; + + macroTask(); + expect(callback).toHaveBeenCalledTimes(1); + expect(channel.port2.postMessage).toHaveBeenCalledTimes(1); + + macroTask.$destroy$!(); + macroTask(); + + expect(channel.port1.close).toHaveBeenCalledTimes(1); + expect(channel.port2.close).toHaveBeenCalledTimes(1); + expect(channel.port2.postMessage).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); +}); diff --git a/packages/qwik/src/core/shared/serdes/inflate.ts b/packages/qwik/src/core/shared/serdes/inflate.ts index 277c14d5293..9202a0130e4 100644 --- a/packages/qwik/src/core/shared/serdes/inflate.ts +++ b/packages/qwik/src/core/shared/serdes/inflate.ts @@ -64,40 +64,170 @@ const isSafeObjectKV = (key: unknown, value: unknown): key is string | number => ); }; +const runDeserializeIterator = (iterator: Generator): T => { + while (true) { + const result = iterator.next(); + if (result.done) { + return result.value; + } + } +}; + export const inflate = ( container: DeserializeContainer, target: unknown, typeId: TypeIds, data: unknown ): void => { + runDeserializeIterator(inflateIterator(container, target, typeId, data)); +}; + +function* eagerDeserializeArrayIterator( + container: DeserializeContainer, + data: unknown[], + output: unknown[] = Array(data.length / 2) +): Generator { + for (let i = 0; i < data.length; i += 2) { + output[i / 2] = yield* deserializeDataIterator(container, data[i] as TypeIds, data[i + 1]); + yield; + } + return output; +} + +export function* eagerDeserializeStateIterator( + container: DeserializeContainer, + data: unknown[], + output: unknown[] = Array(data.length / 2) +): Generator { + const length = data.length / 2; + const allocated = new Uint8Array(length); + const previousGetObjectById = container.$getObjectById$; + + const allocateRoot = (index: number): unknown => { + if (!allocated[index]) { + allocated[index] = 1; + const typeIndex = index * 2; + const typeId = data[typeIndex] as TypeIds; + const value = data[typeIndex + 1]; + output[index] = typeId === TypeIds.Plain ? value : allocate(container, typeId, value); + } + return output[index]; + }; + + const resolveRoot = (id: number | string): unknown => { + if (typeof id === 'string') { + id = parseInt(id, 10); + } + return allocateRoot(id); + }; + + container.$getObjectById$ = resolveRoot; + try { + for (let i = 0; i < length; i++) { + allocateRoot(i); + yield; + } + for (let i = 0; i < length; i++) { + const typeIndex = i * 2; + const typeId = data[typeIndex] as TypeIds; + const value = data[typeIndex + 1]; + const propValue = output[i]; + data[typeIndex] = TypeIds.Plain; + data[typeIndex + 1] = propValue; + if (needsInflation(typeId)) { + yield* inflateIterator(container, propValue, typeId, value); + } + yield; + } + } finally { + container.$getObjectById$ = previousGetObjectById; + } + return output; +} + +function* deserializeDataIterator( + container: DeserializeContainer, + typeId: number, + value: unknown +): Generator { + if (typeId === TypeIds.Plain) { + return value; + } + const propValue = allocate(container, typeId, value); + if (needsInflation(typeId)) { + yield* inflateIterator(container, propValue, typeId, value); + } + return propValue; +} + +function* inflateIterator( + container: DeserializeContainer, + target: unknown, + typeId: TypeIds, + data: unknown +): Generator { if (typeId === TypeIds.Plain) { - // Already processed return; } - // Restore the complex data, special case for Array if (typeId !== TypeIds.Array && Array.isArray(data)) { - data = _eagerDeserializeArray(container, data); + data = yield* eagerDeserializeArrayIterator(container, data); } switch (typeId) { case TypeIds.Array: - // Arrays are special, we need to fill the array in place - _eagerDeserializeArray(container, data as unknown[], target as unknown[]); + yield* eagerDeserializeArrayIterator(container, data as unknown[], target as unknown[]); break; case TypeIds.Object: if (data === 0) { - // Special case, was an empty object break; } for (let i = 0; i < (data as any[]).length; i += 2) { const key = (data as unknown[])[i]; const value = (data as unknown[])[i + 1]; - if (!isSafeObjectKV(key, value)) { - continue; + if (isSafeObjectKV(key, value)) { + (target as Record)[key] = value; } - (target as Record)[key] = value; + yield; + } + break; + case TypeIds.Set: { + const set = target as Set; + const d = data as any[]; + for (let i = 0; i < d.length; i++) { + set.add(d[i]); + yield; + } + break; + } + case TypeIds.Map: { + const map = target as Map; + const d = data as any[]; + for (let i = 0; i < d.length; i++) { + map.set(d[i++], d[i]); + yield; + } + break; + } + case TypeIds.Promise: { + const promise = target as Promise; + const [resolved, result] = data as [boolean, unknown]; + const [resolve, reject] = resolvers.get(promise)!; + if (resolved) { + resolve(result); + } else { + reject(result); + } + break; + } + case TypeIds.Error: { + const d = data as string[]; + (target as Error).message = d[0] as string; + for (let i = 1; i < d.length; i += 2) { + (target as any)[d[i]] = d[i + 1]; + yield; } break; - case TypeIds.Task: + } + case TypeIds.Task: { const task = target as Task; const v = data as any[]; task.$qrl$ = v[0]; @@ -106,28 +236,10 @@ export const inflate = ( task.$el$ = v[3] as HostElement; task.$state$ = v[4]; break; + } case TypeIds.Component: (target as any)[SERIALIZABLE_STATE][0] = (data as any[])[0]; break; - case TypeIds.Store: { - // Inflate the store target - const store = unwrapStore(target) as object; - const storeTarget = pendingStoreTargets.get(store); - if (storeTarget) { - pendingStoreTargets.delete(store); - inflate(container, store, storeTarget.t, storeTarget.v); - } - /** - * Note that we don't do anything with the innerstores we added during serialization, because - * they are already inflated in the deserialize of the data, above. - */ - const [, flags, effects] = data as unknown[]; - const storeHandler = getStoreHandler(target as object)!; - storeHandler.$flags$ = flags as StoreFlags; - storeHandler.$effects$ = effects as any; - restoreEffectBackRefForEffectsMap(storeHandler.$effects$, store); - break; - } case TypeIds.Signal: { const signal = target as SignalImpl; const d = data as [unknown, ...EffectSubscription[]]; @@ -181,7 +293,6 @@ export const inflate = ( asyncSignal.$flags$ = (d[5] as number) ?? 0; if (asyncSignal.$flags$ & AsyncSignalFlags.CLIENT_ONLY) { - // If it's client only, it was serialized because it pretended to be loading asyncSignal.$untrackedLoading$ = true; } @@ -189,12 +300,10 @@ export const inflate = ( if (hasValue) { asyncSignal.$untrackedValue$ = d[6]; } - // can happen when never serialize etc if (asyncSignal.$untrackedValue$ === NEEDS_COMPUTATION) { asyncSignal.$flags$ |= SignalFlags.INVALID; } - // Handle old format (negative = no poll) and new format (always positive, flag in d[5]) const rawExpires = (d[7] ?? 0) as number; asyncSignal.expires = Math.abs(rawExpires); if (rawExpires < 0) { @@ -211,17 +320,11 @@ export const inflate = ( restoreEffectBackRefForEffects(asyncSignal.$errorEffects$, asyncSignal); break; } - // Inflating a SerializerSignal is the same as inflating a ComputedSignal case TypeIds.SerializerSignal: case TypeIds.ComputedSignal: { const computed = target as ComputedSignalImpl; const d = data as [QRLInternal<() => {}>, EffectSubscription[] | undefined, unknown?]; computed.$computeQrl$ = d[0]; - /** - * If we try to compute value and the qrl is not resolved, then system throws an error with - * the resolve promise. To prevent that we load it now and qrls wait for the loading to - * finish. - */ const p = computed.$computeQrl$.resolve(container as any).catch(() => { // ignore preload errors }); @@ -234,26 +337,17 @@ export const inflate = ( computed.$untrackedValue$ = d[2]; } if (typeId !== TypeIds.SerializerSignal && computed.$untrackedValue$ !== NEEDS_COMPUTATION) { - // If we have a value after SSR, it will always be mean the signal was not invalid - // The serialized signal is always left invalid so it can recreate the custom object computed.$flags$ &= ~SignalFlags.INVALID; } restoreEffectBackRefForEffects(computed.$effects$, computed); break; } - case TypeIds.Error: { - const d = data as string[]; - (target as Error).message = d[0] as string; - for (let i = 1; i < d.length; i += 2) { - (target as any)[d[i]] = d[i + 1]; - } - break; - } case TypeIds.FormData: { const formData = target as FormData; const d = data as any[]; for (let i = 0; i < d.length; i++) { formData.append(d[i++], d[i]); + yield; } break; } @@ -268,43 +362,7 @@ export const inflate = ( jsx.toSort = !!toSort; break; } - case TypeIds.Set: { - const set = target as Set; - const d = data as any[]; - for (let i = 0; i < d.length; i++) { - set.add(d[i]); - } - break; - } - case TypeIds.Map: { - const map = target as Map; - const d = data as any[]; - for (let i = 0; i < d.length; i++) { - map.set(d[i++], d[i]); - } - break; - } - case TypeIds.Promise: { - const promise = target as Promise; - const [resolved, result] = data as [boolean, unknown]; - const [resolve, reject] = resolvers.get(promise)!; - if (resolved) { - resolve(result); - } else { - reject(result); - } - break; - } - case TypeIds.Uint8Array: - const bytes = target as Uint8Array; - const buf = atob(data as string); - let i = 0; - for (let j = 0; j < buf.length; j++) { - const s = buf[j]; - bytes[i++] = s.charCodeAt(0); - } - break; - case TypeIds.PropsProxy: + case TypeIds.PropsProxy: { const propsProxy = target as PropsProxy; const d = data as [ JSXNodeImpl | typeof _UNINITIALIZED, @@ -322,6 +380,7 @@ export const inflate = ( propsHandler.$effects$ = d[3]; restoreEffectBackRefForEffectsMap(propsHandler.$effects$, propsProxy); break; + } case TypeIds.SubscriptionData: { const effectData = target as SubscriptionData; effectData.data.$scopedStyleIdPrefix$ = (data as any[])[0]; @@ -337,35 +396,36 @@ export const inflate = ( restoreEffectBackRefForConsumer(effectSub); break; } + case TypeIds.Uint8Array: { + const bytes = target as Uint8Array; + const buf = atob(data as string); + let i = 0; + for (let j = 0; j < buf.length; j++) { + const s = buf[j]; + bytes[i++] = s.charCodeAt(0); + if ((j & 31) === 31) { + yield; + } + } + break; + } + case TypeIds.Store: { + const store = unwrapStore(target) as object; + const storeTarget = pendingStoreTargets.get(store); + if (storeTarget) { + pendingStoreTargets.delete(store); + yield* inflateIterator(container, store, storeTarget.t, storeTarget.v); + } + const [, flags, effects] = data as unknown[]; + const storeHandler = getStoreHandler(target as object)!; + storeHandler.$flags$ = flags as StoreFlags; + storeHandler.$effects$ = effects as any; + restoreEffectBackRefForEffectsMap(storeHandler.$effects$, store); + break; + } default: throw qError(QError.serializeErrorNotImplemented, [typeId]); } -}; - -/** - * Restores an array eagerly. If you need it lazily, use `deserializeData(container, TypeIds.Array, - * array)` instead - */ -export const _eagerDeserializeArray = ( - container: DeserializeContainer, - data: unknown[], - output: unknown[] = Array(data.length / 2) -): unknown[] => { - for (let i = 0; i < data.length; i += 2) { - output[i / 2] = deserializeData(container, data[i] as TypeIds, data[i + 1]); - } - return output; -}; - -export function deserializeData(container: DeserializeContainer, typeId: number, value: unknown) { - if (typeId === TypeIds.Plain) { - return value; - } - const propValue = allocate(container, typeId, value); - if (needsInflation(typeId)) { - inflate(container, propValue, typeId, value); - } - return propValue; } export function inflateWrappedSignalValue(signal: WrappedSignalImpl) { diff --git a/packages/qwik/src/core/shared/serdes/preprocess-state.ts b/packages/qwik/src/core/shared/serdes/preprocess-state.ts index 00187492254..6ead0a7afc8 100644 --- a/packages/qwik/src/core/shared/serdes/preprocess-state.ts +++ b/packages/qwik/src/core/shared/serdes/preprocess-state.ts @@ -52,6 +52,16 @@ import { TypeIds } from './constants'; */ export function preprocessState(data: unknown[], container: DeserializeContainer) { + const iterator = preprocessStateIterator(data, container); + while (!iterator.next().done) { + // Run synchronously for non-browser and non-container deserialization paths. + } +} + +export function* preprocessStateIterator( + data: unknown[], + container: DeserializeContainer +): Generator { const isRootDeepRef = (type: TypeIds, value: unknown) => { return type === TypeIds.RootRef && typeof value === 'string'; }; @@ -99,5 +109,6 @@ export function preprocessState(data: unknown[], container: DeserializeContainer } else if (isForwardRefsMap(data[i] as TypeIds)) { container.$forwardRefs$ = data[i + 1] as number[]; } + yield; } } diff --git a/packages/qwik/src/core/shared/serdes/serdes.public.ts b/packages/qwik/src/core/shared/serdes/serdes.public.ts index 7135a7a64c4..5d22b23be32 100644 --- a/packages/qwik/src/core/shared/serdes/serdes.public.ts +++ b/packages/qwik/src/core/shared/serdes/serdes.public.ts @@ -2,9 +2,13 @@ import { createSerializationContext } from './index'; import { assertTrue } from '../error/assert'; import type { DeserializeContainer } from '../types'; import { wrapDeserializerProxy } from './deser-proxy'; -import { deserializeData } from './inflate'; +import { eagerDeserializeStateIterator } from './inflate'; import { preprocessState } from './preprocess-state'; import { isDev } from '@qwik.dev/core/build'; +import { + createYieldingIteratorState, + scheduleYieldingIterator, +} from '../../client/yielding-iterator'; /** * Serialize data to string using SerializationContext. @@ -26,13 +30,13 @@ export async function _serialize(data: T): Promise { return serializationContext.$writer$.toString(); } /** - * Deserialize data from string to an array of objects. + * Deserialize data from string. * * @param rawStateData - Data to deserialize * @internal */ -export function _deserialize(rawStateData: string): T { +export async function _deserialize(rawStateData: string): Promise { if (rawStateData == null) { throw new Error('No state data to deserialize'); } @@ -41,10 +45,20 @@ export function _deserialize(rawStateData: string): T { throw new Error('Invalid state data'); } - const container = _createDeserializeContainer(stateData); - return deserializeData(container, stateData[0], stateData[1]); + const state = Array(stateData.length / 2); + const container = createBaseDeserializeContainer(stateData, () => state); + container.$state$ = state; + await runDeserializeIterator(eagerDeserializeStateIterator(container, stateData, state)); + return state[0] as T; } +const runDeserializeIterator = (iterator: Generator): Promise => { + return new Promise((resolve, reject) => { + const state = createYieldingIteratorState(iterator, resolve, reject); + scheduleYieldingIterator(state); + }); +}; + export function getObjectById(id: number | string, stateData: unknown[]): unknown { if (typeof id === 'string') { id = parseInt(id, 10); @@ -59,8 +73,18 @@ export function getObjectById(id: number | string, stateData: unknown[]): unknow export function _createDeserializeContainer(stateData: unknown[]): DeserializeContainer { // eslint-disable-next-line prefer-const let state: unknown[]; + const container = createBaseDeserializeContainer(stateData, () => state); + state = wrapDeserializerProxy(container as any, stateData); + container.$state$ = state; + return container; +} + +function createBaseDeserializeContainer( + stateData: unknown[], + getState: () => unknown[] +): DeserializeContainer { const container: DeserializeContainer = { - $getObjectById$: (id: number | string) => getObjectById(id, state), + $getObjectById$: (id: number | string) => getObjectById(id, getState()), getSyncFn: (_: number) => { const fn = () => {}; return fn; @@ -70,7 +94,5 @@ export function _createDeserializeContainer(stateData: unknown[]): DeserializeCo $forwardRefs$: null, }; preprocessState(stateData, container); - state = wrapDeserializerProxy(container as any, stateData); - container.$state$ = state; return container; } diff --git a/packages/qwik/src/core/shared/serdes/serdes.unit.ts b/packages/qwik/src/core/shared/serdes/serdes.unit.ts index c736ffc7c74..933f21f90b8 100644 --- a/packages/qwik/src/core/shared/serdes/serdes.unit.ts +++ b/packages/qwik/src/core/shared/serdes/serdes.unit.ts @@ -41,10 +41,13 @@ import { _OWNER, _PROPS_HANDLER } from '../utils/constants'; import { _constants, _typeIdNames, TypeIds } from './constants'; import { _dumpState } from './dump-state'; import { qrlToString } from './qrl-to-string'; -import { _createDeserializeContainer } from './serdes.public'; +import { preprocessState } from './preprocess-state'; +import { _createDeserializeContainer, getObjectById } from './serdes.public'; import { createSerializationContext } from './serialization-context'; import { _serializationWeakRef } from './serialize'; import type { AsyncSignalImpl } from '../../reactive-primitives/impl/async-signal-impl'; +import type { DeserializeContainer } from '../types'; +import { eagerDeserializeStateIterator } from './inflate'; const DEBUG = false; @@ -939,6 +942,113 @@ describe('shared-serialization', () => { return container.$state$!; }; + const eagerDeserialize = (data: unknown[]) => { + const state = Array(data.length / 2); + const container: DeserializeContainer = { + $getObjectById$: (id) => getObjectById(id, state), + getSyncFn: (_: number) => { + const fn = () => {}; + return fn; + }, + $storeProxyMap$: new WeakMap(), + element: null, + $forwardRefs$: null, + }; + preprocessState(data, container); + const iterator = eagerDeserializeStateIterator(container, data, state); + let yields = 0; + while (!iterator.next().done) { + yields++; + } + return { state, yields }; + }; + + describe('chunked eager deserialize', () => { + const makeEffect = () => + new EffectSubscription( + new Task(0, 0, {} as any, inlinedQrl(0, 's_zero') as any, {} as any, null), + EffectProperty.COMPONENT, + null, + null + ); + + it('restores arrays, objects, maps, sets, and root refs', async () => { + const shared = { value: 1 }; + const root = [ + shared, + { shared }, + new Map([[shared, new Set([shared, 'x'])]]), + new Set([shared]), + ]; + const sync = deserialize(await serialize(root, shared)); + const { state, yields } = eagerDeserialize(await serialize(root, shared)); + const restoredRoot = state[0] as any[]; + const restoredShared = state[1] as Record; + + expect(yields).toBeGreaterThan(0); + expect(state).toEqual(sync); + expect(restoredRoot[0]).toBe(restoredShared); + expect(restoredRoot[1].shared).toBe(restoredShared); + expect([...restoredRoot[2].keys()][0]).toBe(restoredShared); + expect(restoredRoot[3].has(restoredShared)).toBe(true); + }); + + it('restores stores with cycles', async () => { + const target: any = { count: 1 }; + target.self = target; + const store = createStore(null, target, StoreFlags.RECURSIVE); + const { state } = eagerDeserialize(await serialize(store)); + const restoredStore = state[0] as typeof store; + const restoredTarget = unwrapStore(restoredStore) as typeof target; + + expect(restoredTarget.count).toBe(1); + expect(restoredTarget.self).toBe(restoredTarget); + }); + + it('resolves forward refs after root allocation', () => { + const { state } = eagerDeserialize([ + TypeIds.ForwardRef, + 0, + TypeIds.ForwardRefs, + [2], + TypeIds.Object, + [TypeIds.Plain, 'answer', TypeIds.Plain, 42], + ]); + + expect(state[0]).toBe(state[2]); + expect(state[0]).toEqual({ answer: 42 }); + }); + + it('restores signal backrefs and computed qrls', async () => { + const sig = createSignal(42) as SignalImpl; + sig.$effects$ = new Set([makeEffect()]); + const computed = createComputed$(() => 1, { serializationStrategy: 'always' }); + const computedImpl = computed as unknown as ComputedSignalImpl; + computedImpl.$effects$ = new Set([makeEffect()]); + const serializer = createSerializer$({ + deserialize: (n?: number) => new MyCustomSerializable(n ?? 3), + serialize: (obj) => obj.n, + }) as unknown as SerializerSignalImpl; + serializer.$effects$ = new Set([makeEffect()]); + + const { state } = eagerDeserialize(await serialize(sig, computedImpl, serializer)); + const restoredSignal = state[0] as SignalImpl; + const restoredComputed = state[1] as ComputedSignalImpl; + const restoredSerializer = state[2] as SerializerSignalImpl; + + const signals = [restoredSignal, restoredComputed, restoredSerializer]; + for (let i = 0; i < signals.length; i++) { + const restored = signals[i]; + const effect = [...restored.$effects$!][0]; + expect(effect.backRef).toBeDefined(); + expect(effect.backRef!.has(restored)).toBe(true); + } + + expect(restoredComputed.$computeQrl$).toBeDefined(); + expect(restoredSerializer.$computeQrl$).toBeDefined(); + }); + }); + describe('deserialize types', () => { it(title(TypeIds.Plain), async () => { const objs = await serialize('', 'hi', ['hi', 123.456]); @@ -1996,7 +2106,7 @@ describe('serializer - internal', () => { }); it('_deserialize', async () => { const ser = await _serialize({ a: 1 }); - const des = _deserialize(ser); + const des = await _deserialize(ser); expect(des).toEqual({ a: 1 }); }); it('_serialize should emit short integer-like plain object keys as numbers', async () => { @@ -2011,7 +2121,7 @@ describe('serializer - internal', () => { expect(ser).toBe( '[5,[0,"0",0,"e",0,123,0,"a",0,1234567,0,"d",0,"12345678",0,"f",0,-45,0,"b",0,"012",0,"c"]]' ); - expect(_deserialize(ser)).toEqual({ + expect(await _deserialize(ser)).toEqual({ 123: 'a', '-45': 'b', '012': 'c', diff --git a/packages/qwik/src/core/shared/utils/markers.ts b/packages/qwik/src/core/shared/utils/markers.ts index 24944980d4c..894e621292c 100644 --- a/packages/qwik/src/core/shared/utils/markers.ts +++ b/packages/qwik/src/core/shared/utils/markers.ts @@ -36,6 +36,7 @@ export const QBaseAttr = 'q:base'; export const QLocaleAttr = 'q:locale'; export const QManifestHashAttr = 'q:manifest-hash'; export const QInstanceAttr = 'q:instance'; +export const QStatePrewarmAttr = 'q:prewarm'; export const QContainerIsland = 'q:container-island'; export const QContainerIslandEnd = '/' + QContainerIsland; export const QIgnore = 'q:ignore'; diff --git a/packages/qwik/src/core/tests/container.spec.tsx b/packages/qwik/src/core/tests/container.spec.tsx index 0951094e256..eb66b289597 100644 --- a/packages/qwik/src/core/tests/container.spec.tsx +++ b/packages/qwik/src/core/tests/container.spec.tsx @@ -4,8 +4,7 @@ import { describe, expect, it } from 'vitest'; import { ssrCreateContainer } from '../../server/ssr-container'; import { SsrNode } from '../../server/ssr-node'; import { createDocument } from '../../testing/document'; -import { getDomContainer } from '../client/dom-container'; -import { whenVNodeDataReady } from '../client/process-vnode-data'; +import { getDomContainer, whenContainerDataReady } from '../client/dom-container'; import type { ClientContainer } from '../client/types'; import { vnode_ensureElementInflated, @@ -590,7 +589,7 @@ async function withContainer( const html = ssrContainer.writer.toString(); // console.log(html); const container = getDomContainer(toDOM(html)); - await whenVNodeDataReady(container.document, () => undefined); + await whenContainerDataReady(container, () => undefined); // console.log(JSON.stringify((container as any).rawStateData, null, 2)); return container; } @@ -644,7 +643,7 @@ function toDOM(html: string): HTMLElement { async function toVNode(containerElement: HTMLElement): Promise { const container = getDomContainer(containerElement); - await whenVNodeDataReady(container.document, () => undefined); + await whenContainerDataReady(container, () => undefined); const vNode = vnode_getFirstChild(container.rootVNode)!; return vNode; } diff --git a/packages/qwik/src/core/tests/render-api.spec.tsx b/packages/qwik/src/core/tests/render-api.spec.tsx index 2b2ebac01e7..afa0a95bb7f 100644 --- a/packages/qwik/src/core/tests/render-api.spec.tsx +++ b/packages/qwik/src/core/tests/render-api.spec.tsx @@ -33,11 +33,11 @@ import type { StreamWriter, StreamingOptions, } from '../../server/types'; -import { whenVNodeDataReady } from '../client/process-vnode-data'; +import { whenContainerDataReady } from '../client/dom-container'; import { vnode_getFirstChild } from '../client/vnode-utils'; import { _fnSignal, type _ContainerElement } from '../internal'; import { QContainerValue } from '../shared/types'; -import { QContainerAttr } from '../shared/utils/markers'; +import { QContainerAttr, QStatePrewarmAttr } from '../shared/utils/markers'; vi.hoisted(() => { vi.stubGlobal('QWIK_LOADER_DEFAULT_MINIFIED', 'min'); @@ -242,7 +242,7 @@ describe('render api', () => { document = createDocument({ html: result.html }); emulateExecutionOfQwikFuncs(document); const container = getDomContainer(document.body.firstChild as HTMLElement); - await whenVNodeDataReady(container.document, () => undefined); + await whenContainerDataReady(container, () => undefined); const vNode = vnode_getFirstChild(container.rootVNode); expect(vNode).toMatchVDOM(