Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ website/public/llms.txt
website/public/.well-known/llms.txt
packages/core/docs/
benchmarks/.results/

# Playwright output
test-results/
playwright-report/
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,55 @@

All notable changes to Pareto will be documented in this file.

## 5.0.0 (2026-04-08)

### Breaking Changes

- **`configureVite` config option removed** — The framework-specific `configureVite(config, { isServer })` hook has been removed. Users now customize Vite via a standard `vite.config.ts` in their project root, which Pareto loads and merges natively in both dev and build modes. This aligns Pareto with the Vite ecosystem convention (TanStack Start, Vike, etc.) where frameworks do not expose custom config hooks.

**Migration:** Move your `configureVite` logic into a `vite.config.ts` at the project root.

Before (`pareto.config.ts`):
```ts
export default {
configureVite(config, { isServer }) {
config.plugins.push(tsconfigPaths())
if (isServer) {
config.ssr = { ...config.ssr, external: ['heavy-lib'] }
}
return config
},
}
```

After (`vite.config.ts`):
```ts
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
plugins: [tsconfigPaths()],
ssr: {
external: ['heavy-lib'],
},
})
```

For client/server-specific config, use Vite's native `isSsrBuild` flag:
```ts
export default defineConfig(({ isSsrBuild }) => ({
plugins: [!isSsrBuild && clientOnlyPlugin()].filter(Boolean),
}))
```

- **`vite` moved to `peerDependencies`** — Users must install `vite` in their project (any version `^6.0.0 || ^7.0.0`) to write `vite.config.ts`. Most projects already have it transitively.

### Bug Fixes

