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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-buckets-slide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/core': minor
---

feat: improve client resume responsiveness by splitting state processing into smaller tasks
2 changes: 1 addition & 1 deletion packages/docs/src/routes/api/qwik-server/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
}
],
"kind": "Interface",
"content": "```typescript\nexport interface RenderOptions extends SerializeDocumentOptions \n```\n**Extends:** [SerializeDocumentOptions](#serializedocumentoptions)\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nbase?\n\n\n</td><td>\n\n\n</td><td>\n\nstring \\| ((options: [RenderOptions](#renderoptions)<!-- -->) =&gt; string)\n\n\n</td><td>\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</td></tr>\n<tr><td>\n\ncontainerAttributes?\n\n\n</td><td>\n\n\n</td><td>\n\nRecord&lt;string, string&gt;\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\ncontainerTagName?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\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</td></tr>\n<tr><td>\n\nlocale?\n\n\n</td><td>\n\n\n</td><td>\n\nstring \\| ((options: [RenderOptions](#renderoptions)<!-- -->) =&gt; string)\n\n\n</td><td>\n\n_(Optional)_ Language to use when rendering the document.\n\n\n</td></tr>\n<tr><td>\n\npreloader?\n\n\n</td><td>\n\n\n</td><td>\n\n[PreloaderOptions](#preloaderoptions) \\| false\n\n\n</td><td>\n\n_(Optional)_ Specifies how preloading is handled. This ensures that code is instantly available when needed.\n\n\n</td></tr>\n<tr><td>\n\nqwikLoader?\n\n\n</td><td>\n\n\n</td><td>\n\n[QwikLoaderOptions](#qwikloaderoptions)\n\n\n</td><td>\n\n_(Optional)_ Specifies how the Qwik Loader is included in the document. This enables interactivity and lazy loading.\n\n`module`<!-- -->: Use a `<script>` tag to load the Qwik Loader. Subsequent page loads will have the script cached and instantly running.\n\n`inline`<!-- -->: This embeds the Qwik Loader script directly in the document. This adds about 3kB before compression, which typically is reduced to about 1.6kB with gzip.\n\n`never`<!-- -->: Do not include the Qwik Loader script. This is mostly useful when embedding multiple containers on the same page.\n\nDefaults to `module`<!-- -->.\n\nNote that the Qwik Loader is absolutely required for Qwik to work. There must be an instance of it loaded for any interactivity to happen.\n\n\n</td></tr>\n<tr><td>\n\nserverData?\n\n\n</td><td>\n\n\n</td><td>\n\nRecord&lt;string, any&gt;\n\n\n</td><td>\n\n_(Optional)_ Metadata that can be retrieved during SSR with `useServerData()`<!-- -->.\n\n\n</td></tr>\n<tr><td>\n\nsnapshot?\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_ Defaults to `true`\n\n\n</td></tr>\n</tbody></table>",
"content": "```typescript\nexport interface RenderOptions extends SerializeDocumentOptions \n```\n**Extends:** [SerializeDocumentOptions](#serializedocumentoptions)\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nbase?\n\n\n</td><td>\n\n\n</td><td>\n\nstring \\| ((options: [RenderOptions](#renderoptions)<!-- -->) =&gt; string)\n\n\n</td><td>\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</td></tr>\n<tr><td>\n\ncontainerAttributes?\n\n\n</td><td>\n\n\n</td><td>\n\nRecord&lt;string, string&gt;\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n<tr><td>\n\ncontainerTagName?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\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</td></tr>\n<tr><td>\n\nlocale?\n\n\n</td><td>\n\n\n</td><td>\n\nstring \\| ((options: [RenderOptions](#renderoptions)<!-- -->) =&gt; string)\n\n\n</td><td>\n\n_(Optional)_ Language to use when rendering the document.\n\n\n</td></tr>\n<tr><td>\n\npreloader?\n\n\n</td><td>\n\n\n</td><td>\n\n[PreloaderOptions](#preloaderoptions) \\| false\n\n\n</td><td>\n\n_(Optional)_ Specifies how preloading is handled. This ensures that code is instantly available when needed.\n\n\n</td></tr>\n<tr><td>\n\nqwikLoader?\n\n\n</td><td>\n\n\n</td><td>\n\n[QwikLoaderOptions](#qwikloaderoptions)\n\n\n</td><td>\n\n_(Optional)_ Specifies how the Qwik Loader is included in the document. This enables interactivity and lazy loading.\n\n`module`<!-- -->: Use a `<script>` tag to load the Qwik Loader. Subsequent page loads will have the script cached and instantly running.\n\n`inline`<!-- -->: This embeds the Qwik Loader script directly in the document. This adds about 3kB before compression, which typically is reduced to about 1.6kB with gzip.\n\n`never`<!-- -->: Do not include the Qwik Loader script. This is mostly useful when embedding multiple containers on the same page.\n\nDefaults to `module`<!-- -->.\n\nNote that the Qwik Loader is absolutely required for Qwik to work. There must be an instance of it loaded for any interactivity to happen.\n\n\n</td></tr>\n<tr><td>\n\nserverData?\n\n\n</td><td>\n\n\n</td><td>\n\nRecord&lt;string, any&gt;\n\n\n</td><td>\n\n_(Optional)_ Metadata that can be retrieved during SSR with `useServerData()`<!-- -->.\n\n\n</td></tr>\n<tr><td>\n\nsnapshot?\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\n\n_(Optional)_ Defaults to `true`\n\n\n</td></tr>\n<tr><td>\n\nstatePrewarm?\n\n\n</td><td>\n\n\n</td><td>\n\nnumber \\| false\n\n\n</td><td>\n\n_(Optional)_ Root-count threshold for eager yielded state prewarm during client resume.\n\nDefaults to `false`<!-- -->, keeping state fully lazy. Set to a number to enable eager prewarm when serialized state has at least that many roots.\n\n\n</td></tr>\n</tbody></table>",
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/server/types.ts",
"mdFile": "core.renderoptions.md"
},
Expand Down
17 changes: 17 additions & 0 deletions packages/docs/src/routes/api/qwik-server/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,23 @@ boolean

