fix: prioritize dynamic prerendered routes over dynamic server routes (#7235)
* test: add unit tests * fix: prioritize prerendered routes * chore: fix test * add comment * test: try avoiding race condition * chore: changeset * try avoiding race conditions attempt #2 * try avoiding race conditions (attempt 3) * final fix hopefuly * tet: add more tests * sort conflicting dynamic routes aplhabetically * test: fix test
This commit is contained in:
parent
e20a3b20b7
commit
ee2aca80a7
6 changed files with 361 additions and 19 deletions
5
.changeset/hungry-peaches-speak.md
Normal file
5
.changeset/hungry-peaches-speak.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Prioritize dynamic prerendered routes over dynamic server routes
|
|
@ -1,4 +1,3 @@
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import type {
|
import type {
|
||||||
AstroMiddlewareInstance,
|
AstroMiddlewareInstance,
|
||||||
AstroSettings,
|
AstroSettings,
|
||||||
|
@ -65,7 +64,7 @@ export async function preload({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load the module from the Vite SSR Runtime.
|
// Load the module from the Vite SSR Runtime.
|
||||||
const mod = (await env.loader.import(fileURLToPath(filePath))) as ComponentInstance;
|
const mod = (await env.loader.import(viteID(filePath))) as ComponentInstance;
|
||||||
|
|
||||||
return [renderers, mod];
|
return [renderers, mod];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -173,6 +173,7 @@ function comparator(a: Item, b: Item) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// endpoints are prioritized over pages
|
||||||
if (a.isPage !== b.isPage) {
|
if (a.isPage !== b.isPage) {
|
||||||
return a.isPage ? 1 : -1;
|
return a.isPage ? 1 : -1;
|
||||||
}
|
}
|
||||||
|
|
67
packages/astro/src/prerender/routing.ts
Normal file
67
packages/astro/src/prerender/routing.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import type { AstroSettings, RouteData } from '../@types/astro';
|
||||||
|
import { preload, type DevelopmentEnvironment } from '../core/render/dev/index.js';
|
||||||
|
import { getPrerenderStatus } from './metadata.js';
|
||||||
|
|
||||||
|
type GetSortedPreloadedMatchesParams = {
|
||||||
|
env: DevelopmentEnvironment;
|
||||||
|
matches: RouteData[];
|
||||||
|
settings: AstroSettings;
|
||||||
|
};
|
||||||
|
export async function getSortedPreloadedMatches({
|
||||||
|
env,
|
||||||
|
matches,
|
||||||
|
settings,
|
||||||
|
}: GetSortedPreloadedMatchesParams) {
|
||||||
|
return (
|
||||||
|
await preloadAndSetPrerenderStatus({
|
||||||
|
env,
|
||||||
|
matches,
|
||||||
|
settings,
|
||||||
|
})
|
||||||
|
).sort((a, b) => prioritizePrerenderedMatchesComparator(a.route, b.route));
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreloadAndSetPrerenderStatusParams = {
|
||||||
|
env: DevelopmentEnvironment;
|
||||||
|
matches: RouteData[];
|
||||||
|
settings: AstroSettings;
|
||||||
|
};
|
||||||
|
async function preloadAndSetPrerenderStatus({
|
||||||
|
env,
|
||||||
|
matches,
|
||||||
|
settings,
|
||||||
|
}: PreloadAndSetPrerenderStatusParams) {
|
||||||
|
const preloaded = await Promise.all(
|
||||||
|
matches.map(async (route) => {
|
||||||
|
const filePath = new URL(`./${route.component}`, settings.config.root);
|
||||||
|
const preloadedComponent = await preload({ env, filePath });
|
||||||
|
|
||||||
|
// gets the prerender metadata set by the `astro:scanner` vite plugin
|
||||||
|
const prerenderStatus = getPrerenderStatus({
|
||||||
|
filePath,
|
||||||
|
loader: env.loader,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prerenderStatus !== undefined) {
|
||||||
|
route.prerender = prerenderStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { preloadedComponent, route, filePath } as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return preloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prioritizePrerenderedMatchesComparator(a: RouteData, b: RouteData): number {
|
||||||
|
if (areRegexesEqual(a.pattern, b.pattern)) {
|
||||||
|
if (a.prerender !== b.prerender) {
|
||||||
|
return a.prerender ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.component < b.component ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areRegexesEqual(regexp1: RegExp, regexp2: RegExp) {
|
||||||
|
return regexp1.source === regexp2.source && regexp1.global === regexp2.global;
|
||||||
|
}
|
|
@ -16,10 +16,10 @@ import { preload, renderPage } from '../core/render/dev/index.js';
|
||||||
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js';
|
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js';
|
||||||
import { createRequest } from '../core/request.js';
|
import { createRequest } from '../core/request.js';
|
||||||
import { matchAllRoutes } from '../core/routing/index.js';
|
import { matchAllRoutes } from '../core/routing/index.js';
|
||||||
import { getPrerenderStatus } from '../prerender/metadata.js';
|
|
||||||
import { isHybridOutput } from '../prerender/utils.js';
|
import { isHybridOutput } from '../prerender/utils.js';
|
||||||
import { log404 } from './common.js';
|
import { log404 } from './common.js';
|
||||||
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
|
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
|
||||||
|
import { getSortedPreloadedMatches } from '../prerender/routing.js';
|
||||||
|
|
||||||
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
|
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
|
||||||
...args: any
|
...args: any
|
||||||
|
@ -47,24 +47,12 @@ export async function matchRoute(
|
||||||
): Promise<MatchedRoute | undefined> {
|
): Promise<MatchedRoute | undefined> {
|
||||||
const { logging, settings, routeCache } = env;
|
const { logging, settings, routeCache } = env;
|
||||||
const matches = matchAllRoutes(pathname, manifest);
|
const matches = matchAllRoutes(pathname, manifest);
|
||||||
|
const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings });
|
||||||
|
|
||||||
for await (const maybeRoute of matches) {
|
for await (const { preloadedComponent, route: maybeRoute, filePath } of preloadedMatches) {
|
||||||
const filePath = new URL(`./${maybeRoute.component}`, settings.config.root);
|
|
||||||
const preloadedComponent = await preload({ env, filePath });
|
|
||||||
|
|
||||||
// gets the prerender metadata set by the `astro:scanner` vite plugin
|
|
||||||
const prerenderStatus = getPrerenderStatus({
|
|
||||||
filePath,
|
|
||||||
loader: env.loader,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (prerenderStatus !== undefined) {
|
|
||||||
maybeRoute.prerender = prerenderStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, mod] = preloadedComponent;
|
|
||||||
// attempt to get static paths
|
// attempt to get static paths
|
||||||
// if this fails, we have a bad URL match!
|
// if this fails, we have a bad URL match!
|
||||||
|
const [, mod] = preloadedComponent;
|
||||||
const paramsAndPropsRes = await getParamsAndProps({
|
const paramsAndPropsRes = await getParamsAndProps({
|
||||||
mod,
|
mod,
|
||||||
route: maybeRoute,
|
route: maybeRoute,
|
||||||
|
@ -210,7 +198,7 @@ export async function handleRoute(
|
||||||
await writeWebResponse(res, result.response);
|
await writeWebResponse(res, result.response);
|
||||||
} else {
|
} else {
|
||||||
let contentType = 'text/plain';
|
let contentType = 'text/plain';
|
||||||
// Dynamic routes don’t include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg')
|
// Dynamic routes don't include `route.pathname`, so synthesize a path for these (e.g. 'src/pages/[slug].svg')
|
||||||
const filepath =
|
const filepath =
|
||||||
route.pathname ||
|
route.pathname ||
|
||||||
route.segments.map((segment) => segment.map((p) => p.content).join('')).join('/');
|
route.segments.map((segment) => segment.map((p) => p.content).join('')).join('/');
|
||||||
|
|
282
packages/astro/test/units/routing/route-matching.test.js
Normal file
282
packages/astro/test/units/routing/route-matching.test.js
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
// @ts-check
|
||||||
|
import { createFs, createRequestAndResponse } from '../test-utils.js';
|
||||||
|
import { createRouteManifest, matchAllRoutes } from '../../../dist/core/routing/index.js';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { defaultLogging } from '../../test-utils.js';
|
||||||
|
import { createViteLoader } from '../../../dist/core/module-loader/vite.js';
|
||||||
|
import { createDevelopmentEnvironment } from '../../../dist/core/render/dev/environment.js';
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import { createContainer } from '../../../dist/core/dev/container.js';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import testAdapter from '../../test-adapter.js';
|
||||||
|
import { getSortedPreloadedMatches } from '../../../dist/prerender/routing.js';
|
||||||
|
|
||||||
|
const root = new URL('../../fixtures/alias/', import.meta.url);
|
||||||
|
const fileSystem = {
|
||||||
|
'/src/pages/[serverDynamic].astro': `
|
||||||
|
---
|
||||||
|
export const prerender = false;
|
||||||
|
---
|
||||||
|
<p>Server dynamic route! slug:{Astro.params.serverDynamic}</p>
|
||||||
|
`,
|
||||||
|
|
||||||
|
'/src/pages/[xStaticDynamic].astro': `
|
||||||
|
---
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
xStaticDynamic: "static-dynamic-route-here",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<p>Prerendered dynamic route!</p>
|
||||||
|
`,
|
||||||
|
'/src/pages/[aStaticDynamic].astro': `
|
||||||
|
---
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
aStaticDynamic: "another-static-dynamic-route-here",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<p>Another prerendered dynamic route!</p>
|
||||||
|
`,
|
||||||
|
'/src/pages/[...serverRest].astro': `
|
||||||
|
---
|
||||||
|
export const prerender = false;
|
||||||
|
---
|
||||||
|
<p>Server rest route! slug:{Astro.params.serverRest}</p>
|
||||||
|
`,
|
||||||
|
'/src/pages/[...xStaticRest].astro': `
|
||||||
|
---
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
xStaticRest: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<p>Prerendered rest route!</p>
|
||||||
|
`,
|
||||||
|
'/src/pages/[...aStaticRest].astro': `
|
||||||
|
---
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
aStaticRest: "another/static-rest-route-here",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<p>Another prerendered rest route!</p>
|
||||||
|
`,
|
||||||
|
|
||||||
|
'/src/pages/nested/[...serverRest].astro': `
|
||||||
|
---
|
||||||
|
export const prerender = false;
|
||||||
|
---
|
||||||
|
<p>Nested server rest route! slug: {Astro.params.serverRest}</p>
|
||||||
|
`,
|
||||||
|
'/src/pages/nested/[...xStaticRest].astro': `
|
||||||
|
---
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
xStaticRest: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<p>Nested prerendered rest route!</p>
|
||||||
|
`,
|
||||||
|
'/src/pages/nested/[...aStaticRest].astro': `
|
||||||
|
---
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
aStaticRest: "another-nested-static-dynamic-rest-route-here",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<p>Another nested prerendered rest route!</p>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Route matching', () => {
|
||||||
|
let env;
|
||||||
|
let manifest;
|
||||||
|
let container;
|
||||||
|
let settings;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const fs = createFs(fileSystem, root);
|
||||||
|
container = await createContainer({
|
||||||
|
fs,
|
||||||
|
root,
|
||||||
|
userConfig: {
|
||||||
|
trailingSlash: 'never',
|
||||||
|
output: 'hybrid',
|
||||||
|
experimental: {
|
||||||
|
hybridOutput: true,
|
||||||
|
},
|
||||||
|
adapter: testAdapter(),
|
||||||
|
},
|
||||||
|
disableTelemetry: true,
|
||||||
|
});
|
||||||
|
settings = container.settings;
|
||||||
|
|
||||||
|
const loader = createViteLoader(container.viteServer);
|
||||||
|
env = createDevelopmentEnvironment(container.settings, defaultLogging, loader);
|
||||||
|
manifest = createRouteManifest(
|
||||||
|
{
|
||||||
|
cwd: fileURLToPath(root),
|
||||||
|
settings,
|
||||||
|
fsMod: fs,
|
||||||
|
},
|
||||||
|
defaultLogging
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await container.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Matched routes', () => {
|
||||||
|
it('should be sorted correctly', async () => {
|
||||||
|
const matches = matchAllRoutes('/try-matching-a-route', manifest);
|
||||||
|
const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings });
|
||||||
|
const sortedRouteNames = preloadedMatches.map((match) => match.route.route);
|
||||||
|
|
||||||
|
expect(sortedRouteNames).to.deep.equal([
|
||||||
|
'/[astaticdynamic]',
|
||||||
|
'/[xstaticdynamic]',
|
||||||
|
'/[serverdynamic]',
|
||||||
|
'/[...astaticrest]',
|
||||||
|
'/[...xstaticrest]',
|
||||||
|
'/[...serverrest]',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('nested should be sorted correctly', async () => {
|
||||||
|
const matches = matchAllRoutes('/nested/try-matching-a-route', manifest);
|
||||||
|
const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings });
|
||||||
|
const sortedRouteNames = preloadedMatches.map((match) => match.route.route);
|
||||||
|
|
||||||
|
expect(sortedRouteNames).to.deep.equal([
|
||||||
|
'/nested/[...astaticrest]',
|
||||||
|
'/nested/[...xstaticrest]',
|
||||||
|
'/nested/[...serverrest]',
|
||||||
|
'/[...astaticrest]',
|
||||||
|
'/[...xstaticrest]',
|
||||||
|
'/[...serverrest]',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Request', () => {
|
||||||
|
it('should correctly match a static dynamic route I', async () => {
|
||||||
|
const { req, res, text } = createRequestAndResponse({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/static-dynamic-route-here',
|
||||||
|
});
|
||||||
|
container.handle(req, res);
|
||||||
|
const html = await text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('p').text()).to.equal('Prerendered dynamic route!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly match a static dynamic route II', async () => {
|
||||||
|
const { req, res, text } = createRequestAndResponse({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/another-static-dynamic-route-here',
|
||||||
|
});
|
||||||
|
container.handle(req, res);
|
||||||
|
const html = await text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('p').text()).to.equal('Another prerendered dynamic route!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly match a server dynamic route', async () => {
|
||||||
|
const { req, res, text } = createRequestAndResponse({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/a-random-slug-was-matched',
|
||||||
|
});
|
||||||
|
container.handle(req, res);
|
||||||
|
const html = await text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('p').text()).to.equal('Server dynamic route! slug:a-random-slug-was-matched');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly match a static rest route I', async () => {
|
||||||
|
const { req, res, text } = createRequestAndResponse({
|
||||||
|
method: 'GET',
|
||||||
|
url: '',
|
||||||
|
});
|
||||||
|
container.handle(req, res);
|
||||||
|
const html = await text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('p').text()).to.equal('Prerendered rest route!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly match a static rest route II', async () => {
|
||||||
|
const { req, res, text } = createRequestAndResponse({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/another/static-rest-route-here',
|
||||||
|
});
|
||||||
|
container.handle(req, res);
|
||||||
|
const html = await text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('p').text()).to.equal('Another prerendered rest route!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly match a nested static rest route index', async () => {
|
||||||
|
const { req, res, text } = createRequestAndResponse({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/nested',
|
||||||
|
});
|
||||||
|
container.handle(req, res);
|
||||||
|
const html = await text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('p').text()).to.equal('Nested prerendered rest route!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly match a nested static rest route', async () => {
|
||||||
|
const { req, res, text } = createRequestAndResponse({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/nested/another-nested-static-dynamic-rest-route-here',
|
||||||
|
});
|
||||||
|
container.handle(req, res);
|
||||||
|
const html = await text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('p').text()).to.equal('Another nested prerendered rest route!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly match a nested server rest route', async () => {
|
||||||
|
const { req, res, text } = createRequestAndResponse({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/nested/a-random-slug-was-matched',
|
||||||
|
});
|
||||||
|
container.handle(req, res);
|
||||||
|
|
||||||
|
const html = await text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
expect($('p').text()).to.equal('Nested server rest route! slug: a-random-slug-was-matched');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue