Skip to content
Closed
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/rewrite-spa-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': patch
---

fix: client navigation to a URL that only exists through a server `rewrite()` now renders the rewrite target instead of the 404 page. When no page matches on the client, the router asks the server (`q-route.json`) where the URL routes to and loads that route, keeping the original URL.
14 changes: 14 additions & 0 deletions e2e/qwik-e2e/apps/qwikrouter-test/src/routes/plugin@rewrite-spa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { RequestHandler } from '@qwik.dev/router';

// Only `/rewrite-spa/target/` is a route. `/rewrite-spa/pretty/` exists only through this rewrite.
export const onRequest: RequestHandler = ({ url, rewrite, cacheControl, headers }) => {
if (url.pathname === '/qwikrouter-test/rewrite-spa/pretty/') {
throw rewrite('/qwikrouter-test/rewrite-spa/target/');
}
// Same rewrite, but the middleware decides the answer is cacheable and tagged.
if (url.pathname === '/qwikrouter-test/rewrite-spa/pretty-cached/') {
cacheControl({ maxAge: 60 }, 'CDN-Cache-Control');
headers.set('Cache-Tag', 'rewrite-spa-target');
throw rewrite('/qwikrouter-test/rewrite-spa/target/');
}
};
13 changes: 13 additions & 0 deletions e2e/qwik-e2e/apps/qwikrouter-test/src/routes/rewrite-spa/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { component$ } from '@qwik.dev/core';
import { Link } from '@qwik.dev/router';

export default component$(() => {
return (
<>
<h1 id="rewrite-spa-source">rewrite spa: source</h1>
<Link href="/qwikrouter-test/rewrite-spa/pretty/" data-test-link="rewrite-spa-pretty">
pretty URL, no route of its own, rewritten to a different route
</Link>
</>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { component$ } from '@qwik.dev/core';
import { routeLoader$ } from '@qwik.dev/router';

export const useTarget = routeLoader$(({ originalUrl, url }) => ({
original: originalUrl.pathname,
served: url.pathname,
}));

export default component$(() => {
const target = useTarget();
return (
<>
<h1 id="rewrite-spa-target">rewrite spa: target</h1>
<p id="rewrite-spa-original">{target.value.original}</p>
<p id="rewrite-spa-served">{target.value.served}</p>
</>
);
});
1 change: 1 addition & 0 deletions e2e/qwik-e2e/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export { router }
],
// Legacy e2e tests rely on actions re-running all loaders
strictLoaders: false,
serverRewrites: true,
}) as PluginOption
// qwikRouterSsg.nodeServerAdapter({
// ssg: null,
Expand Down
44 changes: 44 additions & 0 deletions e2e/qwik-e2e/tests/qwikrouter/rewrite-spa.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test';

const pretty = '/qwikrouter-test/rewrite-spa/pretty/';
const target = '/qwikrouter-test/rewrite-spa/target/';

