Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/floppy-pandas-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': patch
---

fix: prevent cold dev route loader requests from failing during SPA navigation
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isDev } from '@qwik.dev/core';
import { _serialize } from '@qwik.dev/core/internal';
import {
FULLPATH_HEADER,
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/qwik-router/src/runtime/src/prefetch-route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,7 +25,7 @@ export async function prefetchRoute(
manifestHash?: string,
prefetchBundle = true
) {
if (!isBrowser) {
if (!isBrowser || isDev) {
return;
}

Expand Down
32 changes: 28 additions & 4 deletions packages/qwik-router/src/runtime/src/route-loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, LoaderInternal>()) 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.
*
Expand Down Expand Up @@ -203,7 +221,7 @@ export const fetchRouteLoaderData = async (
const url = `${pathBase}${getLoaderName(loaderId, manifestHash)}${search}`;

const headers: Record<string, string> = {};
if (pageUrl && pageUrl.pathname !== pathBase && !globalThis.__STRICT_LOADERS__) {
if (pageUrl && pageUrl.pathname !== pathBase) {
headers[FULLPATH_HEADER] = pageUrl.pathname;
}

Expand Down Expand Up @@ -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;
Expand All @@ -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. */
Expand Down Expand Up @@ -828,6 +849,9 @@ export const routeLoaderQrl = ((
loader.__search = search;
loader.__allowStale = allowStale;
Object.freeze(loader);
if (isDev) {
registerDevRouteLoader(loader);
}
return loader;
}) as LoaderConstructorQRL;

Expand Down
26 changes: 26 additions & 0 deletions packages/qwik-router/src/runtime/src/use-endpoint.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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',
Expand Down
Loading