fix(node): react router spa detection (#604)

* chore: update react router to v8 and modernize node infrastructure

- Bump React Router to v8.0.0 and update associated dependencies.
- Upgrade Node.js base image to v24.
- Simplify TSConfig and Vite configuration by utilizing native TSConfig path resolution.
- Add .env to gitignore.
- Ensure consistent scripting and environment settings across SPA and SSR examples.

Generated-by: aiautocommit

* feat(node): detect React Router SPA mode from config

Generated-by: aiautocommit

* test(node): improve React Router SPA detection and handling

Generated-by: aiautocommit

* chore(deps): update mise.dev.lock

Generated-by: aiautocommit

* docs(node): clarify SPA detection limitation and manual override

Generated-by: aiautocommit

* test(node): bind React Router preview server to IPv4 in SPA example

- Force preview host to 127.0.0.1 in Vite config and generation script.
- Prevent connection mismatch issues between prerender requests and the preview server.

Generated-by: aiautocommit
This commit is contained in:
Michael Bianco
2026-07-23 12:34:14 -06:00
committed by GitHub
parent 6b992e780a
commit 0be8db4ab1
19 changed files with 1826 additions and 2962 deletions
+19 -1
View File
@@ -9,14 +9,32 @@ import (
const (
DefaultReactRouterOutputDirectory = "build/client/"
DefaultReactRouterStartCommand = "react-router-serve ./build/server/index.js"
ReactRouterConfigJS = "react-router.config.js"
ReactRouterConfigTS = "react-router.config.ts"
)
var reactRouterSPAConfigRegex = regexp.MustCompile(`(?m)(?:^|[{,])\s*['"]?ssr['"]?\s*:\s*false\b`)
func (p *NodeProvider) getReactRouterConfigFileContents(ctx *generate.GenerateContext) string {
_, contents, _ := ctx.App.ReadFirstFileOf(ReactRouterConfigJS, ReactRouterConfigTS)
return contents
}
// SPA mode requires an explicit opt-out because React Router enables SSR by default.
func (p *NodeProvider) isReactRouterSPA(ctx *generate.GenerateContext) bool {
if !p.isReactRouter(ctx) {
return false
}
configContent := p.getReactRouterConfigFileContents(ctx)
return reactRouterSPAConfigRegex.MatchString(configContent)
}
// getReactRouterOutputDirectory attempts to read the output directory from react-router.config.{ts,js} by extracting
// the buildDirectory option in the config object.
func (p *NodeProvider) getReactRouterOutputDirectory(ctx *generate.GenerateContext) string {
_, configContent, _ := ctx.App.ReadFirstFileOf(ReactRouterConfigJS, ReactRouterConfigTS)
configContent := p.getReactRouterConfigFileContents(ctx)
if configContent != "" {
// TODO this field can be an expression `buildDirectory: "build/" + process.env.NODE_ENV,` so we should tighten
+57
View File
@@ -0,0 +1,57 @@
package node
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestReactRouterSPAConfig(t *testing.T) {
tests := []struct {
name string
config string
isSPA bool
}{
{
name: "multiline config",
config: "export default {\n ssr: false,\n} satisfies Config;",
isSPA: true,
},
{
name: "inline config",
config: "export default { ssr: false } satisfies Config;",
isSPA: true,
},
{
name: "quoted property",
config: "export default { 'ssr': false } satisfies Config;",
isSPA: true,
},
{
name: "server rendering enabled",
config: "export default { ssr: true } satisfies Config;",
isSPA: false,
},
{
name: "server rendering omitted",
config: "export default {} satisfies Config;",
isSPA: false,
},
{
name: "dynamic server rendering",
config: "export default { ssr: process.env.SSR === 'true' } satisfies Config;",
isSPA: false,
},
{
name: "commented setting",
config: "export default {\n // ssr: false,\n} satisfies Config;",
isSPA: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.isSPA, reactRouterSPAConfigRegex.MatchString(tt.config))
})
}
}
+28 -15
View File
@@ -37,39 +37,49 @@ func (p *NodeProvider) isSPA(ctx *generate.GenerateContext) bool {
return false
}
if p.isReactRouter(ctx) {
return p.isReactRouterSPA(ctx) && p.getOutputDirectory(ctx) != ""
}
isVite := p.isVite(ctx)
isAstro := p.isAstroSPA(ctx)
isNext := p.isNextSPA(ctx)
isCRA := p.isCRA(ctx)
isAngular := p.isAngular(ctx)
isReactRouter := p.isReactRouter(ctx)
isExpoSPA := p.isExpoSPA(ctx)
return (isVite || isAstro || isNext || isCRA || isAngular || isReactRouter || isExpoSPA) && p.getOutputDirectory(ctx) != ""
return (isVite || isAstro || isNext || isCRA || isAngular || isExpoSPA) && p.getOutputDirectory(ctx) != ""
}
// returns the canonical lowercase SPA framework name, or "" when none is detected.
func (p *NodeProvider) getSPAName(ctx *generate.GenerateContext) string {
// TODO react router is not always SPA!
if p.isReactRouter(ctx) {
return "react-router"
} else if p.isVite(ctx) {
if p.isReactRouterSPA(ctx) {
return "react-router"
}
return ""
}
if p.isVite(ctx) {
return "vite"
} else if p.isAstro(ctx) {
}
if p.isAstro(ctx) {
return "astro"
} else if p.isNextSPA(ctx) {
}
if p.isNextSPA(ctx) {
return "next"
} else if p.isCRA(ctx) {
}
if p.isCRA(ctx) {
return "cra"
} else if p.isAngular(ctx) {
}
if p.isAngular(ctx) {
return "angular"
} else if p.isExpoSPA(ctx) {
}
if p.isExpoSPA(ctx) {
return "expo"
} else {
// this could happen if the user forces SPA with env
log.Infof("No SPA framework detected")
}
// This can happen when the output directory environment variable forces SPA mode.
log.Infof("No SPA framework detected")
return ""
}
@@ -136,7 +146,9 @@ func (p *NodeProvider) getOutputDirectory(ctx *generate.GenerateContext) string
if dir, _ := ctx.Env.GetConfigVariable(OUTPUT_DIR_VAR); dir != "" {
outputDir = dir
} else if p.isReactRouter(ctx) {
outputDir = p.getReactRouterOutputDirectory(ctx)
if p.isReactRouterSPA(ctx) {
outputDir = p.getReactRouterOutputDirectory(ctx)
}
} else if p.isVite(ctx) {
outputDir = p.getViteOutputDirectory(ctx)
} else if p.isAstroSPA(ctx) {
@@ -164,6 +176,7 @@ func (p *NodeProvider) hasCustomStartCommand(ctx *generate.GenerateContext) bool
isCRAStartCommand := startCommand == DefaultCRAStartCommand
isExpoStartCommand := startCommand == DefaultExpoStartCommand
isNextStartCommand := startCommand == DefaultNextStartCommand
isReactRouterStartCommand := startCommand == DefaultReactRouterStartCommand
return startCommand != "" && !isAngularDefaultStartCommand && !isCRAStartCommand && !isExpoStartCommand && !isNextStartCommand
return startCommand != "" && !isAngularDefaultStartCommand && !isCRAStartCommand && !isExpoStartCommand && !isNextStartCommand && !isReactRouterStartCommand
}
+23
View File
@@ -17,6 +17,7 @@ func TestVite(t *testing.T) {
isCRA bool
isAngular bool
isReactRouter bool
isRRSPA bool
isExpo bool
outputDir string
}{
@@ -80,8 +81,17 @@ func TestVite(t *testing.T) {
isSPA: true,
isVite: true,
isReactRouter: true,
isRRSPA: true,
outputDir: "build/client/",
},
{
name: "react-router-ssr",
path: "../../../examples/node-vite-react-router-ssr",
isSPA: false,
isVite: true,
isReactRouter: true,
isRRSPA: false,
},
{
name: "expo-spa",
path: "../../../examples/expo-spa",
@@ -116,6 +126,9 @@ func TestVite(t *testing.T) {
isReactRouter := provider.isReactRouter(ctx)
require.Equal(t, tt.isReactRouter, isReactRouter)
isReactRouterSPA := provider.isReactRouterSPA(ctx)
require.Equal(t, tt.isRRSPA, isReactRouterSPA)
isExpo := provider.isExpoSPA(ctx)
require.Equal(t, tt.isExpo, isExpo)
@@ -162,6 +175,16 @@ func TestHasCustomStartCommand(t *testing.T) {
path: "../../../examples/expo-spa",
want: false,
},
{
name: "react router spa default",
path: "../../../examples/node-vite-react-router-spa",
want: false,
},
{
name: "react router ssr default",
path: "../../../examples/node-vite-react-router-ssr",
want: false,
},
}
for _, tt := range tests {
+5 -2
View File
@@ -215,8 +215,11 @@ static exports). Next.js reads `distDir` from your config when set. Set the
directory. Railpack uses your app's `build` script to produce the static
output.
Note that is an SPA framework is *not* detected automatically, can you force SPA mode
by specifying a `RAILPACK_SPA_OUTPUT_DIR` environment variable. This will enable SPA mode and serve the specified directory as a static site.
Note that if a SPA framework is *not* detected automatically, can you force SPA mode
by specifying a `RAILPACK_SPA_OUTPUT_DIR` environment variable. This will enable SPA
mode and serve the specified directory as a static site. Some of the SPA detection
uses regexes on framework configuration files, which will fail if the default framework
configuration files are customized.
Static sites are served using the [Caddy](https://caddyserver.com/) web server
and a [default
@@ -1,4 +1,5 @@
.DS_Store
.env
/node_modules/
# React Router
@@ -0,0 +1,22 @@
FROM node:24-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:24-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:24-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:24-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]
@@ -1,12 +1,18 @@
#!/usr/bin/env zsh
# Description: autogen this repo from the latest version of the RR template
cd "${0:A:h}"
# Description: autogen this repo from the latest version of the RR template
pnpx create-react-router@latest . --yes --overwrite --no-git-init
# remove the start script otherwise it won't be treated as an SPA
yq eval -i 'del(.scripts.start)' package.json
# disable SSR for SPA buildg st
# disable SSR for SPA build
sed -i '' 's/ssr: true/ssr: false/' react-router.config.ts
# Pin the build-time preview server to IPv4 until React Router connects to its bound address.
# https://github.com/remix-run/react-router/issues/15255
sed -i '' '/plugins: \[tailwindcss(), reactRouter()\],/a\
// Keep React Router'\''s preview server and prerender request on the same address family.\
preview: {\
host: "127.0.0.1",\
},
' vite.config.ts
@@ -1,29 +1,29 @@
{
"name": "note-vite-react-router-spa",
"name": "node-vite-react-router-spa",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"isbot": "^5.1.27",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.7.1"
"@react-router/node": "8.0.0",
"@react-router/serve": "8.0.0",
"isbot": "^5.1.36",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "8.0.0"
},
"devDependencies": {
"@react-router/dev": "^7.7.1",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.3",
"vite-tsconfig-paths": "^5.1.4"
"@react-router/dev": "8.0.0",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^22",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.3"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,6 @@
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
@@ -1,8 +1,14 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
plugins: [tailwindcss(), reactRouter()],
// Keep React Router's preview server and prerender request on the same address family.
preview: {
host: "127.0.0.1",
},
resolve: {
tsconfigPaths: true,
},
});
@@ -1,4 +1,5 @@
.DS_Store
.env
/node_modules/
# React Router
@@ -1,22 +1,22 @@
FROM node:20-alpine AS development-dependencies-env
FROM node:24-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
FROM node:24-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
FROM node:24-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
FROM node:24-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]
CMD ["npm", "run", "start"]
@@ -9,22 +9,21 @@
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"isbot": "^5.1.27",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.7.1"
"@react-router/node": "8.0.0",
"@react-router/serve": "8.0.0",
"isbot": "^5.1.36",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "8.0.0"
},
"devDependencies": {
"@react-router/dev": "^7.7.1",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.3",
"vite-tsconfig-paths": "^5.1.4"
"@react-router/dev": "8.0.0",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^22",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.3"
}
}
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,6 @@
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
@@ -1,8 +1,10 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
plugins: [tailwindcss(), reactRouter()],
resolve: {
tsconfigPaths: true,
},
});
+46
View File
@@ -46,3 +46,49 @@ backend = "go:github.com/go-delve/delve/cmd/dlv"
[[tools."go:github.com/wagoodman/dive"]]
version = "0.13.1"
backend = "go:github.com/wagoodman/dive"
[[tools.jq]]
version = "1.8.2"
backend = "aqua:jqlang/jq"
[tools.jq."platforms.linux-arm64"]
checksum = "sha256:8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309"
url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-arm64"
url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012756"
provenance = "github-attestations"
[tools.jq."platforms.linux-arm64-musl"]
checksum = "sha256:8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309"
url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-arm64"
url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012756"
provenance = "github-attestations"
[tools.jq."platforms.linux-x64"]
checksum = "sha256:b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f"
url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64"
url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012752"
provenance = "github-attestations"
[tools.jq."platforms.linux-x64-musl"]
checksum = "sha256:b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f"
url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64"
url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012752"
provenance = "github-attestations"
[tools.jq."platforms.macos-arm64"]
checksum = "sha256:2d75340ba57a4b4b4c8708a21c2dc8e958a48aaa8bba13b27f77f6e4c0eca07e"
url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-macos-arm64"
url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012783"
provenance = "github-attestations"
[tools.jq."platforms.macos-x64"]
checksum = "sha256:e94b266e3c26690550006abe63152b782280f4e14374accdf04cbde844f00bc0"
url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-macos-amd64"
url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012782"
provenance = "github-attestations"
[tools.jq."platforms.windows-x64"]
checksum = "sha256:a6fc67fedaf9128a3309a1e2ebb8b986aeccf70122ee46d2cb4849e423f0c627"
url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-windows-amd64.exe"
url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012788"
provenance = "github-attestations"