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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
...(await importOriginal<typeof import('@sanity/cli-core')>()),
getCliConfigUncached: mockGetCliConfigUncached,
}))
vi.mock('@sanity/workbench-cli/dev', () => ({
// Only the registry write is mocked; `deriveInterfaces`/`trackInterfaceSet` are
// pure and exercised for real, as they were before moving into workbench-cli.
vi.mock('@sanity/workbench-cli/dev', async (importOriginal) => ({
...(await importOriginal<typeof import('@sanity/workbench-cli/dev')>()),
registerDevServer: mockRegisterDevServer,
}))
vi.mock('../startDevManifestWatcher.js', () => ({
Expand Down Expand Up @@ -58,7 +61,7 @@
cliConfig: workbenchCliConfig(),
isApp: false,
output: createMockOutput(),
server: mockServer({port: 3334}) as any,

Check warning on line 64 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand All @@ -78,7 +81,7 @@
cliConfig: {app: workbenchApp(), deployment: {appId: 'app-abc'}},
isApp: false,
output: createMockOutput(),
server: mockServer({port: 3334}) as any,

Check warning on line 84 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand All @@ -90,7 +93,7 @@
cliConfig: {api: {projectId: 'x1g7jygt'}, app: workbenchApp()},
isApp: false,
output: createMockOutput(),
server: mockServer({port: 3334}) as any,

Check warning on line 96 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand All @@ -104,7 +107,7 @@
cliConfig: workbenchCliConfig(),
isApp: false,
output: createMockOutput(),
server: mockServer({port: 3334}) as any,

Check warning on line 110 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand All @@ -119,7 +122,7 @@
cliConfig: {app: workbenchApp({id: 'legacy-app'})},
isApp: false,
output,
server: mockServer({port: 3334}) as any,

Check warning on line 125 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand All @@ -131,7 +134,7 @@
cliConfig: workbenchCliConfig(),
isApp: false,
output: createMockOutput(),
server: mockServer({port: 3334}) as any,

Check warning on line 137 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand All @@ -145,7 +148,7 @@
cliConfig: workbenchCliConfig(),
isApp: false,
output: createMockOutput(),
server: mockServer({host: 'mydev.local', port: 3334}) as any,

Check warning on line 151 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand All @@ -159,7 +162,7 @@
cliConfig: workbenchCliConfig(),
isApp: false,
output: createMockOutput(),
server: mockServer({host: true, port: 3334}) as any,

Check warning on line 165 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand All @@ -171,7 +174,7 @@
cliConfig: workbenchCliConfig(),
isApp: true,
output: createMockOutput(),
server: mockServer({port: 3334}) as any,

Check warning on line 177 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand All @@ -183,7 +186,7 @@
cliConfig: workbenchCliConfig(),
isApp: false,
output: createMockOutput(),
server: mockServer({port: 3334}) as any,

Check warning on line 189 in packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
workDir: '/tmp/sanity-project',
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import {watch} from 'node:fs'
import {basename, dirname} from 'node:path'

import {findProjectRoot, type Output} from '@sanity/cli-core'
import {canonicalizeWatchDir} from '@sanity/workbench-cli/dev'
import {canonicalizeWatchDir, type DevServerInterface} from '@sanity/workbench-cli/dev'

import {devDebug} from '../devDebug.js'
import {type DevServerInterface} from './deriveInterfaces.js'

/**
* Debounce window between config file events and the next manifest
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import {type CliConfig, getCliConfigUncached, type Output} from '@sanity/cli-core'
import {registerDevServer} from '@sanity/workbench-cli/dev'
import {deriveInterfaces, registerDevServer, trackInterfaceSet} from '@sanity/workbench-cli/dev'
import {type ViteDevServer} from 'vite'

import {checkForDeprecatedAppId, getAppId} from '../../../util/appId.js'
import {extractCoreAppManifest} from '../../manifest/extractCoreAppManifest.js'
import {deriveInterfaces} from './deriveInterfaces.js'
import {extractStudioManifest} from './extractDevServerManifest.js'
import {trackInterfaceSet} from './interfaceSetId.js'
import {startDevManifestWatcher} from './startDevManifestWatcher.js'

interface DevServerRegistrationOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ vi.mock('@vitejs/plugin-react', () => ({default: vi.fn(() => [])}))
vi.mock('../writeWorkbenchRuntime.js', () => ({
writeWorkbenchRuntime: mockWriteWorkbenchRuntime,
}))
vi.mock('@sanity/workbench-cli/dev', () => ({
// Registry/lock I/O is mocked; the pure `createRegistrySetTracker` runs for
// real so the watcher's reload-vs-reconcile decision is exercised, not stubbed.
vi.mock('@sanity/workbench-cli/dev', async (importOriginal) => ({
...(await importOriginal<typeof import('@sanity/workbench-cli/dev')>()),
acquireWorkbenchLock: mockAcquireWorkbenchLock,
getRegisteredServers: mockGetRegisteredServers,
readWorkbenchLock: mockReadWorkbenchLock,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {SANITY_CACHE_DIR} from '@sanity/cli-build/_internal/build'
import {isWorkbenchApp, resolveLocalPackage} from '@sanity/cli-core'
import {
acquireWorkbenchLock,
createRegistrySetTracker,
type DevServerManifest,
getRegisteredServers,
readWorkbenchLock,
Expand All @@ -13,15 +14,11 @@ import {z} from 'zod/mini'

import {resolveReactStrictMode} from '../../../util/resolveReactStrictMode.js'
import {devDebug} from '../devDebug.js'
import {interfaceSetId} from '../registration/interfaceSetId.js'
import {type DevActionOptions} from '../types.js'
import {writeWorkbenchRuntime} from './writeWorkbenchRuntime.js'

const noop = async () => {}

/** Stable per-app key for the registry-watch interface diff. */
const serverKey = (s: DevServerManifest) => `${s.id ?? ''}@${s.host ?? ''}:${s.port}`

const toApplicationsPayload = (servers: DevServerManifest[]) => ({
applications: servers.map(({host, id, interfaces, manifest, port, projectId, type}) => ({
host,
Expand Down Expand Up @@ -241,15 +238,9 @@ async function createWorkbenchViteServer(
// to re-fetch it. A new/removed app, or a manifest-only edit (title/icon),
// reconciles softly as before. Source-file edits don't change the set, so they
// stay on the HMR path and never trip a reload here.
let knownInterfaces = new Map<string, string>()
const setTracker = createRegistrySetTracker()
const registryWatcher = watchRegistry((servers) => {
const rebuiltApp = servers.some((s) => {
const key = serverKey(s)
return knownInterfaces.has(key) && knownInterfaces.get(key) !== interfaceSetId(s.interfaces)
})
knownInterfaces = new Map(servers.map((s) => [serverKey(s), interfaceSetId(s.interfaces)]))

if (rebuiltApp) {
if (setTracker.anyRebuilt(servers)) {
server.ws.send({type: 'full-reload'})
return
}
Expand Down
9 changes: 8 additions & 1 deletion packages/@sanity/workbench-cli/src/_exports/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@
// instance via a lock + PID-liveness), and the workbench host watches it to
// render local panels/services without a deploy. The CLI owns the orchestration
// (starting servers, extracting manifests); this package owns the registry it
// registers into and watches.
// registers into and watches, plus the interface model derived from a project's
// `unstable_defineApp` config and the set-change detection both sides compare.
export {canonicalizeWatchDir} from '../actions/dev/canonicalizeWatchDir.js'
export {deriveInterfaces, type DevServerInterface} from '../actions/dev/deriveInterfaces.js'
export {
createRegistrySetTracker,
interfaceSetId,
trackInterfaceSet,
} from '../actions/dev/interfaceSetId.js'
export {
acquireWorkbenchLock,
type DevServerManifest,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import {type CliConfig} from '@sanity/cli-core'
import {describe, expect, test} from 'vitest'

import {workbenchApp} from '../../__tests__/testHelpers.js'
import {unstable_defineApp} from '../../../defineApp.js'
import {deriveInterfaces} from '../deriveInterfaces.js'

/** A CliConfig `app` from a branded `unstable_defineApp(...)` — the opt-in `deriveInterfaces` reads. */
function workbenchApp(overrides: Record<string, unknown> = {}): CliConfig['app'] {
return unstable_defineApp({
name: 'test-app',
organizationId: 'org-123',
title: 'Test App',
...overrides,
}) as unknown as CliConfig['app']
}

describe('deriveInterfaces', () => {
test('returns undefined for a non-branded app (no unstable_defineApp)', () => {
expect(deriveInterfaces({title: 'Plain'} as CliConfig['app'], {isApp: true})).toBeUndefined()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {describe, expect, test} from 'vitest'

import {type DevServerInterface} from '../deriveInterfaces.js'
import {interfaceSetId, trackInterfaceSet} from '../interfaceSetId.js'
import {createRegistrySetTracker, interfaceSetId, trackInterfaceSet} from '../interfaceSetId.js'
import {type DevServerManifest} from '../registry.js'

const panel = (name: string, src = `./src/${name}.tsx`): DevServerInterface => ({
entry_point: src,
Expand All @@ -13,6 +14,17 @@ const worker = (name: string, src = `./src/${name}.ts`): DevServerInterface => (
interface_type: 'worker',
name,
})
const server = (id: string, port: number, interfaces: DevServerInterface[]): DevServerManifest => ({
host: 'localhost',
id,
interfaces,
pid: 1,
port,
startedAt: '2026-01-01T00:00:00.000Z',
type: 'coreApp',
version: 1,
workDir: '/tmp/app',
})

describe('interfaceSetId', () => {
test('undefined and empty both id to the empty set', () => {
Expand Down Expand Up @@ -69,3 +81,30 @@ describe('trackInterfaceSet', () => {
expect(trackInterfaceSet(undefined).changed([])).toBe(false)
})
})

describe('createRegistrySetTracker', () => {
test('the first snapshot is never a rebuild — no app is known yet', () => {
const tracker = createRegistrySetTracker()
expect(tracker.anyRebuilt([server('a', 1, [panel('x')])])).toBe(false)
})

test('a known app whose set changed signals a rebuild', () => {
const tracker = createRegistrySetTracker()
tracker.anyRebuilt([server('a', 1, [panel('x')])])
expect(tracker.anyRebuilt([server('a', 1, [panel('x'), panel('y')])])).toBe(true)
})

test('a newly appearing app is not a rebuild — it reconciles softly', () => {
const tracker = createRegistrySetTracker()
tracker.anyRebuilt([server('a', 1, [panel('x')])])
expect(tracker.anyRebuilt([server('a', 1, [panel('x')]), server('b', 2, [panel('z')])])).toBe(
false,
)
})

test('a reorder of a known app is not a rebuild', () => {
const tracker = createRegistrySetTracker()
tracker.anyRebuilt([server('a', 1, [panel('x'), worker('y')])])
expect(tracker.anyRebuilt([server('a', 1, [worker('y'), panel('x')])])).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {type CliConfig, isWorkbenchApp} from '@sanity/cli-core'
import {type DevServerManifest} from '@sanity/workbench-cli/dev'

import {type DevServerManifest} from './registry.js'

/** One forwarded interface record on the dev-server registry entry. */
export type DevServerInterface = NonNullable<DevServerManifest['interfaces']>[number]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {type DevServerInterface} from './deriveInterfaces.js'
import {type DevServerManifest} from './registry.js'

/**
* The identity of an app's declared interface set — an order-independent key
Expand Down Expand Up @@ -44,3 +45,37 @@ export function trackInterfaceSet(initial: readonly DevServerInterface[] | undef
},
}
}

/** Stable per-app key for the registry-watch interface diff. */
const serverKey = (server: DevServerManifest): string =>
`${server.id ?? ''}@${server.host ?? ''}:${server.port}`

/**
* Track every registered app's interface set across registry snapshots, for the
* workbench host's reload decision (the multi-app counterpart to
* {@link trackInterfaceSet}, which tracks one app for the rebuild decision).
*
* `anyRebuilt` reports whether a *known* app's set changed since the previous
* snapshot — the signal its federation remote was rebuilt with new exposes, so
* the page must full-reload to drop the stale remote-entry. A new or removed
* app, or a manifest-only edit (title/icon), isn't a rebuild and reconciles
* softly. Every call advances the snapshot, so the comparison is always against
* the immediately preceding registry state.
*/
export function createRegistrySetTracker(): {
anyRebuilt: (servers: readonly DevServerManifest[]) => boolean
} {
let known = new Map<string, string>()
return {
anyRebuilt(servers) {
const rebuilt = servers.some((server) => {
const key = serverKey(server)
return known.has(key) && known.get(key) !== interfaceSetId(server.interfaces)
})
known = new Map(
servers.map((server) => [serverKey(server), interfaceSetId(server.interfaces)]),
)
return rebuilt
},
}
}
Loading