QwikDev / QwikDev/qwik

OOOS: a component that only renders a <Suspense> (deferred widget / page skeleton) loses q:renderFn and can never re-render after resume

Open
#8,876 2 comments 0 reactions 0 assignees View on GitHub

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 drops q:renderFn/q:props/q:seq/q:id for the vnode.
  • packages/qwik/src/server/ssr-container.ts (addRoot): once $noMoreRoots$ is set it returns $hasRootId$(obj)undefined for 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.