Skip to content
Open
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
140 changes: 74 additions & 66 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion examples/expo-blank-with-vite-metro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@
"devDependencies": {
"@babel/core": "^7.28.5",
"@vxrn/vite-plugin-metro": "workspace:*",
"vite": "^8.0.13"
"vite": "^8.1.0"
}
}
2 changes: 1 addition & 1 deletion examples/one-basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@
"devDependencies": {
"@react-native-community/cli": "^20.1.2",
"@types/react": "^19.2.2",
"vite": "^8.0.13"
"vite": "^8.1.0"
}
}
2 changes: 2 additions & 0 deletions examples/one-basic/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export default defineConfig({
one({
web: {
defaultRenderMode: 'ssg',
// vite 8.1 experimental bundled dev (rolldown FullBundleDevEnvironment)
experimentalBundledDev: !!process.env.BUNDLED_DEV,
},

// unified build mode — pages, api routes, and middlewares all build
Expand Down
2 changes: 1 addition & 1 deletion examples/testflight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,6 @@
"@react-native-community/cli": "^20.1.2",
"@tamagui/vite-plugin": "2.3.0",
"tsx": "^4.20.6",
"vite": "^8.0.13"
"vite": "^8.1.0"
}
}
124 changes: 124 additions & 0 deletions notes/vite-8.1-bundleddev-findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Vite 8.1 `experimental.bundledDev` (FullBundleDevEnvironment) + One

Status: **renders/hydrates behind an opt-in flag, but HMR is broken** — editing any
file crashes the dev server (upstream vite 8.1.0 `invalidateModule` recursion; see Known
limitations). Initial-load + navigation work; live editing does not. Enable with:

```ts
one({ web: { experimentalBundledDev: true } })
```

Dev-only (serve); ignored for builds and non-web environments. Validated on
`examples/one-basic`: homepage renders + hydrates, client `<Link>` navigation and
browser back/forward work, **zero console errors**. Existing plugin tests pass
(virtualEntryPlugin, fileSystemRouterPlugin) and normal (unbundled) dev is unaffected
(the new code paths are all gated on `isBundled` / the option).

The separate Vite 8.1 + Rolldown 1.1.2 **upgrade** is on branch `vite-8.1-upgrade`
(CI green); this bundled-dev support sits on top of it.

## What it does / why it's a win
Single rolldown-powered bundle for the web client instead of unbundled ESM dev:
~4× fewer requests, faster parse, big for apps with large icon/util imports
(e.g. lucide). Verified in a plain-Vite bench (42 lucide icons, assets inlined).

## The four problems that had to be solved (all One-specific; plain Vite is fine)
1. **Asset imports dropped.** rolldown's native asset path drops default-import asset
specifiers (`import x from './a.png'`) from raw-bundled node_modules deps under
One's client env — binding elided, usage kept → `X is not defined`
(`@react-navigation/elements` icon Assets). NOT fixable via `moduleTypes`. Fix: a
`transform`-stage inliner (`one:web-bundled-asset`) rewrites such imports to
data-uris — transform() DOES run in the FullBundleDev pipeline.
→ `plugins/bundledDevPlugin.ts`
2. **Barrel re-export linking.** rolldown 1.1.0 defaulted `lazyBarrel` ON, which breaks
One's `index.mjs` barrel re-exports under FullBundleDev (`Slot` resolved to a bare
undefined instead of the real `Slot$1`). Fix: `experimental.lazyBarrel: false` on the
client build rolldownOptions. → `plugins/bundledDevPlugin.ts`
3. **Missing exports.** With lazyBarrel off, react-native packages importing native-only
exports (`codegenNativeComponent`, `TurboModuleRegistry`, …) from react-native →
react-native-web (which lacks them) fail the build. Fix: `shimMissingExports: true` on
the client build rolldownOptions (matches what the dep optimizer already does).
→ `plugins/bundledDevPlugin.ts`
4. **React-refresh preamble.** In bundled mode `/@vite/client` and `/@react-refresh`
aren't standalone modules, so `/@one/dev.js` (which imports them) fails to load and
`$RefreshReg$` never installs → every compiler-wrapped route module throws "preamble
was not loaded". Fixes:
- install the preamble in the bundled client *entry* body (runs before lazily-globbed
route modules; `one`/`/@react-refresh` aren't refresh-wrapped). → `virtualEntryPlugin.ts`
- drop `/@vite/client` from preloads in bundled mode (it 404s). → `fileSystemRouterPlugin.tsx`
- stub `/@vite/client` + `/@react-refresh` imports in `/@one/dev.js` so the devtools
script doesn't take itself down. → `devtoolsPlugin.ts`

## Files
- `plugins/bundledDevPlugin.ts` (new) — the asset inliner + the `config()` that turns on
`experimental.bundledDev` and the client `build.rolldownOptions` (entry input,
lazyBarrel off, shimMissingExports, asset plugin).
- `one.ts` — wires `bundledDevPlugin(!!options.web?.experimentalBundledDev)`.
- `types.ts` — `web.experimentalBundledDev?: boolean`.
- `virtualEntryPlugin.ts`, `fileSystemRouterPlugin.tsx`, `devtoolsPlugin.ts` — the
preamble / preload / devtools fixes above (all gated on bundled mode).

## tamagui.dev validation (2026-06-23)
Linked local One 1.20 into `~/tamagui2` via `bun release --into ~/tamagui2` (1.16.4→1.20)
and ran `BUNDLED_DEV=1 bun run dev` on `~/tamagui2/code/tamagui.dev` (flag added to its
`web` config, gated on `process.env.BUNDLED_DEV`). **Homepage + /community + /docs are
clean on a true cold load** (renders, hydrates, refresh installed, zero console/page errors,
full styling — playwright headless, fresh server each time). Three issues surfaced; two fixed.

5. **SSR/client asset divergence (FIXED).** The client asset inliner (problem 1) only ran
on the client, so SSR emitted the original asset URL (`<img src="/features/.../mj.jpg">`)
while the client emitted a data-uri → that URL 404s under FullBundleDev **and** the two
differ → React hydration mismatch. Fix: run the same inliner on the `ssr` env too, via a
top-level `transform` on the bundled-dev plugin (the rolldown client plugin can't reach
the ssr pipeline). Now both sides emit identical data-uris. → `plugins/bundledDevPlugin.ts`

6. **Cold-first-compile root-layout crash (FIXED — in tamagui, not One).** Every route's
first cold load threw `Cannot read properties of undefined (reading 'jsxDEV')` into the
error boundary. The jsxDEV message was a *symptom*: an instrumented cold load showed the
layout factory registered its `default` export then **threw before** the
`react/jsx-dev-runtime` init line, so React rendered `Layout` with the (var-hoisted, still
undefined) jsx runtime. The real throw was `TypeError: Illegal invocation` from
`createTamagui → configureMedia → setupMediaListeners → getMatch`. `@tamagui/web`'s
`helpers/matchMedia.ts` exports the *unbound* native fn:
`(typeof window !== 'undefined' && window.matchMedia) || matchMediaFallback`, and
`getMatch = () => matchMedia(str)`. Normal bundlers compile that named-import call to the
`(0, ns.matchMedia)(str)` detached form (this=undefined → sloppy → window, works);
rolldown's bundled-dev interop compiled it `ns.matchMedia(str)` (this = module namespace)
→ native matchMedia throws. Fix: `window.matchMedia?.bind(window)` in `matchMedia.ts` —
defensively correct under any bundler. → `~/tamagui2/code/core/web/src/helpers/matchMedia.ts`
(+ dist esm/cjs for local testing). **Should be committed/upstreamed in tamagui proper.**

## Known limitations (acceptable for experimental; future work)
- **Cross-blob cold race on heavily-lazy-split routes (OPEN, low severity).** `/takeout` has
a `React.lazy` 3D component (`TakeoutBox3D`) wrapped in its own error boundary that, on a
cold first load, throws `Cannot convert undefined or null to object` from the runtime's
`__toCommonJS(undefined)` — a CJS dep referenced before its init across concurrently-loading
lazy blobs that share `__rolldown_runtime__.modules`. Only that box fails; the rest of the
page renders, and a reload fixes it (warm is clean). This is a genuine FullBundleDev
experimental-mode concurrency issue (not cleanly reachable from One/tamagui — the throwing
`__toCommonJS` lives in the generated rolldown runtime). A null-safe `__toCommonJS` or less
aggressive lazy-splitting would address it.
- **HMR is BROKEN in bundled mode and crashes the dev server (OPEN, blocker for daily use —
upstream vite 8.1.0 bug).** Live-tested 2026-06-23 (playwright, edit `HomeHero.tsx`
"Write less", browser connected, no reload): the edit (1) never applies to the DOM — Fast
Refresh does not work — and (2) crashes the whole dev server a few seconds later with
`RangeError: Maximum call stack size exceeded`, exit code 7. Root cause is a stock vite
8.1.0 bug in `DevEnvironment.invalidateModule` (`vite/dist/node/chunks/node.js:34276`):
```js
invalidateModule(m, _client) {
if (this.bundledDev) { this.invalidateModule(m, _client); return; } // self-recurse → stack overflow
... // real HMR logic only on the non-bundledDev path
}
```
Any HMR invalidation in bundledDev mode self-recurses forever. Confirmed contrast: NORMAL
(unbundled) dev on the same vite 8.1 branch applies the edit to the DOM and the server
stays up (Fast Refresh works, 0 errors). With no browser attached, the edit does NOT crash
the server — the crash needs a connected client to send the invalidate over `/__vxrnhmr`.
So earlier "Fast Refresh works" notes were wrong: only the *mechanism* (preamble + per-module
wrappers) was confirmed present, never a live edit. Fix is upstream in vite (the bundledDev
branch must call the real invalidation, not itself); One could patch-package it to make
bundled-dev HMR usable at all. One's own `createHotContext` route/loader/css/cursor channels
are additionally a no-op in bundled mode (need `import.meta.hot` wiring), but that's moot
until the vite recursion is fixed.
- FullBundleDev re-bundles the reachable graph per lazy route (`/@vite/lazy` ≈ multi-MB
per route) — fine functionally, a perf characteristic of the experimental mode.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@
"tar": "7.5.11",
"undici": "7.28.0",
"esbuild": "0.28.1",
"vite": "8.0.16",
"rolldown": "1.0.1",
"vite": "8.1.0",
"rolldown": "1.1.2",
"basic-ftp": "5.3.1",
"fast-xml-builder": "1.1.7",
"fast-uri": "3.1.2",
Expand Down
6 changes: 3 additions & 3 deletions packages/compiler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@
"devDependencies": {
"@tamagui/build": "2.3.0",
"depcheck": "^1.4.7",
"rolldown": "^1.0.1",
"vite": "^8.0.13"
"rolldown": "^1.1.2",
"vite": "^8.1.0"
},
"peerDependencies": {
"vite": "^8.0.13"
"vite": "^8.1.0"
},
"peerDependenciesMeta": {
"vite": {
Expand Down
4 changes: 2 additions & 2 deletions packages/one/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@
"ts-pattern": "^5.6.2",
"tsconfig-paths": "^4",
"use-latest-callback": "^0.2.3",
"vite": "^8.0.13",
"vite": "^8.1.0",
"vxrn": "workspace:*",
"ws": "^8.21.0",
"xxhashjs": "^0.2.2"
Expand Down Expand Up @@ -281,7 +281,7 @@
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-test-renderer": "^19.2.0",
"rolldown": "^1.0.1",
"rolldown": "^1.1.2",
"sharp": "^0.34.5",
"typescript": "^5.7.3",
"vitest": "^4.1.0"
Expand Down
3 changes: 3 additions & 0 deletions packages/one/src/vite/one.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { setServerGlobals } from '../server/setServerGlobals'
import { getRouterRootFromOneOptions } from '../utils/getRouterRootFromOneOptions'
import { ensureTSConfig } from './ensureTsConfig'
import { setOneOptions } from './loadConfig'
import { bundledDevPlugin } from './plugins/bundledDevPlugin'
import { clientTreeShakePlugin } from './plugins/clientTreeShakePlugin'
import { createDevtoolsPlugin } from './plugins/devtoolsPlugin'
import { createFileSystemRouterPlugin } from './plugins/fileSystemRouterPlugin'
Expand Down Expand Up @@ -285,6 +286,8 @@ export function one(options: One.PluginOptions = {}): PluginOption {
},
},

bundledDevPlugin(!!options.web?.experimentalBundledDev),

environmentGuardPlugin(options.environmentGuards),

criticalCSSPlugin(),
Expand Down
116 changes: 116 additions & 0 deletions packages/one/src/vite/plugins/bundledDevPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import fs from 'node:fs'
import path from 'node:path'
import type { Plugin } from 'vite'
import { virtualEntryId } from './virtualEntryConstants'

// Vite 8.1 experimental bundled dev (FullBundleDevEnvironment): a rolldown-powered
// single-bundle web client. This plugin wires up the pieces One needs to run under
// it. Enable with `one({ web: { experimentalBundledDev: true } })`. Only active in
// dev (serve); a no-op for builds and for non-web environments.
//
// Companion changes live where they're structurally required:
// - virtualEntryPlugin: installs the react-refresh preamble into the bundled
// client entry (the separate /@one/dev.js path can't, since /@vite/client isn't
// a standalone module in bundled mode).
// - fileSystemRouterPlugin: serves the bundled entry url and drops /@vite/client
// from preloads.
// - devtoolsPlugin: stubs /@vite/client + /@react-refresh imports in /@one/dev.js.

const MIME: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
ico: 'image/x-icon',
}

// rolldown's native asset handling drops default-import asset specifiers
// (`import logo from './logo.png'`) coming from raw-bundled node_modules deps under
// One's client env — the binding is elided while its usage is kept, crashing with
// "X is not defined" (e.g. @react-navigation/elements' icon Assets). transform() DOES
// run in the FullBundleDev pipeline, so rewrite those imports to inline data-uris.
//
// The same data-uri must be produced on the SERVER too: SSR otherwise emits the
// original asset URL (e.g. `<img src="/features/.../mj.jpg">`), which both 404s under
// FullBundleDev and diverges from the client's inlined data-uri → React hydration
// mismatch. So the inliner runs on client (rolldown plugin) and ssr (top-level
// transform) alike — see bundledDevPlugin().
function inlineAssetImports(code: string, id: string): { code: string; map: null } | null {
if (!/\.(png|jpe?g|gif|webp|avif|bmp|ico)/.test(code)) return null
const importRe =
/import\s+(\w+)\s+from\s*['"]([^'"?]+\.(?:png|jpe?g|gif|webp|avif|bmp|ico))['"]\s*;?/g
const base = id.split('?')[0]
let result = code
let changed = false
let m: RegExpExecArray | null
const edits: { full: string; name: string; uri: string }[] = []
while ((m = importRe.exec(code))) {
const [full, name, spec] = m
const abs = spec.startsWith('.') ? path.resolve(path.dirname(base), spec) : null
if (!abs || !fs.existsSync(abs)) continue
const ext = path.extname(abs).slice(1).toLowerCase()
const uri = `data:${MIME[ext] || 'application/octet-stream'};base64,${fs.readFileSync(abs).toString('base64')}`
edits.push({ full, name, uri })
}
for (const e of edits) {
result = result.replace(e.full, `const ${e.name} = ${JSON.stringify(e.uri)};`)
changed = true
}
return changed ? { code: result, map: null } : null
}

function webBundledAssetPlugin() {
return {
name: 'one:web-bundled-asset',
transform: (code: string, id: string) => inlineAssetImports(code, id),
}
}

export function bundledDevPlugin(enabled: boolean): Plugin {
return {
name: 'one:bundled-dev',

// mirror the client's asset inlining on the server env so SSR emits the same
// data-uris (the rolldown client plugin above can't reach the ssr pipeline).
transform(code, id) {
if (!enabled) return
const env = (this as any).environment
if (!env || env.name !== 'ssr' || env.mode !== 'dev') return
return inlineAssetImports(code, id)
},

config(_userConfig, env) {
if (!enabled || env.command !== 'serve') return

return {
experimental: { bundledDev: true },
environments: {
client: {
build: {
rolldownOptions: {
// bundle the virtual entry so FullBundleDev serves it at
// /assets/_virtual_one-entry.js
input: [virtualEntryId],
plugins: [webBundledAssetPlugin()],
experimental: {
// rolldown 1.1.0 defaulted lazyBarrel ON, which breaks One's
// index.mjs barrel re-exports under FullBundleDev (e.g. `Slot`
// resolves to undefined). pin it off.
lazyBarrel: false,
},
// react-native packages import native-only exports
// (codegenNativeComponent, TurboModuleRegistry, …) from react-native
// → aliased to react-native-web, which doesn't export them. shim as
// undefined instead of failing the build (matches the dep optimizer).
shimMissingExports: true,
},
},
},
},
}
},
}
}
19 changes: 19 additions & 0 deletions packages/one/src/vite/plugins/devtoolsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export function createDevtoolsPlugin(options: DevtoolsPluginOptions = {}): Plugi
apply: 'serve', // only in dev

configureServer(server) {
// vite 8.1 bundledDev: /@vite/client and /@react-refresh aren't standalone
// modules (the HMR client + refresh runtime are bundled into the entry), so
// dev.mjs's top-level imports of them 404 / fail MIME checks and take the whole
// devtools script down. stub them: HMR (route/loader/css/cursor) becomes a
// no-op here and the refresh preamble is installed by the bundled entry instead.
const isBundledDev = !!(server.config as any).experimental?.bundledDev

// serve the devtools script by reading and combining the source files
server.middlewares.use(async (req, res, next) => {
if (req.url === DEVTOOLS_VIRTUAL_ID) {
Expand All @@ -31,6 +38,18 @@ export function createDevtoolsPlugin(options: DevtoolsPluginOptions = {}): Plugi
.replace("import './devtools.mjs'", '')
.replace("import './source-inspector.mjs'", '')

if (isBundledDev) {
code = code
.replace(
"import { createHotContext } from '/@vite/client'",
'const createHotContext = () => ({ on() {}, off() {}, accept() {}, dispose() {}, prune() {}, send() {}, invalidate() {} })'
)
.replace(
"import { injectIntoGlobalHook } from '/@react-refresh'",
'const injectIntoGlobalHook = () => {}'
)
}

// only include devtools UI if enabled
if (includeUI) {
const devtoolsPath = resolvePath('one/devtools/devtools.mjs')
Expand Down
15 changes: 14 additions & 1 deletion packages/one/src/vite/plugins/fileSystemRouterPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ const routeTypeColors: Record<string, (s: string) => string> = {
const USE_SERVER_ENV = false //!!process.env.USE_SERVER_ENV

export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin {
const preloads = ['/@vite/client', virtalEntryIdClient]
// under vite 8.1 bundledDev the client entry is bundled and served at a fixed
// /assets url instead of the unbundled /@id/__x00__virtual url. swapped in
// configureServer once we can see whether the client env is bundled.
let preloads = ['/@vite/client', virtalEntryIdClient]

let runner: ModuleRunner
let server: ViteDevServer
Expand Down Expand Up @@ -637,6 +640,16 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
configureServer(serverIn) {
server = serverIn

// vite 8.1 bundledDev: the client env bundles the virtual entry and serves
// it at /assets/_virtual_one-entry.js. the unbundled /@id/__x00__virtual url
// 404s in this mode, so point preloads (and bootstrap) at the bundled url.
const clientEnv = server.environments?.client as any
if (clientEnv?.config?.isBundled || clientEnv?.bundledDev) {
// bundled mode: the HMR client + refresh runtime are bundled into the
// entry; /@vite/client doesn't exist as a standalone module (404s).
preloads = ['/assets/_virtual_one-entry.js']
}

// change this to .server to test using the indepedently scoped env
runner = createServerModuleRunner(
USE_SERVER_ENV ? server.environments.server : server.environments.ssr
Expand Down
Loading
Loading