From 7002b184d0002e1751e10524528f3c045d340e83 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Tue, 4 Nov 2025 12:44:13 -0500 Subject: [PATCH 1/4] fix: ignore SPA redirect in dev mode to allow Vite to serve JS modules Filter out the SPA redirect pattern (from '/*' to '/index.html' with status 200) in createRewriter when ignoreSPARedirect is true. This prevents the redirect from interfering with local dev servers like Vite, while still allowing it to work in production. RedirectsHandler now always passes ignoreSPARedirect: true to createRewriter, ensuring the SPA redirect is ignored in dev mode. Added e2e test to verify JS modules load correctly and execute when SPA redirect is configured in netlify.toml. Fixes #325 --- packages/redirects/src/lib/rewriter.ts | 20 +++++++- packages/redirects/src/main.ts | 1 + packages/vite-plugin/src/main.test.ts | 66 ++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/redirects/src/lib/rewriter.ts b/packages/redirects/src/lib/rewriter.ts index 57d94049..28f1e93e 100644 --- a/packages/redirects/src/lib/rewriter.ts +++ b/packages/redirects/src/lib/rewriter.ts @@ -23,6 +23,7 @@ export const createRewriter = async function ({ configPath, configRedirects, geoCountry, + ignoreSPARedirect = false, jwtRoleClaim, jwtSecret, projectDir, @@ -31,6 +32,7 @@ export const createRewriter = async function ({ configPath?: string | undefined configRedirects: Redirect[] geoCountry?: string | undefined + ignoreSPARedirect?: boolean jwtRoleClaim: string jwtSecret: string projectDir: string @@ -40,7 +42,23 @@ export const createRewriter = async function ({ const redirectsFiles = [ ...new Set([path.resolve(publicDir ?? '', REDIRECTS_FILE_NAME), path.resolve(projectDir, REDIRECTS_FILE_NAME)]), ] - const redirects = await parseRedirects({ configRedirects, redirectsFiles, configPath }) + let redirects = await parseRedirects({ configRedirects, redirectsFiles, configPath }) + + // Hacky solution: Filter out the SPA redirect pattern when requested. + // This prevents the redirect from interfering with local dev servers like Vite, + // while still allowing it to work in production. + // See: https://github.com/netlify/primitives/issues/325 + if (ignoreSPARedirect) { + redirects = redirects.filter((redirect) => { + // Filter out redirects that match the SPA pattern: from "/*" to "/index.html" with status 200 + const isSPARedirect = + redirect.from === '/*' && + redirect.to === '/index.html' && + (redirect.status === 200 || redirect.status === undefined) + + return !isSPARedirect + }) + } const getMatcher = async (): Promise => { if (matcher) return matcher diff --git a/packages/redirects/src/main.ts b/packages/redirects/src/main.ts index 280e7b45..709cf21f 100644 --- a/packages/redirects/src/main.ts +++ b/packages/redirects/src/main.ts @@ -56,6 +56,7 @@ export class RedirectsHandler { configPath, configRedirects, geoCountry, + ignoreSPARedirect: true, jwtRoleClaim, jwtSecret, projectDir, diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index e5fe53f5..59b89046 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -705,6 +705,72 @@ defined on your team and site and much more. Run npx netlify init to get started await server.close() await fixture.destroy() }) + + test('Ignores SPA redirect in dev mode to allow Vite to serve JS modules', async () => { + const fixture = new Fixture() + .withFile( + 'netlify.toml', + `[[redirects]] + from = "/*" + to = "/index.html" + status = 200`, + ) + .withFile( + 'vite.config.js', + `import { defineConfig } from 'vite'; + import netlify from '@netlify/vite-plugin'; + + export default defineConfig({ + plugins: [ + netlify({ + middleware: true, + }) + ] + });`, + ) + .withFile( + 'index.html', + ` + + SPA App + +
+ + + `, + ) + .withFile('src/main.js', `document.getElementById('app').textContent = 'Hello from SPA'`) + const directory = await fixture.create() + await fixture + .withPackages({ + vite: viteVersion, + '@netlify/vite-plugin': pathToFileURL(path.resolve(directory, PLUGIN_PATH)).toString(), + }) + .create() + + const { server, url } = await startTestServer({ + root: directory, + }) + + // The JS module should load correctly (not be redirected to index.html) + const jsResponse = await page.goto(`${url}/src/main.js`) + expect(jsResponse?.status()).toBe(200) + expect(await jsResponse?.text()).toContain("document.getElementById('app').textContent = 'Hello from SPA'") + expect(jsResponse?.headers()['content-type']).toContain('javascript') + + // The root route should still work (Vite handles it) and JS should execute + await page.goto(url) + await page.waitForSelector('#app') + expect(await page.textContent('#app')).toBe('Hello from SPA') + + // A client-side route should also work (Vite handles it) and JS should execute + await page.goto(`${url}/some-route`) + await page.waitForSelector('#app') + expect(await page.textContent('#app')).toBe('Hello from SPA') + + await server.close() + await fixture.destroy() + }) }) describe('With @vitejs/plugin-react', () => { From 6764088d626aeedcbbc9805b0a3220fc559570c8 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 5 Nov 2025 09:59:34 -0500 Subject: [PATCH 2/4] fix: use normalized redirect.origin in SPA redirect filter After normalization, redirects use 'origin' instead of 'from', so we need to check redirect.origin for the SPA pattern match. --- packages/redirects/src/lib/rewriter.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/redirects/src/lib/rewriter.ts b/packages/redirects/src/lib/rewriter.ts index 28f1e93e..42f0d1c7 100644 --- a/packages/redirects/src/lib/rewriter.ts +++ b/packages/redirects/src/lib/rewriter.ts @@ -51,10 +51,8 @@ export const createRewriter = async function ({ if (ignoreSPARedirect) { redirects = redirects.filter((redirect) => { // Filter out redirects that match the SPA pattern: from "/*" to "/index.html" with status 200 - const isSPARedirect = - redirect.from === '/*' && - redirect.to === '/index.html' && - (redirect.status === 200 || redirect.status === undefined) + // See https://docs.netlify.com/manage/routing/redirects/rewrites-proxies/#history-pushstate-and-single-page-apps, + const isSPARedirect = redirect.origin === '/*' && redirect.to === '/index.html' && redirect.status === 200 return !isSPARedirect }) From 1a0b29267f71a40ddec5d9fab65ae60a471ed1ad Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 5 Nov 2025 09:59:37 -0500 Subject: [PATCH 3/4] test: improve SPA redirect test to avoid Windows path issues Removed direct JS file check that was causing path resolution issues on Windows with Vite 5. The test now verifies the fix by checking that JS executes and updates the page, which is a better test of the actual user-facing behavior. --- packages/vite-plugin/src/main.test.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index 59b89046..99ce2255 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -706,7 +706,7 @@ defined on your team and site and much more. Run npx netlify init to get started await fixture.destroy() }) - test('Ignores SPA redirect in dev mode to allow Vite to serve JS modules', async () => { + test('Ignores SPA redirect in dev mode', async () => { const fixture = new Fixture() .withFile( 'netlify.toml', @@ -752,19 +752,14 @@ defined on your team and site and much more. Run npx netlify init to get started root: directory, }) - // The JS module should load correctly (not be redirected to index.html) - const jsResponse = await page.goto(`${url}/src/main.js`) - expect(jsResponse?.status()).toBe(200) - expect(await jsResponse?.text()).toContain("document.getElementById('app').textContent = 'Hello from SPA'") - expect(jsResponse?.headers()['content-type']).toContain('javascript') - - // The root route should still work (Vite handles it) and JS should execute - await page.goto(url) + // Any route should render the root index.html (Vite handles it) and JS should execute (which + // verifies the SPA redirect isn't interfering with loading the .js module). + await page.goto(`${url}/some-route`) await page.waitForSelector('#app') expect(await page.textContent('#app')).toBe('Hello from SPA') - // A client-side route should also work (Vite handles it) and JS should execute - await page.goto(`${url}/some-route`) + // Client-side navigation should also work (Vite handles it) + await page.goto(`${url}/some-other-route`) await page.waitForSelector('#app') expect(await page.textContent('#app')).toBe('Hello from SPA') From eae89492a0ba97863718f3732f7bcbfaf8fdc03c Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 5 Nov 2025 12:35:44 -0500 Subject: [PATCH 4/4] test: remove redundant step --- packages/vite-plugin/src/main.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/vite-plugin/src/main.test.ts b/packages/vite-plugin/src/main.test.ts index 99ce2255..346cc3b6 100644 --- a/packages/vite-plugin/src/main.test.ts +++ b/packages/vite-plugin/src/main.test.ts @@ -758,11 +758,6 @@ defined on your team and site and much more. Run npx netlify init to get started await page.waitForSelector('#app') expect(await page.textContent('#app')).toBe('Hello from SPA') - // Client-side navigation should also work (Vite handles it) - await page.goto(`${url}/some-other-route`) - await page.waitForSelector('#app') - expect(await page.textContent('#app')).toBe('Hello from SPA') - await server.close() await fixture.destroy() })