Skip to content

Routeloader split#8501

Draft
wmertens wants to merge 11 commits intobuild/v2from
routeloader-split
Draft

Routeloader split#8501
wmertens wants to merge 11 commits intobuild/v2from
routeloader-split

Conversation

@wmertens
Copy link
Copy Markdown
Member

opening for visibility

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 28, 2026

🦋 Changeset detected

Latest commit: 9cca76c

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

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

@maiieul maiieul moved this to In progress in Qwik Development Mar 28, 2026
@wmertens wmertens changed the base branch from main to build/v2 March 28, 2026 14:53
@wmertens wmertens force-pushed the routeloader-split branch 3 times, most recently from 07ecc7d to 042e959 Compare March 30, 2026 22:11
@wmertens wmertens force-pushed the routeloader-split branch 2 times, most recently from 822296f to 627914d Compare April 1, 2026 22:43
Copy link
Copy Markdown
Member

@Varixo Varixo left a comment

Choose a reason for hiding this comment

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

Great work! Added some comments/questions

@@ -0,0 +1,3 @@
'@qwik.dev/router': minor

Refactor route loaders to be backed by shared async signals across SSR, client refresh, and action invalidation.
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.

format here is wrong

Comment on lines +324 to +326
const routeFiles = node._files
.filter((f) => f.type === 'route' || f.type === 'layout')
.map((f) => f.filePath);
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.

how much performance matters here?

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.

not much, it's build time, so a few ms won't matter

Comment on lines +95 to +123
const result = await runValidators(requestEv, action.__validators, data, devMode);
if (!result.success) {
actionError = requestEv.fail(result.status ?? 500, result.error);
} else {
const actionResolved = devMode
? await measure(requestEv, action.__qrl.getHash(), () =>
action!.__qrl.call(requestEv, result.data as JSONObject, requestEv)
)
: await action.__qrl.call(requestEv, result.data as JSONObject, requestEv);
if (devMode) {
verifySerializable(actionResolved, action.__qrl);
}
if (actionResolved instanceof ServerError) {
actionError = actionResolved;
} else {
actionData = actionResolved;
}
}
} catch (err) {
if (err instanceof ServerError) {
actionError = err;
} else if (err instanceof Error) {
console.error('Action error:', err);
actionError = new ServerError(500, 'Internal Server Error');
} else {
// RedirectMessage, AbortMessage, etc. — re-throw for middleware
throw err;
}
}
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.

maybe wrap with something like executeAction?

Comment on lines +195 to +197
function now() {
return typeof performance !== 'undefined' ? performance.now() : 0;
}
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.

this should be moved to some utils

/** @public */
export const routeLoader$: LoaderConstructor = /*#__PURE__*/ implicit$FirstArg(routeLoaderQrl);