- **`configureVite` was never called in dev mode** ([#13](https://github.com/childrentime/pareto/issues/13)) — The root cause is addressed by removing the hook entirely and routing customization through standard Vite config, which works identically in dev and build.
- **Dev server SSR now respects `ssr` config** — The dev server now includes `ssr.noExternal: [/^@paretojs\//]` matching build mode, so `ssrLoadModule` behaves consistently between dev and build.
- **Fixed flaky streaming SSR e2e test** — Use `waitUntil: 'commit'` for streaming pages so assertions run against the initial shell without waiting for the full deferred stream.

## 4.0.0 (2026-03-31)

### Breaking Changes
Expand Down
15 changes: 15 additions & 0 deletions example-custom-server/app/vite-config-test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
declare const __PARETO_TEST_VITE_CONFIG__: string | undefined

export default function ViteConfigTestPage() {
const value =
typeof __PARETO_TEST_VITE_CONFIG__ !== 'undefined'
? __PARETO_TEST_VITE_CONFIG__
: 'not-defined'

return (
<div>
<h1>vite.config.ts Test</h1>
<p data-testid="vite-config-value">{value}</p>
</div>
)
}
13 changes: 13 additions & 0 deletions example-custom-server/e2e/vite-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, test } from '@playwright/test'

test.describe('vite.config.ts integration (custom server)', () => {
test('user vite.config.ts define is applied in dev mode', async ({
page,
}) => {
await page.goto('/vite-config-test')
await expect(page.locator('h1')).toContainText('vite.config.ts Test')

const value = page.locator('[data-testid="vite-config-value"]')
await expect(value).toHaveText('vite-config-works')
})
})
4 changes: 3 additions & 1 deletion example-custom-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "pareto-example-custom-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "pareto dev",
"build": "NODE_ENV=production pareto build",
Expand All @@ -22,6 +23,7 @@
"@types/react-dom": "catalog:",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.0"
"tailwindcss": "^3.4.0",
"vite": "^8.0.7"
}
}
2 changes: 1 addition & 1 deletion example-custom-server/postcss.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
Expand Down
2 changes: 1 addition & 1 deletion example-custom-server/tailwind.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
export default {
content: ['./app/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
Expand Down
4 changes: 0 additions & 4 deletions example-custom-server/test-results/.last-run.json

This file was deleted.

7 changes: 7 additions & 0 deletions example-custom-server/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'

export default defineConfig({
define: {
__PARETO_TEST_VITE_CONFIG__: JSON.stringify('vite-config-works'),
},
})
8 changes: 8 additions & 0 deletions examples/.env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Server-only variables (accessible via process.env, NOT exposed to client)
DATABASE_URL=postgresql://localhost:5432/myapp
API_SECRET=server-secret-value

# Client-accessible variables (PARETO_ prefix)
# These are exposed to client-side code via import.meta.env
PARETO_API_URL=https://api.example.com
PARETO_APP_NAME=My Pareto App
68 changes: 68 additions & 0 deletions examples/app/env-test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { LoaderContext } from '@paretojs/core'
import { useLoaderData } from '@paretojs/core'

// Loader runs on the server only — can read server-only process.env vars.
export function loader(_ctx: LoaderContext) {
return {
// Server reads server-only var from process.env
serverSideApiSecret: process.env.API_SECRET ?? 'undefined',
serverSideDatabaseUrl: process.env.DATABASE_URL ?? 'undefined',
// Server can also read PARETO_ vars from process.env
serverSidePretoApiUrl: process.env.PARETO_API_URL ?? 'undefined',
}
}

interface LoaderData {
serverSideApiSecret: string
serverSideDatabaseUrl: string
serverSidePretoApiUrl: string
}

export default function EnvTestPage() {
const data = useLoaderData<LoaderData>()

// Client reads PARETO_ vars via import.meta.env (inlined at build time).
// Server-only vars (API_SECRET, DATABASE_URL) are NOT present on
// import.meta.env — they would render as "undefined".
const clientSideApiUrl = import.meta.env.PARETO_API_URL ?? 'undefined'
const clientSideAppName = import.meta.env.PARETO_APP_NAME ?? 'undefined'
// These should be undefined on the client — Vite does not expose
// unprefixed vars to import.meta.env.
const clientSideApiSecret =
(import.meta.env as Record<string, string | undefined>).API_SECRET ??
'undefined'
const clientSideDatabaseUrl =
(import.meta.env as Record<string, string | undefined>).DATABASE_URL ??
'undefined'

return (
<div>
<h1>Env Test</h1>

<section>
<h2>Server-side (loader, process.env)</h2>
<p data-testid="server-api-secret">
API_SECRET: {data.serverSideApiSecret}
</p>
<p data-testid="server-database-url">
DATABASE_URL: {data.serverSideDatabaseUrl}
</p>
<p data-testid="server-pareto-api-url">
PARETO_API_URL: {data.serverSidePretoApiUrl}
</p>
</section>

<section>
<h2>Client-side (import.meta.env)</h2>
<p data-testid="client-api-url">PARETO_API_URL: {clientSideApiUrl}</p>
<p data-testid="client-app-name">
PARETO_APP_NAME: {clientSideAppName}
</p>
<p data-testid="client-api-secret">API_SECRET: {clientSideApiSecret}</p>
<p data-testid="client-database-url">
DATABASE_URL: {clientSideDatabaseUrl}
</p>
</section>
</div>
)
}
15 changes: 15 additions & 0 deletions examples/app/vite-config-test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
declare const __PARETO_TEST_VITE_CONFIG__: string | undefined

export default function ViteConfigTestPage() {
const value =
typeof __PARETO_TEST_VITE_CONFIG__ !== 'undefined'
? __PARETO_TEST_VITE_CONFIG__
: 'not-defined'

return (
<div>
<h1>vite.config.ts Test</h1>
<p data-testid="vite-config-value">{value}</p>
</div>
)
}
6 changes: 5 additions & 1 deletion examples/e2e/basic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ test.describe('SSR rendering', () => {
})

test('stream page renders quick stats immediately', async ({ page }) => {
await page.goto('/stream')
// Use waitUntil:'commit' — the h1 and quick stats are in the initial
// shell, so we can assert as soon as the first bytes arrive without
// waiting for the full deferred stream to finish (which can take 2-3s
// and cause flaky timeouts under heavy parallelism).
await page.goto('/stream', { waitUntil: 'commit' })
await expect(page.locator('h1')).toContainText('Streaming SSR')
await expect(page.locator('text=12,847')).toBeVisible()
await expect(page.locator('text=48,392')).toBeVisible()
Expand Down
51 changes: 51 additions & 0 deletions examples/e2e/env.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test'

test.describe('Environment variables', () => {
test('server-only vars are accessible in loaders but NOT on the client', async ({
page,
}) => {
await page.goto('/env-test')
await expect(page.locator('h1')).toContainText('Env Test')

// Server side: loader reads process.env — both PARETO_ and unprefixed
// vars are accessible.
await expect(page.locator('[data-testid="server-api-secret"]')).toHaveText(
'API_SECRET: server-secret-value',
)
await expect(
page.locator('[data-testid="server-database-url"]'),
).toHaveText('DATABASE_URL: postgresql://localhost:5432/myapp')
await expect(
page.locator('[data-testid="server-pareto-api-url"]'),
).toHaveText('PARETO_API_URL: https://api.example.com')
})

test('PARETO_ prefixed vars are accessible on the client', async ({
page,
}) => {
await page.goto('/env-test')

// Client side: only PARETO_ prefixed vars are inlined via import.meta.env
await expect(page.locator('[data-testid="client-api-url"]')).toHaveText(
'PARETO_API_URL: https://api.example.com',
)
await expect(page.locator('[data-testid="client-app-name"]')).toHaveText(
'PARETO_APP_NAME: My Pareto App',
)
})

test('unprefixed server-only vars are NOT exposed on the client', async ({
page,
}) => {
await page.goto('/env-test')

// Security check: unprefixed vars must NOT be visible on the client.
// Vite's envPrefix: 'PARETO_' ensures unprefixed vars stay server-side.
await expect(page.locator('[data-testid="client-api-secret"]')).toHaveText(
'API_SECRET: undefined',
)
await expect(
page.locator('[data-testid="client-database-url"]'),
).toHaveText('DATABASE_URL: undefined')
})
})
15 changes: 15 additions & 0 deletions examples/e2e/vite-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { expect, test } from '@playwright/test'

test.describe('vite.config.ts integration', () => {
test('user vite.config.ts define is applied in dev mode', async ({
page,
}) => {
await page.goto('/vite-config-test')
await expect(page.locator('h1')).toContainText('vite.config.ts Test')

// __PARETO_TEST_VITE_CONFIG__ is defined in the user's vite.config.ts.
// Vite auto-loads it and merges with Pareto's internal config.
const value = page.locator('[data-testid="vite-config-value"]')
await expect(value).toHaveText('vite-config-works')
})
})
4 changes: 3 additions & 1 deletion examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "pareto-example",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "pareto dev",
"build": "NODE_ENV=production pareto build",
Expand All @@ -21,6 +22,7 @@
"@types/react-dom": "catalog:",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.0"
"tailwindcss": "^3.4.0",
"vite": "^7.3.1"
}
}
2 changes: 1 addition & 1 deletion examples/postcss.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
Expand Down
2 changes: 1 addition & 1 deletion examples/tailwind.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
export default {
content: ['./app/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
Expand Down
4 changes: 0 additions & 4 deletions examples/test-results/.last-run.json

This file was deleted.

2 changes: 1 addition & 1 deletion examples/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@
"isolatedModules": true,
"noEmit": true
},
"include": ["app/**/*"]
"include": ["app/**/*", "vite.config.ts"]
}
7 changes: 7 additions & 0 deletions examples/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'

export default defineConfig({
define: {
__PARETO_TEST_VITE_CONFIG__: JSON.stringify('vite-config-works'),
},
})
5 changes: 3 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@paretojs/core",
"version": "4.0.1",
"version": "5.0.0",
"homepage": "https://paretojs.tech/",
"repository": {
"type": "git",
Expand Down Expand Up @@ -92,7 +92,8 @@
"peerDependencies": {
"express": "^4.0.0 || ^5.0.0",
"react": "catalog:",
"react-dom": "catalog:"
"react-dom": "catalog:",
"vite": "^6.0.0 || ^7.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "^4.5.0",
Expand Down
Loading
Loading