From 7244e3e5fbdef9bd0880c5f6f9b18cb8566c0648 Mon Sep 17 00:00:00 2001 From: Varixo Date: Wed, 1 Jul 2026 19:06:22 +0200 Subject: [PATCH] fix: prevent cold dev route loader requests from failing during SPA navigation --- .changeset/floppy-pandas-count.md | 5 +++ .../handlers/loader-handler.ts | 5 ++- .../handlers/loader-handler.unit.ts | 17 +++++++++- .../src/runtime/src/prefetch-route.ts | 4 +-- .../src/runtime/src/route-loaders.ts | 32 ++++++++++++++++--- .../src/runtime/src/use-endpoint.unit.ts | 26 +++++++++++++++ 6 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 .changeset/floppy-pandas-count.md diff --git a/.changeset/floppy-pandas-count.md b/.changeset/floppy-pandas-count.md new file mode 100644 index 00000000000..f4aafbcc762 --- /dev/null +++ b/.changeset/floppy-pandas-count.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +fix: prevent cold dev route loader requests from failing during SPA navigation diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts index 372d3912288..b6b1a9c237a 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts @@ -1,3 +1,4 @@ +import { isDev } from '@qwik.dev/core'; import { _serialize } from '@qwik.dev/core/internal'; import { FULLPATH_HEADER, @@ -42,7 +43,9 @@ export function loaderHandler( return; } - setLoaderData(requestEv, routeLoaders, loaderPaths); + const activeRouteLoaders = + isDev && !routeLoaders.includes(loader) ? [...routeLoaders, loader] : routeLoaders; + setLoaderData(requestEv, activeRouteLoaders, loaderPaths); const loaderRequestEv = createLoaderRequestEventFactory(requestEv)(loader); diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts index ec13011c0a5..0dbdb1ff3c8 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { FULLPATH_HEADER } from '../../../runtime/src/route-loaders'; +import { FULLPATH_HEADER, routeLoaderQrl } from '../../../runtime/src/route-loaders'; import { getLoaderName, IsQLoader, QLoaderId } from '../request-path'; import { loaderHandler } from './loader-handler'; @@ -120,6 +120,21 @@ describe('loaderHandler', () => { expect(requestEv.send).toHaveBeenCalledWith(200, expect.any(String)); }); + it('runs a dev registered loader that was not exported by the route module', async () => { + const requestEv = createRequestEv(); + const qrl = { + call: vi.fn(async () => 'registered-loader-value'), + getHash: () => 'loader-id', + getSymbol: () => 'loader-id', + }; + routeLoaderQrl(qrl as any); + + await loaderHandler([])(requestEv as any); + + expect(qrl.call).toHaveBeenCalledOnce(); + expect(requestEv.send).toHaveBeenCalledWith(200, expect.any(String)); + }); + it('treats empty normalized eTags as absent', async () => { const requestEv = createRequestEv(); const cacheKey = vi.fn(() => null); diff --git a/packages/qwik-router/src/runtime/src/prefetch-route.ts b/packages/qwik-router/src/runtime/src/prefetch-route.ts index db3a69e3956..1b9a93f171a 100644 --- a/packages/qwik-router/src/runtime/src/prefetch-route.ts +++ b/packages/qwik-router/src/runtime/src/prefetch-route.ts @@ -1,5 +1,5 @@ import * as qwikRouterConfig from '@qwik-router-config'; -import { isBrowser } from '@qwik.dev/core'; +import { isBrowser, isDev } from '@qwik.dev/core'; // @ts-expect-error no types for preloader yet import { p as preload } from '@qwik.dev/core/preloader'; import { ensureSlash } from '../../utils/pathname'; @@ -25,7 +25,7 @@ export async function prefetchRoute( manifestHash?: string, prefetchBundle = true ) { - if (!isBrowser) { + if (!isBrowser || isDev) { return; } diff --git a/packages/qwik-router/src/runtime/src/route-loaders.ts b/packages/qwik-router/src/runtime/src/route-loaders.ts index cbbe7010a47..31e68dde587 100644 --- a/packages/qwik-router/src/runtime/src/route-loaders.ts +++ b/packages/qwik-router/src/runtime/src/route-loaders.ts @@ -169,6 +169,24 @@ const isRequestEvent = (value: unknown): value is RequestEvent => const isLoaderInternal = (value: unknown): value is LoaderInternal => typeof value === 'function' && (value as LoaderInternal).__brand === 'server_loader'; +const getDevRouteLoaderRegistry = () => { + if (!isDev || !isServer) { + return undefined; + } + const registryKey = Symbol.for('qwik.dev.router.route-loaders'); + return ((globalThis as any)[registryKey] ||= new Map()) as Map< + string, + LoaderInternal + >; +}; + +const registerDevRouteLoader = (loader: LoaderInternal) => { + if (!isDev) { + return; + } + getDevRouteLoaderRegistry()?.set(loader.__id, loader); +}; + /** * Fetch a single loader's data from the server. * @@ -203,7 +221,7 @@ export const fetchRouteLoaderData = async ( const url = `${pathBase}${getLoaderName(loaderId, manifestHash)}${search}`; const headers: Record = {}; - if (pageUrl && pageUrl.pathname !== pathBase && !globalThis.__STRICT_LOADERS__) { + if (pageUrl && pageUrl.pathname !== pathBase) { headers[FULLPATH_HEADER] = pageUrl.pathname; } @@ -621,8 +639,7 @@ export const ensureRouteLoaderSignals = ( for (let i = 0; i < loaders.length; i++) { const loader = loaders[i]; // Dev-only safety net for the first SPA nav: the route module isn't transformed yet, so the - // client trie has no _R loader hash and the loader would resolve to undefined. Seed the page - // path (where the loader runs), only filling genuine gaps so a trie-resolved path is kept. + // client trie has no _R loader hash and the loader would resolve to undefined. if (isDev && !isServer) { if (routeLoaderCtx.pagePathname && routeLoaderCtx.loaderPaths[loader.__id] === undefined) { routeLoaderCtx.loaderPaths[loader.__id] = routeLoaderCtx.pagePathname; @@ -648,7 +665,11 @@ export const resolveRouteLoaderByHash = ( routeLoaders: readonly LoaderInternal[], loaderId: string ) => { - return routeLoaders.find((loader) => loader.__id === loaderId); + // Cold dev q-loader requests can know an id before route-local scans see the loader object. + return ( + routeLoaders.find((loader) => loader.__id === loaderId) ?? + (isDev ? getDevRouteLoaderRegistry()?.get(loaderId) : undefined) + ); }; /** Run a loader and return its raw value. Errors/redirects propagate as exceptions. */ @@ -828,6 +849,9 @@ export const routeLoaderQrl = (( loader.__search = search; loader.__allowStale = allowStale; Object.freeze(loader); + if (isDev) { + registerDevRouteLoader(loader); + } return loader; }) as LoaderConstructorQRL; diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.unit.ts b/packages/qwik-router/src/runtime/src/use-endpoint.unit.ts index c6ccb251295..9e4b4f566e5 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.unit.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.unit.ts @@ -4,10 +4,13 @@ import { getLoaderName } from '../../middleware/request-handler/request-path'; import { FULLPATH_HEADER, fetchRouteLoaderData } from './route-loaders'; import { submitAction } from './use-endpoint'; +const previousStrictLoaders = globalThis.__STRICT_LOADERS__; + describe('submitAction', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + globalThis.__STRICT_LOADERS__ = previousStrictLoaders; }); const makeJsonResponse = async (payload: object, status = 200) => @@ -129,6 +132,29 @@ describe('fetchRouteLoaderData', () => { ); }); + it('sends X-Qwik-fullpath for strict root loader requests on deeper page paths', async () => { + globalThis.__STRICT_LOADERS__ = true; + const fetchSpy = vi.fn().mockResolvedValue( + new Response('', { + status: 404, + }) + ); + vi.stubGlobal('fetch', fetchSpy); + + await fetchRouteLoaderData('root-loader', '/', 'manifest-hash', { + pageUrl: new URL('http://localhost/products/123/?view=full'), + }); + + expect(fetchSpy).toHaveBeenCalledWith( + `/${getLoaderName('root-loader', 'manifest-hash')}?view=full`, + expect.objectContaining({ + headers: { + [FULLPATH_HEADER]: '/products/123/', + }, + }) + ); + }); + it('converts unfollowed HTTP redirects into loader redirects', async () => { vi.stubGlobal( 'fetch',