OOOS: a component that only renders a <Suspense> (deferred widget / page skeleton) loses q:renderFn and can never re-render after resume
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 22.1k
- Forks
- 1.4k
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 52
Description
What is it?
- Bug
Description
Under out-of-order streaming, a component whose root JSX is the <Suspense> itself resumes with no q:renderFn / q:props on its vnode. The client identifies component hosts via OnRenderProp, so markVNodeDirty(..., ChoreBits.COMPONENT) can never re-execute that component for the rest of the page's life. Any state change that should re-render it (signal write, key bump on a child) is a silent no-op.
Reproduces on main (verified at d54b5d436) with no error-boundary code involved. In-order streaming passes; OOOS fails.
Mechanism
packages/qwik/src/server/ssr-container.ts(writeFragmentAttrs):const rootId = this.addRoot(rawValue); if (rootId === undefined) { continue; }— silently dropsq:renderFn/q:props/q:seq/q:idfor the vnode.packages/qwik/src/server/ssr-container.ts(addRoot): once$noMoreRoots$is set it returns$hasRootId$(obj)—undefinedfor anything not already a root. Under OOOS the root state script flushes before deferred segments resolve, so anything first reachable through segment content never becomes a root and its vnode data is emitted bare.
Observed vnode data for the broken author: {{2|q:type|C@A8_2||6A}|q:type|C@A8_3} (key only) vs the working shape {1|q:type|C<14>45@Iq_915^16[17=13}` (renderFn 14, props 45).
Any shell element above the <Suspense> in the author's JSX makes the author a root before the freeze and everything works — which is why this stayed hidden.
Repro (vitest spec)
import { component$, Suspense, useSignal, type JSXOutput, type Signal } from '@qwik.dev/core';
import { ssrRenderToDom, trigger } from '@qwik.dev/core/testing';
import { describe, expect, it } from 'vitest';
import { isServerPlatform } from '../shared/platform/platform';
import { delay } from '../shared/utils/promises';
const OOOS_OPT_IN = {
streaming: { inOrder: { strategy: 'disabled' as const }, outOfOrder: true },
};
const IN_ORDER = { streaming: { outOfOrder: false } };
const mounts = { count: 0 };
const Deferred = component$<{ attempt: Signal<number> }>((props) => {
if (isServerPlatform()) {
return delay(10).then(() => (
<button id="retry" onClick$={() => props.attempt.value++}>
mount 0
</button>
)) as unknown as JSXOutput;
}
mounts.count++;
return (
<button id="retry" onClick$={() => props.attempt.value++}>
mount {mounts.count}
</button>
);
});
// Author renders nothing but the Suspense; only a remount can prove it re-rendered.
const DeferOnlyKeyApp = component$(() => {
const attempt = useSignal(0);
return (
<Suspense fallback={<span id="skel">loading</span>}>
<Deferred key={attempt.value} attempt={attempt} />
</Suspense>
);
});
describe('author-only-Suspense remount', () => {
it.each([
['in-order', IN_ORDER],
['ooos', OOOS_OPT_IN],
])('%s', async (_m, opts) => {
mounts.count = 0;
const { container } = await ssrRenderToDom(<DeferOnlyKeyApp />, { debug: false, ...opts });
const el = container.element;
expect(el.querySelector('#retry')?.textContent).toContain('mount 0');
await trigger(el, '#retry', 'click');
expect(el.querySelector('#retry')?.textContent).toContain('mount 1');
});
});
in-order passes, ooos fails with expected 'mount 0' to contain 'mount 1'.
Fix directions
Either defer the root vnode-data emission until deferred segments resolve, or backfill the missing component refs through the existing q:patch vnode script (today it only carries element-level data).
Found while migrating the ErrorBoundary e2e suite to a router app (the error-handling e2e work on #8745); three reset-family tests hit it because their fixture authors render only a <Suspense>.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/qwik/src/server/ssr-container.ts, tracing writeFragmentAttrs and addRoot through the OOOS path, especially the $noMoreRoots$ case. Add the provided Vitest reproduction for an app whose root is only Suspense, then run it in both in-order and out-of-order modes. Done means the deferred component re-renders after the retry click in both modes without regressing the in-order case.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100