async function runValidators(
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.

similar function in action-handler.ts

if (g._R && loaderHashes) {
loaderHashes.push(...g._R);
if (loaderPathsByHash) {
for (const hash of g._R) {
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.

avoid for of

if (node._R && loaderHashes) {
loaderHashes.push(...node._R);
if (loaderPathsByHash) {
for (const hash of node._R) {
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.

and here too. I wont make more comments about for of, maybe we should enable the eslint rule for qwik router too

params: PathParams;
response: EndpointResponse;
loadedRoute: LoadedRoute;
routeLoaderCtx: import('./route-loaders').RouteLoaderCtx;
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.

we we cant import this type normally?

Comment on lines -66 to -88
[
{ pathname: '/', expect: '/q-data.json' },
{ pathname: '/about', expect: '/about/q-data.json' },
{ pathname: '/about/', expect: '/about/q-data.json' },
].forEach((t) => {
test(`getClientEndpointUrl("${t.pathname}")`, () => {
const endpointPath = getClientDataPath(t.pathname);
assert.equal(endpointPath, t.expect);
});
});

[
{ pathname: '/', search: '?foo=bar', expect: '/q-data.json?foo=bar' },
{ pathname: '/about', search: '?foo=bar', expect: '/about/q-data.json?foo=bar' },
{ pathname: '/about/', search: '?foo=bar', expect: '/about/q-data.json?foo=bar' },
{ pathname: '/about/', search: '?foo=bar&baz=qux', expect: '/about/q-data.json?foo=bar&baz=qux' },
].forEach((t) => {
test(`getClientEndpointUrl("${t.pathname}", "${t.search}")`, () => {
const endpointPath = getClientDataPath(t.pathname, t.search);
assert.equal(endpointPath, t.expect);
});
});

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.

should we move these tests?

@wmertens wmertens force-pushed the routeloader-split branch 3 times, most recently from 4dd423a to d296b44 Compare April 8, 2026 13:40
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

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

@wmertens wmertens force-pushed the routeloader-split branch 2 times, most recently from 0c064d3 to bea64ae Compare April 8, 2026 17:37
wmertens and others added 7 commits April 8, 2026 21:22
Replace the monolithic q-data.json endpoint with per-loader
q-loader-{id}.{manifestHash}.json endpoints. Each routeLoader$
gets its own cacheable JSON endpoint.

Key changes:
- Route trie includes loader hashes per route segment
- New loaderHandler returns individual loader data as {d, r, e} envelope
- New jsonRequestWrapper captures middleware redirects/errors for JSON requests
- Action handler split into separate handlers/action-handler.ts
- Route loader signals use AsyncSignal with reactive tracking of route paths
- SPA navigation awaits loader promises with navCount-based redirect detection
- SSG updated for per-loader endpoints
- Core: export additional internals needed by router spec tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ng, unify wire format

- ActionStore gains .error (ServerError with data spread directly) and .loading
- .value now contains only success data (Exclude<RETURN, ServerError>)
- fail() returns ServerError instead of { failed: true, ...data }
- Action wire format unified to {d, e, s, h, l} envelope
- Non-ServerError throws caught with console.error, sent as generic 500
- Action type gains ERROR type parameter for typed validator errors
- FailReturn deprecated (alias for ServerError<T> & T)
- RedirectMessage/AbortMessage re-thrown (not Error instances)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add jsonRequestWrapper handler that wraps next() in try/catch for
q-loader and q-action JSON requests. Middleware redirects/errors are
captured into JSON envelopes ({r} for loaders, {e,s} for actions)
instead of propagating as HTTP redirects/error pages.

- Loader redirects: returned as {r: url} in LoaderResponse envelope
- Action redirects: re-thrown (client handles via response.redirected)
- Errors: wrapped as ServerError in both loader and action envelopes
- Dev mode: error messages include original error text
- SSR loadersMiddleware: unchanged, errors propagate for middleware
routeLoader$ now accepts an `eTag` option for ETag-based caching of
q-loader-*.json responses:

- `eTag: true` — auto-hash serialized data (loader runs, then checks)
- `eTag: "version"` — static eTag (304 returned before loader runs)
- `eTag: (ev) => string|null` — dynamic from request context (params,
  URL, etc.), 304 returned before loader runs

For string/function eTags, the loader is skipped entirely on cache hit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
routeLoader$ now accepts a `search` option — an allowlist of URL search
parameter names the loader depends on.

When set:
- Only re-fetches when the listed search params change
- Other param changes are ignored (returns previous value)
- Only the listed params are sent in the loader JSON request URL

When not set: all params sent, any change triggers re-fetch (current behavior).

Example: `routeLoader$(fn, { search: ['sort', 'page'] })`

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use page.waitForURL() after SPA navigation clicks before asserting
  content. URL changes complete before loader data renders, providing
  a stable synchronization point.
- Assert loader-dependent values first (they change between routes)
  before checking static values (same title on both routes).
- Increase timeout for loader redirect test (multi-step: fetch →
  {r} envelope → goto).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add strictLoaders Vite plugin option (default: true) that makes loaders
default to search:[] and actions default to invalidate:[].
This maximizes cacheability.

Add allowStale
option to LoaderOptions, passed through to the AsyncSignal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@wmertens wmertens force-pushed the routeloader-split branch from bea64ae to 016112d Compare April 8, 2026 19:22
@wmertens wmertens force-pushed the routeloader-split branch from 8f666d8 to 9cca76c Compare April 9, 2026 06:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants