Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/pr-1413.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<!-- auto-generated -->
---
'@sanity/cli-core': minor
'@sanity/cli': minor
---

feat(cli): add --experimental-bundle opt-in for Vite bundled dev mode
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ describe('getCliConfig', () => {
expect(mockImportModule).toHaveBeenCalledOnce()
})

test('preserves experimental.bundledDev rather than stripping it on parse', async () => {
const getCliConfig = await freshImport()
setupSingleConfig()
mockImportModule.mockResolvedValue({experimental: {bundledDev: true}})

const config = await getCliConfig(ROOT)

// The schema declares `experimental`; without it zod would drop the key and
// the sanity.cli.ts opt-in would never reach the dev server.
expect(config).toEqual({experimental: {bundledDev: true}})
})

test('throws when no config found', async () => {
const getCliConfig = await freshImport()
mockFindPathForFiles.mockResolvedValue([
Expand Down
6 changes: 6 additions & 0 deletions packages/@sanity/cli-core/src/config/cli/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export const cliConfigSchema = z.object({
}),
),

experimental: z.optional(
z.object({
bundledDev: z.optional(z.boolean()),
}),
),

graphql: z.optional(
z.array(
z.object({
Expand Down
11 changes: 11 additions & 0 deletions packages/@sanity/cli-core/src/config/cli/types/cliConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ export interface CliConfig {
autoUpdates?: boolean
}

/** Experimental, unstable options. May change or be removed without notice. */
experimental?: {
/**
* Enable Vite's experimental bundled dev mode for `sanity dev`. This serves bundled
* files during development, which can speed up startup and reloads for large projects.
* Can also be enabled per-run with `sanity dev --experimental-bundle`. Defaults to false.
* {@link https://vite.dev/guide/rolldown#full-bundle-mode}
*/
bundledDev?: boolean
}

/** Define the GraphQL APIs that the CLI can deploy and interact with */
graphql?: Array<{
filterSuffix?: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function createMockOutput(): Output {
* by the code under test but are required to type-check as `DevFlags`. */
export const DEV_FLAGS = {
'auto-updates': false,
'experimental-bundle': false,
host: 'localhost',
json: false,
'load-in-dashboard': false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import {createMockOutput, DEV_FLAGS as FLAGS} from '../../__tests__/testHelpers.
import {getDevServerConfig} from '../getDevServerConfig.js'

vi.mock('@sanity/cli-core/ux', () => ({
logSymbols: {error: '✖', info: 'ℹ', success: '✔', warning: '⚠'},
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))

// oclif types the flag as `boolean`, but with `default: undefined` an unset flag
// is `undefined` at runtime — this helper lets tests exercise that path.
const withBundleFlag = (value: boolean | undefined) =>
({...FLAGS, 'experimental-bundle': value}) as typeof FLAGS

describe('getDevServerConfig', () => {
afterEach(() => {
vi.unstubAllEnvs()
Expand Down Expand Up @@ -181,4 +187,78 @@ describe('getDevServerConfig', () => {
})
expect(withoutOverride.httpPort).toBe(3333)
})

describe('experimental bundled dev mode', () => {
test('enables bundledDev when the --experimental-bundle flag is passed', () => {
const config = getDevServerConfig({
cliConfig: {},
flags: withBundleFlag(true),
output: createMockOutput(),
workDir: '/tmp',
})

expect(config.bundledDev).toBe(true)
})

test('enables bundledDev from sanity.cli.ts when the flag is unset', () => {
const config = getDevServerConfig({
cliConfig: {experimental: {bundledDev: true}},
flags: withBundleFlag(undefined),
output: createMockOutput(),
workDir: '/tmp',
})

expect(config.bundledDev).toBe(true)
})

test('the --no-experimental-bundle flag overrides a config opt-in', () => {
const config = getDevServerConfig({
cliConfig: {experimental: {bundledDev: true}},
flags: withBundleFlag(false),
output: createMockOutput(),
workDir: '/tmp',
})

expect(config.bundledDev).toBe(false)
})

test('defaults bundledDev to false when neither flag nor config is set', () => {
const config = getDevServerConfig({
cliConfig: {},
flags: withBundleFlag(undefined),
output: createMockOutput(),
workDir: '/tmp',
})

expect(config.bundledDev).toBe(false)
})

test('logs a notice when bundled dev mode is enabled', () => {
const output = createMockOutput()

getDevServerConfig({
cliConfig: {},
flags: withBundleFlag(true),
output,
workDir: '/tmp',
})

expect(output.log).toHaveBeenCalledWith(
expect.stringContaining('experimental Vite bundled dev mode'),
)
})

test('does not log the notice when bundled dev mode is off', () => {
const output = createMockOutput()

getDevServerConfig({
cliConfig: {},
flags: withBundleFlag(undefined),
output,
workDir: '/tmp',
})

expect(output.log).not.toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from 'node:path'

import {type CliConfig, getSanityEnvVar, isWorkbenchApp, type Output} from '@sanity/cli-core'
import {spinner} from '@sanity/cli-core/ux'
import {logSymbols, spinner} from '@sanity/cli-core/ux'

import {type DevServerOptions} from '../../../server/devServer.js'
import {determineIsApp} from '../../../util/determineIsApp.js'
Expand Down Expand Up @@ -48,8 +48,17 @@ export function getDevServerConfig({
)
}

// Opt into Vite's experimental bundled dev mode. The CLI flag wins when set
// (including `--no-experimental-bundle` to force it off); otherwise fall back
// to `experimental.bundledDev` in sanity.cli.ts, defaulting to off.
const bundledDev = flags['experimental-bundle'] ?? cliConfig?.experimental?.bundledDev ?? false
if (bundledDev) {
output.log(`${logSymbols.info} Running dev server with experimental Vite bundled dev mode`)
}

return {
...baseConfig,
bundledDev,
// The app's navigable entry. A branded app that omits `entry` has no app
// view: the runtime/federation skip the `./App` render path entirely.
entry: app?.entry,
Expand Down
8 changes: 8 additions & 0 deletions packages/@sanity/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,21 @@ export class DevCommand extends SanityCommand<typeof DevCommand> {
'<%= config.bin %> <%= command.id %> --host=0.0.0.0',
'<%= config.bin %> <%= command.id %> --port=1942',
'<%= config.bin %> <%= command.id %> --load-in-dashboard',
'<%= config.bin %> <%= command.id %> --experimental-bundle',
]

static override flags = {
'auto-updates': Flags.boolean({
allowNo: true,
description: 'Automatically update Sanity Studio dependencies',
}),
'experimental-bundle': Flags.boolean({
allowNo: true,
// Unset falls through to `experimental.bundledDev` in sanity.cli.ts; the
// explicit `--no-` form can still force it off.
default: undefined,
description: 'Experimental: enable Vite bundled dev mode (vite experimental.bundledDev)',
}),
host: Flags.string({
description: 'Local network interface to listen on (default: localhost)',
}),
Expand Down
113 changes: 113 additions & 0 deletions packages/@sanity/cli/src/server/__tests__/devServer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import path from 'node:path'

import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'

import {type DevServerOptions, startDevServer} from '../devServer.js'

const mockGetViteConfig = vi.hoisted(() => vi.fn())
const mockWriteSanityRuntime = vi.hoisted(() => vi.fn())
const mockExtendViteConfigWithUserConfig = vi.hoisted(() => vi.fn())
const mockCreateServer = vi.hoisted(() => vi.fn())

vi.mock('@sanity/cli-build/_internal/build', () => ({
extendViteConfigWithUserConfig: mockExtendViteConfigWithUserConfig,
getViteConfig: mockGetViteConfig,
writeSanityRuntime: mockWriteSanityRuntime,
}))

vi.mock('@sanity/cli-build/_internal/env', () => ({
getAppEnvironmentVariables: vi.fn(() => ({})),
getStudioEnvironmentVariables: vi.fn(() => ({})),
}))

vi.mock('vite', () => ({
createServer: mockCreateServer,
}))

// The typegen plugin pulls in @sanity/codegen at load; keep the suite hermetic
// since these tests never enable typegen.
vi.mock('../vite/plugin-typegen.js', () => ({
sanityTypegenPlugin: vi.fn(),
}))

function baseOptions(overrides: Partial<DevServerOptions> = {}): DevServerOptions {
return {
basePath: '/',
cwd: '/tmp/project',
httpPort: 3333,
reactCompiler: undefined,
reactStrictMode: undefined,
staticPath: '/tmp/project/static',
...overrides,
}
}

describe('startDevServer', () => {
beforeEach(() => {
mockWriteSanityRuntime.mockResolvedValue({entries: {}, watcher: undefined})
mockGetViteConfig.mockResolvedValue({configFile: false})
mockCreateServer.mockResolvedValue({
close: vi.fn().mockResolvedValue(undefined),
config: {logger: {info: vi.fn()}, server: {port: 3333}},
listen: vi.fn().mockResolvedValue(undefined),
})
})

afterEach(() => {
vi.clearAllMocks()
})

test('enables experimental.bundledDev on the vite config when bundledDev is set', async () => {
await startDevServer(baseOptions({bundledDev: true}))

expect(mockCreateServer).toHaveBeenCalledOnce()
const passedConfig = mockCreateServer.mock.calls[0][0]
expect(passedConfig.experimental).toEqual({bundledDev: true})
})

test('points the bundler at the runtime HTML entry when bundledDev is set', async () => {
await startDevServer(baseOptions({bundledDev: true, cwd: '/tmp/project'}))

const passedConfig = mockCreateServer.mock.calls[0][0]
// Without an explicit input, Vite bundled dev falls back to <root>/index.html
// which does not exist in a Sanity project.
expect(passedConfig.build.rolldownOptions.input).toBe(
path.join('/tmp/project', '.sanity', 'runtime', 'index.html'),
)
})

test('does not touch experimental or build options when bundledDev is off', async () => {
await startDevServer(baseOptions({bundledDev: false}))

const passedConfig = mockCreateServer.mock.calls[0][0]
expect(passedConfig.experimental).toBeUndefined()
expect(passedConfig.build?.rolldownOptions).toBeUndefined()
})

test('preserves other experimental options while enabling bundledDev', async () => {
mockGetViteConfig.mockResolvedValue({
configFile: false,
experimental: {hmrPartialAccept: true},
})

await startDevServer(baseOptions({bundledDev: true}))

const passedConfig = mockCreateServer.mock.calls[0][0]
expect(passedConfig.experimental).toEqual({bundledDev: true, hmrPartialAccept: true})
})

test('applies user vite config on top of the bundledDev-enabled config', async () => {
mockExtendViteConfigWithUserConfig.mockResolvedValue({configFile: false, marker: 'user'})

await startDevServer(baseOptions({bundledDev: true, vite: {}}))

// The user extension receives the config with bundledDev already enabled...
expect(mockExtendViteConfigWithUserConfig).toHaveBeenCalledOnce()
const configPassedToExtend = mockExtendViteConfigWithUserConfig.mock.calls[0][1]
expect(configPassedToExtend.experimental).toEqual({bundledDev: true})

// ...and createServer receives the user-extended result.
const passedConfig = mockCreateServer.mock.calls[0][0]
expect(passedConfig).toEqual({configFile: false, marker: 'user'})
})
})
26 changes: 26 additions & 0 deletions packages/@sanity/cli/src/server/devServer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from 'node:path'

import {
extendViteConfigWithUserConfig,
getViteConfig,
Expand Down Expand Up @@ -29,6 +31,8 @@ export interface DevServerOptions {
staticPath: string

appTitle?: string
/** Enable Vite's experimental bundled dev mode (`experimental.bundledDev`). */
bundledDev?: boolean
entry?: string
httpHost?: string
isApp?: boolean
Expand All @@ -52,6 +56,7 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
const {
appTitle,
basePath,
bundledDev,
cwd,
entry,
httpHost,
Expand Down Expand Up @@ -115,6 +120,27 @@ export async function startDevServer(options: DevServerOptions): Promise<DevServ
views,
})

// Opt into Vite's experimental bundled dev mode. Set before the user-config
// extension below so a `vite` override in sanity.cli.ts still has final say.
//
// Bundled mode bundles the app up front from an HTML entry, defaulting to
// `<root>/index.html`. Sanity has no such file — it serves a virtual document
// rewritten to `.sanity/runtime/index.html` — so point the bundler at the real
// runtime HTML, otherwise the build fails with UNRESOLVED_ENTRY.
if (bundledDev) {
viteConfig = {
...viteConfig,
build: {
...viteConfig.build,
rolldownOptions: {
...viteConfig.build?.rolldownOptions,
input: path.join(cwd, '.sanity', 'runtime', 'index.html'),
},
},
experimental: {...viteConfig.experimental, bundledDev: true},
}
}

// Extend Vite configuration with user-provided config
if (extendViteConfig) {
viteConfig = await extendViteConfigWithUserConfig(
Expand Down
Loading