test.describe('rewrite to a different route', () => {
test('SSR serves the target page under the original URL', async ({ page }) => {
const response = await page.goto(pretty);
expect(response?.status()).toBe(200);
await expect(page.locator('#rewrite-spa-target')).toBeVisible();
await expect(page.locator('#rewrite-spa-original')).toHaveText(pretty);
await expect(page.locator('#rewrite-spa-served')).toHaveText(target);
});

test('q-route.json uses the cache headers the rewriting middleware set', async ({ request }) => {
const plain = await request.get(`${pretty}q-route.json`);
expect(await plain.json()).toEqual({ p: target });
expect(plain.headers()['cdn-cache-control']).toBeUndefined();
expect(plain.headers()['cache-tag']).toBeUndefined();

const cached = await request.get('/qwikrouter-test/rewrite-spa/pretty-cached/q-route.json');
expect(await cached.json()).toEqual({ p: target });
expect(cached.headers()['cdn-cache-control']).toContain('max-age=60');
expect(cached.headers()['cache-tag']).toBe('rewrite-spa-target');
});

test('SPA navigation renders the target and keeps the URL, without a reload', async ({
page,
}) => {
const pageErrors: Error[] = [];
page.on('pageerror', (err) => pageErrors.push(err));
await page.goto('/qwikrouter-test/rewrite-spa/');
await page.evaluate(() => ((window as any).__noReload = true));

await page.locator('[data-test-link="rewrite-spa-pretty"]').click();

await expect(page.locator('#rewrite-spa-target')).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${pretty}$`));
await expect(page.locator('#rewrite-spa-original')).toHaveText(pretty);
await expect(page.locator('#rewrite-spa-served')).toHaveText(target);
expect(await page.evaluate(() => (window as any).__noReload)).toBe(true);
expect(pageErrors).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,32 @@ Check `internalRequest` before applying broad rewrite rules. Qwik Router uses in
requests for route loader fetches and fetch-based action submissions, and those requests must keep
their internal routing behavior.

## Rewrites and client navigation

A `rewrite()` happens on the server, so on a client navigation the router can't know which route a rewritten URL renders. If the URL has no page of its own, or only matches a dynamic (`[param]`) or wildcard route, it would render the wrong thing or the 404 page.

Enable `serverRewrites` in the Vite plugin to have the router ask the server where such a URL finally routes to (`q-route.json`), then render that route while keeping the original URL:

```ts title="vite.config.ts"
qwikRouter({ serverRewrites: true });
```

A static route that matches the URL exactly is always rendered as-is, so don't keep a page on a URL that your middleware rewrites.

The answer is sent with the response headers your middleware has set when it calls `rewrite()`, and `private` if it set no `Cache-Control`, like loaders. To cache it (browser, CDN, ...), set the headers before rewriting. If the rewrite depends on the user (cookies, auth), keep it private or `Vary` accordingly:

```tsx
export const onRequest: RequestHandler = async ({ url, rewrite, cacheControl, headers }) => {
const match = url.pathname.match(/^\/articles\/(.*)$/);
if (match) {
const { id } = await db.getArticleByName(match[1]);
cacheControl({ maxAge: 300, staleWhileRevalidate: 3600 }, 'CDN-Cache-Control');
headers.set('Cache-Tag', `article-${id}`);
throw rewrite(`/articles/${id}`);
}
};
```

The `rewrite()` function, which was destructured in the RequestHandler function arguments, is invoked with a pathname string.

```tsx
Expand Down
3 changes: 3 additions & 0 deletions packages/qwik-router/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,8 @@ declare var __SSR_CACHE_SIZE__: number;
*/
declare var __STRICT_LOADERS__: boolean;

/** Resolve server `rewrite()` targets on client navigation (`serverRewrites` plugin option). */
declare var __SERVER_REWRITES__: boolean;

declare var __QWIK_BUILD_DIR__: string;
declare var __QWIK_ASSETS_DIR__: string;
3 changes: 3 additions & 0 deletions packages/qwik-router/src/buildtime/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ function normalizeOptions(
if (opts.strictLoaders === undefined) {
opts.strictLoaders = true;
}
if (opts.serverRewrites === undefined) {
opts.serverRewrites = false;
}

return opts;
}
15 changes: 15 additions & 0 deletions packages/qwik-router/src/buildtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,21 @@ export interface PluginOptions {
* Defaults to `true`.
*/
strictLoaders?: boolean;
/**
* Resolve `rewrite()` targets on client navigation.
*
* A `rewrite()` made by middleware (e.g. a database lookup that maps `/articles/some-title` to
* `/articles/7777`) only happens on the server, so the client can't tell which route to render
* for the original URL. When true, a client navigation to a URL that has no page, or only matches
* a dynamic or wildcard route, first asks the server (`q-route.json`) where the URL finally
* routes to, then renders that route while keeping the original URL.
*
* A static route that matches the URL exactly is always rendered as-is. Costs one extra request
* for those navigations.
*
* Defaults to `false`.
*/
serverRewrites?: boolean;
}

export interface MdxPlugins {
Expand Down
1 change: 1 addition & 0 deletions packages/qwik-router/src/buildtime/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ function qwikRouterPlugin(
viteEnv.command === 'serve' ? 0 : (userOpts?.ssrCacheSize ?? 50)
),
'globalThis.__STRICT_LOADERS__': JSON.stringify(userOpts?.strictLoaders !== false),
'globalThis.__SERVER_REWRITES__': JSON.stringify(userOpts?.serverRewrites === true),
},
appType: 'custom',
resolve: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { RequestEvent, RequestHandler } from '../types';
import { IsQRoute } from '../request-path';

/**
* Answers `q-route.json` requests with the pathname the URL finally routes to, after any
* `rewrite()`. It sits after the plugins and route middleware, so a rewrite has already re-resolved
* the route by the time it runs. Lets the client load a rewrite target it can't match by itself.
*
* The response carries the headers set so far, so a middleware can make a rewrite cacheable with
* `cacheControl()` / `headers.set('Cache-Tag', ...)` before `throw rewrite(...)`.
*/
export function routeResolveHandler(): RequestHandler {
return (requestEv: RequestEvent) => {
if (!requestEv.sharedMap.has(IsQRoute) || requestEv.headersSent || requestEv.exited) {
return;
}
// Caching is up to the middleware that rewrites (cacheControl(), Cache-Tag, ...), same as for
// loaders: private unless it said otherwise.
if (!requestEv.headers.has('Cache-Control')) {
requestEv.cacheControl('private');
}
requestEv.json(200, { p: requestEv.url.pathname });
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createCacheControl } from './cache-control';
import { Cookie } from './cookie';
import {
IsQAction,
IsQLoader,
QActionId,
QLoaderId,
recognizeRequest,
Expand Down Expand Up @@ -68,7 +69,10 @@ export function createRequestEvent(
if (recognized) {
url.pathname = trimRecognizedInternalPathname(url.pathname, recognized);
sharedMap.set(recognized.type, true);
internalRequest = 'loader';
// A route-resolve request is a navigation, so middleware (e.g. rewrites) must treat it as one.
if (recognized.type === IsQLoader) {
internalRequest = 'loader';
}
if (recognized.data?.loaderId) {
sharedMap.set(QLoaderId, recognized.data.loaderId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ensureSlash } from '../../utils/pathname';
// New per-loader/per-action URL patterns
export const IsQLoader = '@isQLoader';
export const IsQAction = '@isQAction';
export const IsQRoute = '@isQRoute';
export const QLoaderId = '@loaderId';
export const QActionId = '@actionId';

Expand All @@ -12,11 +13,16 @@ export const LOADER_REGEX = /\/(q-loader-([^/.]+)\.([^/.]+)\.json)$/;
export const getLoaderName = (loaderId: string, manifestHash: string) =>
`q-loader-${loaderId}.${manifestHash}.json`;

export type RecognizedRequest = {
type: typeof IsQLoader;
trimLength: number;
data: { loaderId?: string; manifestHash?: string } | null;
};
/** Matches the final `/q-route.json` path segment: the client asks where a URL really routes to. */
export const ROUTE_REGEX = /\/q-route\.json$/;

export type RecognizedRequest =
| {
type: typeof IsQLoader;
trimLength: number;
data: { loaderId?: string; manifestHash?: string } | null;
}
| { type: typeof IsQRoute; trimLength: number; data: null };

/**
* Recognize internal request types from the URL pathname.
Expand All @@ -40,6 +46,10 @@ export function recognizeRequest(pathname: string): RecognizedRequest | null {
};
}

if (ROUTE_REGEX.test(pathname)) {
return { type: IsQRoute, trimLength: '/q-route.json'.length, data: null };
}

return null;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
getLoaderName,
IsQRoute,
recognizeRequest,
resolveValidInternalFullPathname,
trimInternalPathname,
Expand Down Expand Up @@ -49,6 +50,17 @@ describe('request path helpers', () => {
);
});

it('recognizes q-route requests and trims them like any internal request', () => {
globalThis.__NO_TRAILING_SLASH__ = false;
const recognized = recognizeRequest('/articles/qwik-is-quick/q-route.json');

expect(recognized?.type).toBe(IsQRoute);
expect(
trimRecognizedInternalPathname('/articles/qwik-is-quick/q-route.json', recognized!)
).toBe('/articles/qwik-is-quick/');
expect(recognizeRequest('/articles/q-route.json.map')).toBeNull();
});

it('accepts internal full pathnames below the loader pathname', () => {
expect(resolveValidInternalFullPathname('/products/123/', '/products/123/view/')).toBe(
'/products/123/view/'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { loaderHandler, setRouteLoaderData } from './handlers/loader-handler';
import { jsonRequestWrapper } from './handlers/json-request-wrapper';
import { actionHandler } from './handlers/action-handler';
import { routeResolveHandler } from './handlers/route-resolve-handler';
import { IsQLoader, QLoaderId } from './request-path';
import { isStaticPath } from './static-paths';
import type { ErrorCodes, RequestEvent, RequestEventBase, RequestHandler } from './types';
Expand Down Expand Up @@ -119,6 +120,7 @@ function createResolveRequestHandlers() {

if (isPageRoute) {
// Per-loader handler: returns JSON with metadata and exits if IsQLoader is set
requestHandlers.push(routeResolveHandler());
requestHandlers.push(loaderHandler(routeLoaders, route));
// Per-action handler: returns JSON and exits if IsQAction + Accept: json
requestHandlers.push(actionHandler(routeActions));
Expand Down
Loading
Loading