From ace05e0c049c88902d193fe530fe5e0b7c108996 Mon Sep 17 00:00:00 2001 From: ShiroKSH Date: Sat, 1 Aug 2026 21:48:25 +0300 Subject: [PATCH] fix(router): preserve no-trailing static paths --- .changeset/shy-crabs-dream.md | 5 +++ .../src/adapters/shared/vite/index.ts | 5 ++- .../src/adapters/shared/vite/post-build.ts | 20 +++++++--- .../adapters/shared/vite/post-build.unit.ts | 37 ++++++++++++++++++- 4 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 .changeset/shy-crabs-dream.md diff --git a/.changeset/shy-crabs-dream.md b/.changeset/shy-crabs-dream.md new file mode 100644 index 00000000000..2b9f83cbe9a --- /dev/null +++ b/.changeset/shy-crabs-dream.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +fix: preserve no-trailing-slash static routes diff --git a/packages/qwik-router/src/adapters/shared/vite/index.ts b/packages/qwik-router/src/adapters/shared/vite/index.ts index 66d3f9d71f7..f8ab3fb53e7 100644 --- a/packages/qwik-router/src/adapters/shared/vite/index.ts +++ b/packages/qwik-router/src/adapters/shared/vite/index.ts @@ -260,6 +260,8 @@ export function viteAdapter(opts: ViteAdapterPluginOptions) { const basePathname = qwikRouterPlugin.api.getBasePathname(); const clientOutDir = qwikVitePlugin.api.getClientOutDir()!; const clientPublicOutDir = qwikVitePlugin.api.getClientPublicOutDir()!; + const noTrailingSlash = + builder.config.define?.['globalThis.__NO_TRAILING_SLASH__'] === 'true'; const ssgEnv = builder.environments.ssg; const ssgOutDir = ssgOutDirFor(builder.config.root); @@ -303,7 +305,8 @@ export function viteAdapter(opts: ViteAdapterPluginOptions) { serverOutDir, basePathname, staticPaths, - !!opts.cleanStaticGenerated + !!opts.cleanStaticGenerated, + noTrailingSlash ); if (typeof opts.generate === 'function') { diff --git a/packages/qwik-router/src/adapters/shared/vite/post-build.ts b/packages/qwik-router/src/adapters/shared/vite/post-build.ts index 8399b9143f3..32d550c51e6 100644 --- a/packages/qwik-router/src/adapters/shared/vite/post-build.ts +++ b/packages/qwik-router/src/adapters/shared/vite/post-build.ts @@ -9,7 +9,8 @@ export async function postBuild( serverOutDir: string, pathName: string, userStaticPaths: string[], - cleanStatic: boolean + cleanStatic: boolean, + noTrailingSlash: boolean ) { if (pathName && !pathName.endsWith('/')) { pathName = ensureSlash(pathName); @@ -20,7 +21,15 @@ export async function postBuild( pathNameBase + (globalThis.__QWIK_ASSETS_DIR__ || 'assets') + '/', ]); - const staticPaths = new Set(userStaticPaths.map(ensureSlash)); + const getStaticPathname = (pathname: string) => { + pathname = ensureSlash(pathname); + if (noTrailingSlash && pathname !== pathNameBase) { + return pathname.slice(0, -1); + } + return pathname; + }; + + const staticPaths = new Set(userStaticPaths.map(getStaticPathname)); const loadItem = async (fsDir: string, fsName: string, pathname: string) => { pathname = ensureSlash(pathname); @@ -32,7 +41,7 @@ export async function postBuild( if (fsName === 'index.html') { // The route pathname already represents this page; clean it if that route is no longer static. - if (!staticPaths.has(pathname) && cleanStatic) { + if (!staticPaths.has(getStaticPathname(pathname)) && cleanStatic) { await fs.promises.unlink(fsPath); } return; @@ -40,8 +49,9 @@ export async function postBuild( if (LOADER_REGEX.test('/' + fsName)) { // List the exact sidecar SSG wrote so isStaticPath only claims loaders with data on disk. - if (staticPaths.has(pathname)) { - staticPaths.add(pathname + fsName); + const staticPathname = getStaticPathname(pathname); + if (staticPaths.has(staticPathname)) { + staticPaths.add(ensureSlash(staticPathname) + fsName); } else if (cleanStatic) { await fs.promises.unlink(fsPath); } diff --git a/packages/qwik-router/src/adapters/shared/vite/post-build.unit.ts b/packages/qwik-router/src/adapters/shared/vite/post-build.unit.ts index b73199781a9..61ab9e94ad1 100644 --- a/packages/qwik-router/src/adapters/shared/vite/post-build.unit.ts +++ b/packages/qwik-router/src/adapters/shared/vite/post-build.unit.ts @@ -16,7 +16,13 @@ async function tmp() { } /** Run postBuild against a temp client/server tree and return the injected STATIC_PATHS array. */ -async function run(files: Record, userStaticPaths: string[], cleanStatic = false) { +async function run( + files: Record, + userStaticPaths: string[], + cleanStatic = false, + noTrailingSlash = false, + pathName = '/' +) { const clientOutDir = await tmp(); const serverOutDir = await tmp(); for (const [rel, content] of Object.entries(files)) { @@ -28,7 +34,14 @@ async function run(files: Record, userStaticPaths: string[], cle join(serverOutDir, 'server.js'), `export const staticPaths = new Set(['__QWIK_ROUTER_STATIC_PATHS_ARRAY__']);` ); - await postBuild(clientOutDir, serverOutDir, '/', userStaticPaths, cleanStatic); + await postBuild( + clientOutDir, + serverOutDir, + pathName, + userStaticPaths, + cleanStatic, + noTrailingSlash + ); const code = await readFile(join(serverOutDir, 'server.js'), 'utf-8'); return JSON.parse(code.match(/new Set\((\[[^\]]*\])\)/)![1]) as string[]; } @@ -46,6 +59,26 @@ test('lists a written loader sidecar of a static route, but not its index.html', expect(paths).not.toContain('/blog/index.html'); }); +test('uses no-trailing-slash static paths for static pages', async () => { + const paths = await run( + { + 'blog/index.html': '', + 'blog/q-loader-WaXl02RHfZE.abc.json': '{"d":{}}', + }, + ['/blog'], + false, + true + ); + expect(paths).toContain('/blog'); + expect(paths).toContain('/blog/q-loader-WaXl02RHfZE.abc.json'); + expect(paths).not.toContain('/blog/'); +}); + +test('keeps the base pathname trailing slash for static pages', async () => { + const paths = await run({ 'index.html': '' }, ['/app/'], false, true, '/app/'); + expect(paths).toEqual(['/app/']); +}); + test('does not list a loader sidecar whose route is not static', async () => { const paths = await run({ 'other/q-loader-X.abc.json': '{"d":{}}' }, []); expect(paths).not.toContain('/other/q-loader-X.abc.json');