_(Optional)_ Defaults to `true`

</td></tr>
<tr><td>

statePrewarm?

</td><td>

</td><td>

number \| false

</td><td>

_(Optional)_ Root-count threshold for eager yielded state prewarm during client resume.

Defaults to `false`, keeping state fully lazy. Set to a number to enable eager prewarm when serialized state has at least that many roots.

</td></tr>
</tbody></table>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
title: State Prewarm | Advanced
---

import { Note } from '~/components/note/note';

# State Prewarm

Qwik resumes an SSR-rendered application from serialized state in the HTML. By default, that state
stays lazy in the browser: Qwik parses the state payload, but individual state roots are
deserialized only when code reads them.

This keeps startup work low, which is usually the best default. On very large pages, however, the
first read can touch a large connected state graph. In that case the lazy read can turn into one
large synchronous task. `statePrewarm` is an opt-in render option that performs that state work
during client resume and slices it into yielded tasks.

## What It Does

When `statePrewarm` is enabled, Qwik eagerly deserializes the container state as part of client
resume.

- The number is a root-count threshold.
- If the serialized state has fewer roots than the threshold, state remains lazy.
- If the serialized state has at least that many roots, Qwik deserializes the state eagerly during
resume.
- The work is yielded across small tasks, so the browser can stay responsive.

`statePrewarm` does not load all QRL chunks or run components. It only
prepares serialized state so later synchronous state reads are cheaper.

<Note>
The default is `false`. With no `statePrewarm` option, Qwik keeps serialized state lazy.
</Note>

## When To Use It

Use `statePrewarm` only when profiling shows that the first state access creates a long task during
resume. This is most useful for pages with a large serialized state graph, such as data-heavy product
pages, dashboards, search pages, or large routed documents.

It is usually not needed for small or typical pages. Prewarming can increase the amount of work done
during client resume, even though that work is split into smaller tasks.

Good reasons to enable it:

- A performance trace shows a long deserialize or state-inflate task after the first state read.
- The page serializes thousands of state roots.
- The page feels blocked soon after resume, and the stack points to state deserialization.

Reasons to keep it disabled:

- The page has little serialized state.
- The state is rarely read on startup.
- You want the smallest possible amount of client work during resume.

## Setting The Threshold

Set `statePrewarm` on the server render options. The value is the minimum number of serialized state
roots required before Qwik prewarms the state.

```tsx
import { renderToStream, type RenderOptions } from '@qwik.dev/core/server';
import Root from './root';

export default function (opts: RenderOptions) {
return renderToStream(<Root />, {
...opts,
statePrewarm: 2048,
});
}
```

The same option is available with `renderToString`:

```tsx
import { renderToString, type RenderOptions } from '@qwik.dev/core/server';
import Root from './root';

export default function (opts: RenderOptions) {
return renderToString(<Root />, {
...opts,
statePrewarm: 2048,
});
}
```

In Qwik Router applications, set `statePrewarm` in the server entry or custom renderer where
`renderToStream` receives the adapter-provided render options:

```tsx
export default function (opts: RenderOptions) {
return renderToStream(<Root />, {
...opts,
statePrewarm: 2048,
});
}
```

Use a lower number to prewarm smaller state payloads:

```tsx
renderToStream(<Root />, {
...opts,
statePrewarm: 512,
});
```

Use `0` to prewarm any non-empty serialized state:

```tsx
renderToStream(<Root />, {
...opts,
statePrewarm: 0,
});
```

## Disabling It

The default is disabled, so most applications do not need to set anything.

```tsx
renderToStream(<Root />, {
...opts,
statePrewarm: false,
});
```

`false` and `undefined` both mean that Qwik will not emit the internal prewarm marker and the
browser will keep state lazy.

## Choosing A Value

Start with a conservative value such as `2048` and compare traces before and after the change. The
goal is not to reduce total deserialization work. The goal is to avoid one large synchronous task by
doing the state work earlier in the resume process and slicing it.

After rebuilding and cache-busting the app, check:

- Whether long tasks caused by state deserialization disappear.
- Whether state work during resume is split into smaller tasks.
- Whether the additional resume work is acceptable for the page.

If the page still has long tasks after enabling `statePrewarm`, inspect the trace for other causes
such as layout, rendering, third-party scripts, image decode, or application microtasks.
1 change: 1 addition & 0 deletions packages/docs/src/routes/docs/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

- [The $ dollar sign](</docs/(qwik)/advanced/dollar/index.mdx>)
- [Containers](</docs/(qwik)/advanced/containers/index.mdx>)
- [State Prewarm](</docs/(qwik)/advanced/state-prewarm/index.mdx>)
- [QRL](</docs/(qwik)/advanced/qrl/index.mdx>)
- [Library mode](</docs/(qwik)/advanced/library/index.mdx>)
- [Qwikloader](</docs/(qwik)/advanced/qwikloader/index.mdx>)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,13 +423,13 @@ const parseRequest = async (
const data = query.get(deps.QDATA_KEY);
if (data) {
try {
return _deserialize(decodeURIComponent(data)) as JSONValue;
return (await _deserialize(decodeURIComponent(data))) as JSONValue;
} catch {
//
}
}
}
return _deserialize(await request.text()) as JSONValue;
return (await _deserialize(await request.text())) as JSONValue;
}
return undefined;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,8 @@ export const useQwikRouter = (props?: QwikRouterProps) => {
historyUpdated = true;
}

actionState.value = undefined;

