one
Version:
One is a new React Framework that makes Vite serve both native and web.
287 lines (265 loc) • 10.1 kB
text/typescript
import { Buffer } from 'node:buffer'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { build } from 'vite'
import { createRouteIndex } from '../../utils/routeIndex'
import { createVirtualEntry } from './virtualEntryPlugin'
// mock isNativeEnvironment
vi.mock('vxrn', () => ({
isNativeEnvironment: (env: any) => {
return env?.name === 'ios' || env?.name === 'android'
},
}))
vi.mock('@vxrn/compiler', () => ({
configuration: { enableNativewind: false },
}))
function loadEntry(
plugin: any,
envName: string,
command: 'serve' | 'build' = 'build',
envMode: 'dev' | 'build' = command === 'serve' ? 'dev' : 'build'
) {
// simulate configResolved so isDevMode is set
plugin.configResolved?.({ root: '', command })
const ctx = {
environment: { name: envName, mode: envMode },
}
return plugin.load.handler.call(ctx, '\0virtual:one-entry')
}
describe('virtualEntryPlugin', () => {
it('keeps spa route matching without bundling the client module on the server', async () => {
const root = mkdtempSync(join(tmpdir(), 'one-spa-server-'))
const appDir = join(root, 'app')
mkdirSync(appDir)
const spaRoute = join(appDir, 'client-only+spa.tsx')
writeFileSync(
spaRoute,
`export const browserPayload = 'must-not-enter-server-graph'
export default function ClientOnlyPage() { return null }
`
)
try {
const result = await build({
root,
configFile: false,
logLevel: 'silent',
plugins: [
{
name: 'test-one-entry',
enforce: 'pre',
resolveId(id) {
if (id === 'one') return '\0test-one-entry'
},
load(id) {
if (id === '\0test-one-entry') {
return `export const createApp = (options) => options
export const registerPreloadedRoute = () => {}`
}
},
},
createVirtualEntry({
root: 'app',
flags: {},
routeIndex: createRouteIndex({ routerRoot: appDir }),
}),
],
build: {
ssr: true,
write: false,
rolldownOptions: {
input: 'virtual:one-entry',
},
},
})
if (Array.isArray(result)) throw new Error('expected one server build output')
if (!('output' in result)) throw new Error('expected completed server build')
const moduleIds = result.output.flatMap((output) =>
output.type === 'chunk' ? output.moduleIds : []
)
expect(moduleIds).not.toContain(resolve(spaRoute))
expect(
result.output
.flatMap((output) => (output.type === 'chunk' ? output.code : ''))
.join('\n')
).not.toContain('must-not-enter-server-graph')
const entry = result.output.find(
(output) => output.type === 'chunk' && output.isEntry
)
if (!entry || entry.type !== 'chunk') throw new Error('missing server entry chunk')
const built = await import(
`data:text/javascript;base64,${Buffer.from(entry.code).toString('base64')}`
)
expect(Object.keys(built.default.routes)).toContain('/app/client-only+spa.tsx')
await expect(built.default.routes['/app/client-only+spa.tsx']()).resolves.toEqual(
{}
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
describe('setupFile', () => {
const base = {
root: 'app',
flags: {},
routeIndex: {
getPaths: () => [],
update: () => false,
},
}
it('server (ssr) uses lazy dynamic import, not static import', () => {
const plugin = createVirtualEntry({
...base,
setupFile: {
client: './src/setupClient.ts',
server: './src/setupServer.ts',
},
})
const code = loadEntry(plugin, 'ssr')
// must NOT use static import (executes at build time)
expect(code).not.toContain('import "./src/setupServer.ts"')
// must use lazy function wrapping dynamic import
expect(code).toContain('__oneGetSetupPromise = () => import(')
expect(code).toContain('./src/setupServer.ts')
// must pass it to createApp so it's called at render time
expect(code).toContain('getSetupPromise: __oneGetSetupPromise')
})
it('client uses dynamic import for setupFile', () => {
const plugin = createVirtualEntry({
...base,
setupFile: {
client: './src/setupClient.ts',
server: './src/setupServer.ts',
},
})
const code = loadEntry(plugin, 'client')
// should have lazy dynamic import
expect(code).toContain('__oneGetSetupPromise')
expect(code).toContain('import(')
expect(code).toContain('./src/setupClient.ts')
expect(code).toContain('getSetupPromise: __oneGetSetupPromise')
// should NOT have a static import
expect(code).not.toContain('import "./src/setupClient.ts"')
})
it('native (ios) uses static import for setupFile', () => {
const plugin = createVirtualEntry({
...base,
setupFile: {
client: './src/setupClient.ts',
native: './src/setupNative.ts',
},
})
const code = loadEntry(plugin, 'ios')
expect(code).toContain('import "./src/setupNative.ts"')
// native should NOT use lazy import or pass getSetupPromise
expect(code).not.toContain('__oneGetSetupPromise')
expect(code).not.toContain('getSetupPromise')
})
it('android uses static import like ios', () => {
const plugin = createVirtualEntry({
...base,
setupFile: {
native: './src/setupNative.ts',
},
})
const code = loadEntry(plugin, 'android')
expect(code).toContain('import "./src/setupNative.ts"')
expect(code).not.toContain('__oneGetSetupPromise')
})
it('server (ssr) in dev mode uses static import for dep discovery', () => {
const plugin = createVirtualEntry({
...base,
setupFile: {
client: './src/setupClient.ts',
server: './src/setupServer.ts',
},
})
const code = loadEntry(plugin, 'ssr', 'serve')
// dev mode SSR uses static import so Vite crawls deps before first request
expect(code).toContain('import "./src/setupServer.ts"')
expect(code).not.toContain('__oneGetSetupPromise')
})
it('no setupFile produces no import', () => {
const plugin = createVirtualEntry(base)
const code = loadEntry(plugin, 'ssr')
expect(code).not.toContain('setupServer')
expect(code).not.toContain('__oneGetSetupPromise')
expect(code).not.toContain('getSetupPromise')
})
it('string setupFile applies to all environments', () => {
const plugin = createVirtualEntry({
...base,
setupFile: './src/setup.ts',
})
const ssrCode = loadEntry(plugin, 'ssr')
const clientCode = loadEntry(plugin, 'client')
const iosCode = loadEntry(plugin, 'ios')
// client and ssr both use lazy dynamic import
expect(ssrCode).toContain('./src/setup.ts')
expect(ssrCode).toContain('__oneGetSetupPromise')
expect(ssrCode).not.toContain('import "./src/setup.ts"')
expect(clientCode).toContain('./src/setup.ts')
expect(clientCode).toContain('__oneGetSetupPromise')
expect(clientCode).not.toContain('import "./src/setup.ts"')
// native uses static import
expect(iosCode).toContain('import "./src/setup.ts"')
expect(iosCode).not.toContain('__oneGetSetupPromise')
})
it('passes router linking config to createApp', () => {
const plugin = createVirtualEntry({
...base,
router: {
linking: {
scheme: 'threepunchconvo',
prefixes: ['threepunchconvo://app'],
},
},
})
const code = loadEntry(plugin, 'ios')
expect(code).toContain(
'linking: {"scheme":"threepunchconvo","prefixes":["threepunchconvo://app"]}'
)
})
})
describe('react-refresh preamble', () => {
const base = {
root: 'app',
flags: {},
routeIndex: {
getPaths: () => [],
update: () => false,
},
}
// /@one/dev.js also installs the preamble, but it is a deferred module
// script and the client entry is async, so on a large prerendered document
// the entry wins and every compiler-wrapped route module throws "React
// refresh preamble was not loaded". the entry has to install it itself so
// the result does not depend on which script arrives first.
it('dev client installs the preamble before route modules load', () => {
const code = loadEntry(createVirtualEntry(base), 'client', 'serve')
expect(code).toContain(`from '/@react-refresh'`)
expect(code).toContain('window.$RefreshReg$ = () => {}')
// must be guarded so it is a no-op when /@one/dev.js already won the race
expect(code).toContain(`!window.$RefreshReg$`)
// and it has to sit in the entry body, ahead of the lazily-globbed routes
expect(code.indexOf('__oneInjectRefresh(window)')).toBeLessThan(
code.indexOf('createApp(')
)
})
it('production build emits no reference to the dev-only refresh runtime', () => {
const code = loadEntry(createVirtualEntry(base), 'client', 'build')
// /@react-refresh only exists while serving, so a build that imported it
// would ship a broken specifier
expect(code).not.toContain('/@react-refresh')
expect(code).not.toContain('$RefreshReg$')
})
it('server and native entries never get the preamble', () => {
for (const envName of ['ssr', 'ios', 'android']) {
const code = loadEntry(createVirtualEntry(base), envName, 'serve')
expect(code).not.toContain('/@react-refresh')
expect(code).not.toContain('$RefreshReg$')
}
})
})
})