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
32 changes: 32 additions & 0 deletions apps/onestack.dev/data/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,38 @@ One assumes the following environments:
- `ios`: iOS
- `android`: Android

The default native bundler uses Rolldown directly. A normal Vite plugin is not
automatically forwarded into that pipeline because many Vite hooks require a
Vite dev server or browser build. Plugins that support native can provide an
explicit Rolldown implementation with `withNativePlugin`:

```tsx fileName=vite.config.ts
import { one, withNativePlugin } from 'one/vite'

const sourceCompiler = withNativePlugin(
{
name: 'source-compiler',
transform(code, id) {
// web transform
},
},
({ platform, dev }) => ({
name: `source-compiler-${platform}`,
transform(code, id) {
// native Rolldown transform
},
})
)

export default {
plugins: [one(), sourceCompiler],
}
```

The factory receives `root`, `platform`, and `dev`, and creates a separate plugin
instance for each native engine. This keeps iOS and Android compiler caches and
watch state isolated. Metro mode continues to use Metro's transformer pipeline.

We set up platform-specific extensions based on the environment:

- client: `.web.(js|ts|tsx|mjs)`
Expand Down
5 changes: 5 additions & 0 deletions packages/one/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import './server/setupServerGlobals'

// plugins
export { resolvePath } from '@vxrn/resolve'
export {
withNativePlugin,
type NativePluginContext,
type NativePluginFactory,
} from 'vxrn/vite-plugin'
export { build } from './cli/build'
export { makePluginWebOnly } from './vite/makePluginWebOnly'
export { one } from './vite/one'
Expand Down
38 changes: 17 additions & 21 deletions packages/one/src/vite/one.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { normalizePath, type Plugin, type PluginOption } from 'vite'
import { autoDepOptimizePlugin, getOptionsFilled, loadEnv } from 'vxrn'
import vxrnVitePlugin from 'vxrn/vite-plugin'
import vxrnVitePlugin, { withNativePlugin } from 'vxrn/vite-plugin'
import { CACHE_KEY } from '../constants'
import { getViteMetroPluginOptions } from '../metro-config/getViteMetroPluginOptions'
import '../polyfills-server'
Expand Down Expand Up @@ -61,10 +61,24 @@ export function one(options: One.PluginOptions = {}): PluginOption {
// and all native-only globals — One runs as a pure web framework.
const nativeDisabled = options.native === false
const nativeOptions = options.native === false ? undefined : options.native
const flags: One.Flags = {}
const nativeClientTreeShakePlugin = withNativePlugin(clientTreeShakePlugin(), () =>
clientTreeShakePlugin({ runtime: 'rolldown' })
)

if (nativeDisabled) {
// tamagui compiler reads this to decide whether to process the native env
globalThis.__vxrnEnableNativeEnv = false
delete globalThis.__vxrnNativeEntryConfig
} else {
globalThis.__vxrnEnableNativeEnv = true
globalThis.__vxrnNativeEntryConfig = {
routerRoot,
ignoredRouteFiles: options.router?.ignoredRouteFiles,
linking: options.router?.linking,
setupFile: options.setupFile,
flags,
}
}

/**
Expand Down Expand Up @@ -141,7 +155,7 @@ export function one(options: One.PluginOptions = {}): PluginOption {
setOneOptions(options)
globalThis['__vxrnPluginConfig__'] = options
globalThis['__vxrnMetroOptions__'] = metroOptions
return []
return nativeDisabled ? [] : [nativeClientTreeShakePlugin]
}
}

Expand Down Expand Up @@ -828,27 +842,9 @@ export function one(options: One.PluginOptions = {}): PluginOption {
] satisfies Plugin[]

// TODO move to single config and through environments
const nativeWebDevAndProdPlugsin: Plugin[] = [clientTreeShakePlugin()]

// TODO make this passed into vxrn through real API
if (!nativeDisabled) {
globalThis.__vxrnAddNativePlugins = [clientTreeShakePlugin({ runtime: 'rolldown' })]
}
const nativeWebDevAndProdPlugsin: Plugin[] = [nativeClientTreeShakePlugin]
globalThis.__vxrnAddWebPluginsProd = devAndProdPlugins

const flags: One.Flags = {}

// pass config to the rolldown native entry (createNativeDevEngine reads this)
if (!nativeDisabled) {
globalThis.__vxrnNativeEntryConfig = {
routerRoot: routerRoot,
ignoredRouteFiles: options.router?.ignoredRouteFiles,
linking: options.router?.linking,
setupFile: options.setupFile,
flags,
}
}

// source inspector must come before clientTreeShakePlugin so line numbers
// are computed from original source (tree-shaking removes loader code, shifting lines)
const inspectorPlugins = (() => {
Expand Down
1 change: 1 addition & 0 deletions packages/one/types/vite.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import './server/setupServerGlobals';
export { resolvePath } from '@vxrn/resolve';
export { withNativePlugin, type NativePluginContext, type NativePluginFactory, } from 'vxrn/vite-plugin';
export { build } from './cli/build';
export { makePluginWebOnly } from './vite/makePluginWebOnly';
export { one } from './vite/one';
Expand Down
10 changes: 9 additions & 1 deletion packages/vxrn/src/exports/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { BuildArgs, VXRNOptions } from '../types'
import { getServerCJSSetting, getServerEntry } from '../utils/getServerEntry'
import { applyBuiltInPatches } from '../utils/patches'
import { loadEnv } from './loadEnv'
import { getNativePluginsFromOptions } from '../nativePlugin'

const { existsSync } = FSExtra

Expand Down Expand Up @@ -96,7 +97,14 @@ export const build = async (optionsIn: VXRNOptions, buildArgs: BuildArgs = {}) =

return buildBundle(
[],
{ root: options.root },
{
root: options.root,
nativePlugins: await getNativePluginsFromOptions(userViteConfig?.plugins ?? [], {
root: options.root,
platform: buildArgs.platform,
dev: false,
}),
},
{
platform: buildArgs.platform,
bundleOutput: `${outDir}${sep}${buildArgs.platform}.js`,
Expand Down
126 changes: 126 additions & 0 deletions packages/vxrn/src/nativePlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { rolldown } from 'rolldown'
import type { Plugin as VitePlugin } from 'vite'
import { describe, expect, it } from 'vitest'
import {
getNativePlugins,
getNativePluginsFromOptions,
withNativePlugin,
} from './nativePlugin'

describe('native Vite plugin providers', () => {
it('only creates explicitly provided native plugins', () => {
const webOnly = { name: 'web-only' }
const shared = withNativePlugin({ name: 'shared' }, ({ platform, dev }) => ({
name: `shared-${platform}-${dev ? 'dev' : 'prod'}`,
}))

expect(
getNativePlugins([webOnly, shared], {
root: '/project',
platform: 'android',
dev: false,
}).map((plugin) => plugin.name)
).toEqual(['shared-android-prod'])
})

it('preserves an existing plugin api', () => {
const plugin = withNativePlugin({ name: 'shared', api: { marker: true } }, () => ({
name: 'shared-native',
}))

expect(plugin.api).toMatchObject({ marker: true })
expect(typeof (plugin.api as any).vxrnNative).toBe('function')
})

it('flattens async Vite plugin options', async () => {
const provider = withNativePlugin({ name: 'shared' }, () => ({
name: 'shared-native',
}))

const plugins = await getNativePluginsFromOptions(
[false, Promise.resolve([undefined, provider])],
{ root: '/project', platform: 'ios', dev: true }
)

expect(plugins.map((plugin) => plugin.name)).toEqual(['shared-native'])
})

it('preserves provider order while resolving async plugin options', async () => {
let resolveFirst!: (plugin: VitePlugin) => void
const first = new Promise<VitePlugin>((resolve) => {
resolveFirst = resolve
})
const second = Promise.resolve(
withNativePlugin({ name: 'second' }, () => ({ name: 'second-native' }))
)
const pluginsPromise = getNativePluginsFromOptions([first, second], {
root: '/project',
platform: 'ios',
dev: true,
})

resolveFirst(withNativePlugin({ name: 'first' }, () => ({ name: 'first-native' })))

await expect(
pluginsPromise.then((plugins) => plugins.map((plugin) => plugin.name))
).resolves.toEqual(['first-native', 'second-native'])
})

it('runs providers in the fixed native transform slot', async () => {
const calls = new Map<string, string[]>()
const record = (id: string, name: string) => {
const moduleCalls = calls.get(id) ?? []
moduleCalls.push(name)
calls.set(id, moduleCalls)
}
const provider = withNativePlugin({ name: 'shared' }, () => ({
name: 'shared-native',
enforce: 'pre',
transform: {
order: 'pre',
filter: { id: /entry$/ },
handler(code, id) {
record(id, 'provider')
return code
},
},
}))
const [native] = getNativePlugins([provider], {
root: '/project',
platform: 'android',
dev: false,
})
const transform = (name: string) => ({
name,
transform(code: string, id: string) {
record(id, name)
return code
},
})
const bundle = await rolldown({
input: 'entry',
plugins: [
{
name: 'fixture',
resolveId(id, importer) {
if (id === 'entry') return id
if (id === './dependency' && importer === 'entry') return 'dependency'
},
load(id) {
if (id === 'entry') return `import './dependency'; export const value = 1`
if (id === 'dependency') return 'export const dependency = 1'
},
},
transform('compiler'),
native!,
transform('flow'),
],
})

await bundle.generate({ format: 'esm' })
await bundle.close()

expect(calls.get('entry')).toEqual(['compiler', 'provider', 'flow'])
expect(calls.get('dependency')).toEqual(['compiler', 'flow'])
})
})
96 changes: 96 additions & 0 deletions packages/vxrn/src/nativePlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Plugin as RolldownPlugin } from 'rolldown'
import type { Plugin as VitePlugin, PluginOption } from 'vite'

export type NativePluginContext = {
root: string
platform: 'ios' | 'android'
dev: boolean
}

export type NativePluginFactory = (
context: NativePluginContext
) => RolldownPlugin | readonly RolldownPlugin[]

type NativePluginApi = {
vxrnNative?: NativePluginFactory
}

/**
* Adds a native Rolldown implementation to a Vite plugin.
*
* VxRN only forwards plugins with this explicit provider into native builds.
*/
export function withNativePlugin(
plugin: VitePlugin,
factory: NativePluginFactory
): VitePlugin {
const api =
plugin.api && typeof plugin.api === 'object'
? (plugin.api as Record<string, unknown>)
: {}

return {
...plugin,
api: {
...api,
vxrnNative: factory,
},
}
}

export function getNativePlugins(
plugins: readonly VitePlugin[],
context: NativePluginContext
): RolldownPlugin[] {
return plugins.flatMap((plugin) => {
const api = plugin.api as NativePluginApi | undefined
const nativePlugin = api?.vxrnNative?.(context)
const nativePlugins = nativePlugin
? Array.isArray(nativePlugin)
? [...nativePlugin]
: [nativePlugin]
: []

return nativePlugins.map(normalizeNativePlugin)
})
}

export async function getNativePluginsFromOptions(
pluginOptions: readonly PluginOption[],
context: NativePluginContext
): Promise<RolldownPlugin[]> {
const plugins: VitePlugin[] = []

const visit = async (option: PluginOption): Promise<void> => {
const resolved = await option
if (!resolved) return
if (Array.isArray(resolved)) {
for (const nested of resolved) {
await visit(nested)
}
return
}
plugins.push(resolved as VitePlugin)
}

for (const option of pluginOptions) {
await visit(option)
}
return getNativePlugins(plugins, context)
}

function normalizeNativePlugin(plugin: RolldownPlugin): RolldownPlugin {
const transform = plugin.transform
const { enforce: _enforce, ...normalized } = plugin as RolldownPlugin & {
enforce?: unknown
}
const normalizedTransform =
transform && typeof transform === 'object' && 'handler' in transform
? (({ order: _order, ...hook }) => hook)(transform)
: transform

return {
...normalized,
transform: normalizedTransform,
}
}
Loading