routeInternal.value = {
type,
dest,
Expand All @@ -407,7 +409,6 @@ export const useQwikRouter = (props?: QwikRouterProps) => {
loadRoute(qwikRouterConfig.routes, qwikRouterConfig.cacheModules, dest.pathname);
}

actionState.value = undefined;
routeLocation.isNavigating = true;

return new Promise<void>((resolve) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/qwik-router/src/runtime/src/server-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ export const serverQrl = <T extends ServerFunction>(
})();
} else if (contentType === 'application/qwik-json') {
const str = await res.text();
const obj = _deserialize(str);
const obj = await _deserialize(str);
if (res.status >= 400) {
throw obj;
}
Expand Down Expand Up @@ -575,7 +575,7 @@ const deserializeStream = async function* (
const lines = buffer.split(/\n/);
buffer = lines.pop()!;
for (const line of lines) {
const deserializedData = _deserialize(line);
const deserializedData = await _deserialize(line);
yield deserializedData;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/qwik-router/src/runtime/src/use-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export const loadClientData = async (
}
if ((rsp.headers.get('content-type') || '').includes('json')) {
// we are safe we are reading a q-data.json
return rsp.text().then((text) => {
const clientData = _deserialize<ClientPageData>(text);
return rsp.text().then(async (text) => {
const clientData = await _deserialize<ClientPageData>(text);
if (!clientData) {
// Something went wrong, show to the user
location.href = url.href;
Expand Down
55 changes: 42 additions & 13 deletions packages/qwik/src/core/client/dom-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { QError, qError } from '../shared/error/error';
import { ERROR_CONTEXT, isRecoverable } from '../shared/error/error-handling';
import type { QRL } from '../shared/qrl/qrl.public';
import { wrapDeserializerProxy } from '../shared/serdes/deser-proxy';
import { getObjectById, parseQRL, preprocessState } from '../shared/serdes/index';
import { eagerDeserializeStateIterator } from '../shared/serdes/inflate';
import { getObjectById, parseQRL } from '../shared/serdes/index';
import { preprocessStateIterator } from '../shared/serdes/preprocess-state';
import { _SharedContainer } from '../shared/shared-container';
import { QContainerValue, type HostElement, type ObjToProxyMap } from '../shared/types';
import { EMPTY_ARRAY } from '../shared/utils/flyweight';
Expand All @@ -26,6 +28,7 @@ import {
QLocaleAttr,
QManifestHashAttr,
QScopedStyle,
QStatePrewarmAttr,
QStyle,
QStyleSelector,
QStylesAllSelector,
Expand Down Expand Up @@ -61,6 +64,9 @@ import {
vnode_newUnMaterializedElement,
vnode_setProp,
} from './vnode-utils';
import { processStateData } from './process-state-data';
export { onContainerDataReady, whenContainerDataReady } from './process-state-data';
import { ContainerDataProcessState, isContainerReady } from './process-container-state-utils';

/** @public */
export function getDomContainer(element: Element): IClientContainer {
Expand All @@ -85,6 +91,18 @@ export const isDomContainer = (container: any): container is DomContainer => {
return container instanceof DomContainer;
};

export const processContainerData = (container: IClientContainer): void => {
const domContainer = container as DomContainer;
const state = domContainer.$containerDataProcessState$;
if (state === ContainerDataProcessState.ProcessingState || isContainerReady(domContainer)) {
return;
}
processVNodeData(domContainer);
onVNodeDataReady(domContainer, () => {
processStateData(domContainer);
});
};

/** @internal */
export class DomContainer extends _SharedContainer implements IClientContainer {
public element: ContainerElement;
Expand All @@ -97,6 +115,11 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
public $instanceHash$: string;
public $forwardRefs$: Array<number> | null = null;
public vNodeLocate: (id: string | Element) => VNode = (id) => vnode_locate(this.rootVNode, id);
public $containerDataProcessState$ = ContainerDataProcessState.NotStarted;
public $containerVNodeReadyCallbacks$: Array<() => unknown | Promise<unknown>> | undefined =
undefined;
public $containerStateReadyCallbacks$: Array<() => unknown | Promise<unknown>> | undefined =
undefined;

private $rawStateData$: unknown[];
private $stateData$: unknown[];
Expand All @@ -108,24 +131,23 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
if (!this.qContainer) {
throw qError(QError.elementWithoutContainer);
}
this.document = element.ownerDocument as QDocument;
const document = element.ownerDocument as QDocument;
this.document = document;
this.element = element;
this.$buildBase$ = element.getAttribute(QBaseAttr)!;
this.$instanceHash$ = element.getAttribute(QInstanceAttr)!;
this.qManifestHash = element.getAttribute(QManifestHashAttr)!;
this.rootVNode = vnode_newUnMaterializedElement(this.element);
this.$rawStateData$ = [];
this.$stateData$ = [];
const document = this.element.ownerDocument as QDocument;
processVNodeData(document);
this.$qFuncs$ = getQFuncs(document, this.$instanceHash$) || EMPTY_ARRAY;
this.$setServerData$();
element.qContainer = this;
(element as any).qDestroy = () => this.$destroy$();
onVNodeDataReady(document, () => this.$finalizeResume$());
element.qDestroy = () => this.$destroy$();
processContainerData(this);
}

private $finalizeResume$(): void {
*$processContainerData$(): Generator<void, void, void> {
const element = this.element;
if (element.qContainer !== this) {
return;
Expand All @@ -134,8 +156,14 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
if (qwikStates.length !== 0) {
const lastState = qwikStates[qwikStates.length - 1];
this.$rawStateData$ = JSON.parse(lastState.textContent!);
preprocessState(this.$rawStateData$, this);
this.$stateData$ = wrapDeserializerProxy(this, this.$rawStateData$) as unknown[];
yield* preprocessStateIterator(this.$rawStateData$, this);
const rootCount = this.$rawStateData$.length / 2;
const statePrewarm = element.getAttribute(QStatePrewarmAttr);
if (statePrewarm !== null && rootCount > 0 && rootCount >= Number(statePrewarm)) {
this.$stateData$ = yield* eagerDeserializeStateIterator(this, this.$rawStateData$);
} else {
this.$stateData$ = wrapDeserializerProxy(this, this.$rawStateData$) as unknown[];
}
}
this.$hoistStyles$();
element.setAttribute(QContainerAttr, QContainerValue.RESUMED);
Expand All @@ -157,10 +185,11 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
el.removeAttribute(QContainerAttr);
const document = el.ownerDocument as QDocument;
document.qVNodeData = undefined!;
document.qVNodeDataStarted = false;
document.qVNodeDataReady = false;
document.qVNodeDataState = undefined;
document.qVNodeDataCallbacks = undefined;
// document.qVNodeDataStarted = false;
// document.qVNodeDataReady = false;
// document.qVNodeDataState = undefined;
// document.qVNodeDataCallbacks = undefined;
this.$containerDataProcessState$ = ContainerDataProcessState.NotStarted;
}

/**
Expand Down
2 changes: 0 additions & 2 deletions packages/qwik/src/core/client/dom-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { vnode_setProp } from './vnode-utils';
import { markVNodeDirty } from '../shared/vnode/vnode-dirty';
import { ChoreBits } from '../shared/vnode/enums/chore-bits.enum';
import { NODE_DIFF_DATA_KEY } from '../shared/cursor/cursor-props';
import { whenVNodeDataReady } from './process-vnode-data';

/**
* Render JSX.
Expand Down Expand Up @@ -44,7 +43,6 @@ export const render = async (
(parent as Element).setAttribute(QContainerAttr, QContainerValue.RESUMED);

const container = getDomContainer(parent as HTMLElement) as DomContainer;
await whenVNodeDataReady(container.document, () => undefined);
container.$serverData$ = opts.serverData || {};
const host = container.rootVNode;
vnode_setProp(host, NODE_DIFF_DATA_KEY, jsxNode);
Expand Down
Loading
Loading