Skip to content

feat(router): blockSSR#8793

Open
wmertens wants to merge 3 commits into
build/v2from
action-redirect
Open

feat(router): blockSSR#8793
wmertens wants to merge 3 commits into
build/v2from
action-redirect

Conversation

@wmertens

@wmertens wmertens commented Jul 2, 2026

Copy link
Copy Markdown
Member

Routeloaders block SSR by default and can be opted-out.

Copilot AI review requested due to automatic review settings July 2, 2026 23:09
@wmertens wmertens requested review from a team as code owners July 2, 2026 23:09
@changeset-bot

changeset-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d57a664

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@qwik.dev/router Minor
eslint-plugin-qwik Minor
@qwik.dev/core Minor
create-qwik Minor
@qwik.dev/react Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new blockSSR option to Qwik Router route loaders so SSR can either await loaders by default (allowing redirect/error to short-circuit) or opt out and run loaders in the background without affecting the page response.

Changes:

  • Introduces LoaderOptions.blockSSR (default true) and propagates it through loader internals.
  • Updates loader execution to detach response controls for blockSSR: false loaders and adds middleware/unit/e2e/docs coverage.
  • Adds a changeset announcing the new router feature.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/qwik/src/core/shared/serdes/serialize.ts Adds a TODO comment related to async serialization behavior.
packages/qwik-router/src/runtime/src/types.ts Adds blockSSR to loader options and internal loader shape.
packages/qwik-router/src/runtime/src/route-loaders.ts Implements response-detaching for background loaders and threads blockSSR into server capture.
packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts Adds unit tests covering blocking vs background loader SSR behavior.
packages/qwik-router/src/middleware/request-handler/resolve-request-handlers-core.ts Changes loader middleware to start loaders and await only blocking loaders before SSR.
packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx Documents default SSR-blocking behavior and the new “background loaders” option.
e2e/qwik-e2e/tests/qwikrouter/loaders.e2e.ts Adds an e2e regression test ensuring unread background loaders don’t fail SSR.
e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/non-blocking/index.tsx Adds an e2e fixture route that throws from a background loader without reading .value.
.changeset/lazy-route-loaders.md Declares a minor release for @qwik.dev/router to ship blockSSR.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/qwik-router/src/runtime/src/route-loaders.ts
Comment thread packages/qwik/src/core/shared/serdes/serialize.ts
@maiieul

maiieul commented Jul 3, 2026

Copy link
Copy Markdown
Member

Comparing the two shapes for deferred/streamed loader data — a defer-marked loader (its own request) vs a bare Promise field returned inside a loader's value (rides the parent's request). They're not substitutes; each has real exclusives that follow from which side of the request boundary the streamed unit sits on.

Capability comparison

Capability defer loaders bare-promise fields
Per-unit HTTP caching (cacheKey / eTag / CDN, cross-route cache entry) ✓ per loader ✓ per loader (just split it out) · ✗ per field
Independent revalidation after an action ✓ per loader ✓ per loader · ✗ per field
Typed .error (ServerError envelope, prod redaction, closest-ErrorBoundary routing) per streamed unit ✓ per loader ✓ per loader · ✗ per field
Static knowability (build-time envelope enforcement, pre-head await set known without executing, typed RequestEvent with no status/redirect) ✓ exclusive ✗ discovered only after the loader body runs; runtime guard only
Ships on existing machinery (whole-signal OOOS streaming) ✗ (needs a pending-promise wire protocol — the serializer currently await-flattens nested promises)
Co-location (streamed work closes over critical data, no resolveValue / embedded re-run) ✓ exclusive
Declarations needed for critical + streamed ≥ 2 loaders 1 loader
Streamed units share one request / one middleware run (SPA nav) ✗ one request per deferred loader fields ride the parent's request
Dynamic deferral (await the fast path / stream the slow path per request) ✗ (bot/SEO case is better a framework knob) ✓ (niche)
Dynamic cardinality (N streamed units, N known only at runtime) ✗ inexpressible ✓ exclusive

The one thing only bare promises can express: dynamic cardinality

A search page: fast ID query, slow per-item hydration, and you want each result to pop in individually as its own fetch settles.

// bare-promise fields — one streamed unit PER ITEM, count known only at runtime
export const useSearch = routeLoader$(async (ev) => {
  const ids = await searchIds(ev.query.get('q'));         // fast, critical, envelope-capable
  return { ids, items: ids.map((id) => fetchItem(id)) };  // N bare promises, N = runtime
});

// render: each card reveals as ITS OWN promise settles
{data.value.ids.map((id, i) => (
  <Await key={id} promise={data.value.items[i]} fallback={<SkeletonCard />}>
    {(item) => <ProductCard item={item} />}
  </Await>
))}

Why defer loaders can't match it:

// Attempt 1 — one deferred loader with Promise.all: loses per-item streaming
export const useItems = routeLoader$(async (ev) => {
  const ids = await ev.resolveValue(useSearchIds);
  return Promise.all(ids.map(fetchItem));  // signal resolves when the SLOWEST item does
}, { defer: true });                        // → the whole grid pops at once, no per-item pop-in

// Attempt 2 — one loader per item: impossible
// loaders are static module-level exports; you can't declare useItem_0 … useItem_N
// when N depends on the request. One loader = one signal; there is no per-instance
// parameterization of a routeLoader$.

The unit of deferral for a defer loader is the loader, and loaders are statically declared — so a runtime-determined number of independently-streamed units is inexpressible. A bare promise's unit is a value, and values are dynamic (the serializer already walks values recursively, so promises inside arrays are natural for it).

@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@qwik.dev/core

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/core@8793

@qwik.dev/router

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/router@8793

eslint-plugin-qwik

npm i https://pkg.pr.new/QwikDev/qwik/eslint-plugin-qwik@8793

create-qwik

npm i https://pkg.pr.new/QwikDev/qwik/create-qwik@8793

@qwik.dev/optimizer

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/optimizer@8793

@qwik.dev/devtools

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/devtools@8793

commit: d57a664

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor
built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
qwik-docs ✅ Ready (View Log) Visit Preview d57a664

Comment on lines +335 to +337
allBlockSSRLoaders = (
allBlockSSRLoaders ? allBlockSSRLoaders.then(() => promise) : promise
) as Promise<void>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why no Promise.all?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this way, the first error in route order will throw even if there are multiple errors.
Also, the loads all start at the same time so the .then() chaining doesn't cause a waterfall

Comment thread .changeset/lazy-route-loaders.md Outdated
'@qwik.dev/router': minor
---

route loaders gain a `blockSSR` option (default `true`); set `blockSSR: false` to run a loader in the background without blocking SSR

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there should be "feat:" prefix

@jordanw66

jordanw66 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

In the PR it mentions the recommended setting for loaders is blockSSR: false

Should that be the default instead? Or less radically, could there be a global setting to change the default to false?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Waiting For Review

Development

Successfully merging this pull request may close these issues.

5 participants