Skip to content

Routeloader split#8501

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

Routeloader split#8501
wmertens wants to merge 10 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: 7488793

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 thread packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts Outdated
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 7488793

@wmertens wmertens force-pushed the routeloader-split branch 4 times, most recently from 8f666d8 to 9cca76c Compare April 9, 2026 06:14
wmertens and others added 5 commits April 17, 2026 22:32
Replace the 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}`
- New jsonRequestWrapper captures middleware redirects/errors for JSON requests
- Action handler split into separate `handlers/action-handler.ts`
- Route loaders are AsyncSignal, tracking their 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
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>
@wmertens wmertens force-pushed the routeloader-split branch from ce335be to 3e44f25 Compare April 17, 2026 20:44
wmertens and others added 2 commits April 17, 2026 23:36
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 and others added 3 commits April 17, 2026 23:36
When variable migration collected transitive dependencies for a root
variable being migrated to a single segment, it didn't check whether
those transitive deps were also directly used by other segments. This
caused the deps to be migrated (inlined) into one segment and their
exports removed from the parent module, leaving other segments with
dangling references.

In qwik-router, this manifested as `currentScrollState` and
`saveScrollHistory` being migrated to the useTask segment (as transitive
deps of other helpers) while the goto segment also needed them, causing
ReferenceErrors at runtime during SPA navigation.

The fix adds a check in the safety filter of `find_migratable_vars`:
if a candidate variable appears in `root_var_usage` for any segment
other than the migration target, it is excluded from migration.
it's a bit of a workaround but it's server-side only and it is needed for robustness against bundling issues
Preserve AsyncSignal subscriptions across a loader-driven redirect by
returning `previous` instead of throwing — an error-state AsyncSignal
can drop its Resource subscription, which would prevent the
redirect-target fetch from updating the UI. The UX trade-off (a brief
flash of stale data before the new route's loaders resolve) is
intentional; awaiting every loader promise on every nav to catch the
rare redirect case is too costly.

- Gate the welcome-redirect assertions in loaders.e2e.ts on MPA only,
  since SPA races the commit against the redirect nav and doesn't
  guarantee the composed loader re-resolves with fresh data.
- Refresh the SSG snapshot fixtures for the new serialized state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@wmertens wmertens force-pushed the routeloader-split branch from 3e44f25 to 7488793 Compare April 17, 2026 21:36
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