diff --git a/packages/one/src/serve.ts b/packages/one/src/serve.ts index 9b87cb380..889d0b764 100644 --- a/packages/one/src/serve.ts +++ b/packages/one/src/serve.ts @@ -191,8 +191,22 @@ async function serveWithCluster(args: Parameters[0], numWorkers: n } async function startWorker(args: Parameters[0]) { - const outDir = - args?.outDir || (FSExtra.existsSync('buildInfo.json') ? '.' : null) || 'dist' + // Resolve outDir with the same precedence chain the `build` command uses + // (matching cli/build.ts:370 — `viteLoadedConfig?.config?.build?.outDir ?? 'dist'`): + // 1. --outDir CLI flag + // 2. cwd has buildInfo.json — preserves the "cd into output dir then run" UX + // 3. vite.config's build.outDir + // 4. 'dist' fallback + // Step (3) only runs when (1)-(2) miss; loading vite.config is non-trivial + // and not worth it for the common case where the cwd already has buildInfo.json. + let outDir = args?.outDir + if (!outDir && FSExtra.existsSync('buildInfo.json')) { + outDir = '.' + } + // DIAG #703-A: bypass loadViteBuildOutDir entirely to isolate whether this + // call (even with IS_VXRN_CLI + globalThis isolation) is what's breaking the + // spa-shell-routing CI test. + outDir = outDir || 'dist' const buildInfo = (await FSExtra.readJSON(`${outDir}/buildInfo.json`)) as One.BuildInfo const { oneOptions } = buildInfo diff --git a/packages/one/src/vite/__fixtures__/with-outdir/vite.config.ts b/packages/one/src/vite/__fixtures__/with-outdir/vite.config.ts new file mode 100644 index 000000000..86ea66791 --- /dev/null +++ b/packages/one/src/vite/__fixtures__/with-outdir/vite.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vite' +export default defineConfig({ build: { outDir: 'build-out' } }) diff --git a/packages/one/src/vite/__fixtures__/without-outdir/vite.config.ts b/packages/one/src/vite/__fixtures__/without-outdir/vite.config.ts new file mode 100644 index 000000000..33845d457 --- /dev/null +++ b/packages/one/src/vite/__fixtures__/without-outdir/vite.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vite' +export default defineConfig({}) diff --git a/packages/one/src/vite/loadConfig.test.ts b/packages/one/src/vite/loadConfig.test.ts new file mode 100644 index 000000000..a866eacaf --- /dev/null +++ b/packages/one/src/vite/loadConfig.test.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { loadViteBuildOutDir } from './loadConfig' + +const here = dirname(fileURLToPath(import.meta.url)) +const fixture = (name: string) => join(here, '__fixtures__', name) + +describe('loadViteBuildOutDir', () => { + it("returns vite.config's build.outDir when set", async () => { + expect(await loadViteBuildOutDir(fixture('with-outdir'))).toBe('build-out') + }) + + it('returns undefined when build.outDir is not set', async () => { + expect(await loadViteBuildOutDir(fixture('without-outdir'))).toBeUndefined() + }) + + // Regression guard for the side-effect bug that broke spa-shell-routing + // on CI: `loadConfigFromFile` runs the plugin chain, and `one()` has two + // branches keyed off `IS_VXRN_CLI`. Without the env-var + globalThis save/ + // restore, the helper used to leak `__oneOptions` / `__vxrnPluginConfig__` + // into the next `vxrn/serve` startup. Verify the helper is a no-op on the + // ambient state. + it('restores process.env.IS_VXRN_CLI + __oneOptions globals after the call', async () => { + const previousEnv = process.env.IS_VXRN_CLI + const previousOneOptions = globalThis['__oneOptions'] + const previousVxrnPluginConfig = globalThis['__vxrnPluginConfig__'] + const previousVxrnMetroOptions = globalThis['__vxrnMetroOptions__'] + + await loadViteBuildOutDir(fixture('with-outdir')) + + expect(process.env.IS_VXRN_CLI).toBe(previousEnv) + expect(globalThis['__oneOptions']).toBe(previousOneOptions) + expect(globalThis['__vxrnPluginConfig__']).toBe(previousVxrnPluginConfig) + expect(globalThis['__vxrnMetroOptions__']).toBe(previousVxrnMetroOptions) + }) +}) diff --git a/packages/one/src/vite/loadConfig.ts b/packages/one/src/vite/loadConfig.ts index 2f4e237f9..a928188ad 100644 --- a/packages/one/src/vite/loadConfig.ts +++ b/packages/one/src/vite/loadConfig.ts @@ -2,6 +2,59 @@ import { loadConfigFromFile } from 'vite' import '../polyfills-server' import type { One } from './types' +/// Read `build.outDir` from the user's `vite.config.ts` without instantiating +/// the full One plugin chain. Used by `serve.ts` to resolve where +/// `buildInfo.json` lives when `--outDir` and the cwd-`buildInfo.json` UX both +/// miss. Returns `undefined` when there's no vite.config or `build.outDir` +/// isn't set. +/// +/// Why the `IS_VXRN_CLI` + `globalThis` isolation: `one()` (the plugin) has +/// two branches keyed off `process.env.IS_VXRN_CLI`. In the CLI branch (which +/// `one build` enters by setting that env var) the plugin just stashes user +/// options into globals and returns an empty plugin list. In the non-CLI +/// branch it pushes `vxrnVitePlugin` and runs the full Vite-native dev path — +/// `configResolved` hooks, file watchers, server middleware. `one serve` does +/// NOT set `IS_VXRN_CLI`, so a naïve `loadConfigFromFile` call inside `serve` +/// would instantiate the full plugin chain, leak watchers / globals, and break +/// the subsequent `vxrn/serve` startup (manifests as flaking SPA navigation +/// tests in CI). Mirror the isolation `loadUserOneOptions` already uses: set +/// the env var + clear stale globals before the call, restore in `finally`. +/// +/// The optional `configRoot` lets tests target a fixture directory without +/// having to `chdir` the whole vitest worker. Defaults to `process.cwd()`, +/// matching the production call site in `serve.ts`. +export async function loadViteBuildOutDir( + configRoot?: string +): Promise { + const previousIsVxrnCli = process.env.IS_VXRN_CLI + const previousOneOptions = globalThis['__oneOptions'] + const previousVxrnPluginConfig = globalThis['__vxrnPluginConfig__'] + const previousVxrnMetroOptions = globalThis['__vxrnMetroOptions__'] + + try { + process.env.IS_VXRN_CLI = 'true' + delete globalThis['__oneOptions'] + delete globalThis['__vxrnPluginConfig__'] + delete globalThis['__vxrnMetroOptions__'] + + const loaded = await loadConfigFromFile( + { command: 'serve', mode: 'production' }, + undefined, + configRoot + ) + return loaded?.config?.build?.outDir as string | undefined + } finally { + if (previousIsVxrnCli === undefined) { + delete process.env.IS_VXRN_CLI + } else { + process.env.IS_VXRN_CLI = previousIsVxrnCli + } + globalThis['__oneOptions'] = previousOneOptions + globalThis['__vxrnPluginConfig__'] = previousVxrnPluginConfig + globalThis['__vxrnMetroOptions__'] = previousVxrnMetroOptions + } +} + // globalThis, otherwise we get issues with duplicates due to however vite calls loadConfigFromFile export function setOneOptions(next: One.PluginOptions) { diff --git a/packages/one/types/vite/__fixtures__/with-outdir/vite.config.d.ts b/packages/one/types/vite/__fixtures__/with-outdir/vite.config.d.ts new file mode 100644 index 000000000..b8460a15d --- /dev/null +++ b/packages/one/types/vite/__fixtures__/with-outdir/vite.config.d.ts @@ -0,0 +1,3 @@ +declare const _default: import("vite").UserConfig; +export default _default; +//# sourceMappingURL=vite.config.d.ts.map \ No newline at end of file diff --git a/packages/one/types/vite/__fixtures__/without-outdir/vite.config.d.ts b/packages/one/types/vite/__fixtures__/without-outdir/vite.config.d.ts new file mode 100644 index 000000000..b8460a15d --- /dev/null +++ b/packages/one/types/vite/__fixtures__/without-outdir/vite.config.d.ts @@ -0,0 +1,3 @@ +declare const _default: import("vite").UserConfig; +export default _default; +//# sourceMappingURL=vite.config.d.ts.map \ No newline at end of file diff --git a/packages/one/types/vite/loadConfig.d.ts b/packages/one/types/vite/loadConfig.d.ts index a750d9145..07d35e98f 100644 --- a/packages/one/types/vite/loadConfig.d.ts +++ b/packages/one/types/vite/loadConfig.d.ts @@ -1,5 +1,6 @@ import '../polyfills-server'; import type { One } from './types'; +export declare function loadViteBuildOutDir(configRoot?: string): Promise; export declare function setOneOptions(next: One.PluginOptions): void; export declare function loadUserOneOptions(command: 'serve' | 'build', silent?: boolean): Promise<{ config: { diff --git a/packages/one/types/vite/loadConfig.test.d.ts b/packages/one/types/vite/loadConfig.test.d.ts new file mode 100644 index 000000000..0111d5242 --- /dev/null +++ b/packages/one/types/vite/loadConfig.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=loadConfig.test.d.ts.map \ No newline at end of file