diff --git a/packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts b/packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts index d68bfda95..63b833de0 100644 --- a/packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts +++ b/packages/@sanity/cli/src/actions/dev/registration/__tests__/startDevServerRegistration.test.ts @@ -16,7 +16,10 @@ vi.mock('@sanity/cli-core', async (importOriginal) => ({ ...(await importOriginal()), 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()), registerDevServer: mockRegisterDevServer, })) vi.mock('../startDevManifestWatcher.js', () => ({ diff --git a/packages/@sanity/cli/src/actions/dev/registration/deriveInterfaces.ts b/packages/@sanity/cli/src/actions/dev/registration/deriveInterfaces.ts deleted file mode 100644 index 4e493ac77..000000000 --- a/packages/@sanity/cli/src/actions/dev/registration/deriveInterfaces.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {type CliConfig, isWorkbenchApp} from '@sanity/cli-core' -import {type DevServerManifest} from '@sanity/workbench-cli/dev' - -/** One forwarded interface record on the dev-server registry entry. */ -export type DevServerInterface = NonNullable[number] - -/** - * Derive the workbench `interfaces[]` an app forwards to the dev-server - * registry from its `unstable_defineApp` config: `views` → `panel`s, - * `services` → `worker`s, and (SDK apps) `entry` → the navigable `app` view. - * `entry_point` is the declared `src` — the raw value, not a resolved URL. - * - * Returns `undefined` for a non-branded app (no `unstable_defineApp`). A studio - * that declares `entry` reaches the not-yet-implemented studio app-view path and - * is rejected (FR-026). - * - * Shared by the initial registration and the dev config watcher so editing - * `views`/`services`/`entry` in `sanity.cli.ts` re-pushes the same shape live, - * the way `title`/`icon` already re-sync (FR-024). - */ -export function deriveInterfaces( - app: CliConfig['app'], - options: {isApp: boolean}, -): DevServerInterface[] | undefined { - if (!isWorkbenchApp(app)) return undefined - - // sanity-io/workbench spec 002-workbench-extension-api, US5 — studio app views are not implemented yet. A studio (not an SDK app) - // that declares `entry` reaches the app-view path; reject with a clear error - // rather than deriving an `app` interface for it. - if (!options.isApp && app.entry !== undefined) { - throw new Error('App views for studios are not implemented yet') - } - - return [ - ...(app.views?.map((view) => ({ - entry_point: view.src, - interface_type: view.type, - name: view.name, - })) ?? []), - ...(app.services?.map((service) => ({ - entry_point: service.src, - interface_type: service.type, - name: service.name, - })) ?? []), - // sanity-io/workbench spec 002-workbench-extension-api, US5 — with no `entry` the app has no `app` view and isn't reachable as a - // full-page app; with one, forward it so the workbench gates navigability. - ...(app.entry === undefined - ? [] - : [{entry_point: app.entry, interface_type: 'app' as const, name: app.name}]), - ] -} diff --git a/packages/@sanity/cli/src/actions/dev/registration/interfaceSetId.ts b/packages/@sanity/cli/src/actions/dev/registration/interfaceSetId.ts deleted file mode 100644 index 2a313c966..000000000 --- a/packages/@sanity/cli/src/actions/dev/registration/interfaceSetId.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {type DevServerInterface} from './deriveInterfaces.js' - -/** - * The identity of an app's declared interface set — an order-independent key - * over its forwarded Interface records (interface_type, name, entry_point). - * Two sets that differ only in declaration order share an id, so reordering - * `views`/`services` in `sanity.cli.ts` is not a change; adding, removing, - * renaming, or repointing a view/service is. An `undefined` set (project types - * that declare no interfaces, e.g. studios) gets the same id as the empty set. - * - * Both detection sites compare this id against their own last-seen value across - * the dev-server registry seam: the app dev server rebuilds the federation - * remote when its set changes, and the workbench dev server reloads the page. - * Editing a view's/service's *source file* doesn't change the id, so it stays - * on the HMR path. - */ -export function interfaceSetId(interfaces: readonly DevServerInterface[] | undefined): string { - if (!interfaces || interfaces.length === 0) return '' - return interfaces - .map((iface) => [iface.interface_type, iface.name, iface.entry_point].join('::')) - .toSorted() - .join('|') -} - -/** - * Track the declared interface *set* across config reloads — an added, removed, - * renamed, or repointed view/service. `changed` reports whether a set differs - * from the last *committed* one (a reorder or manifest-only/source-file edit - * leaves it unchanged, so HMR handles it) without advancing the committed set; - * `commit` advances it. Splitting the two lets the caller commit only after the - * rebuild that depends on the new set has succeeded — a thrown rebuild leaves - * the set uncommitted, so the next config save retries it instead of skipping. - * Seed it with the initially registered set. - */ -export function trackInterfaceSet(initial: readonly DevServerInterface[] | undefined): { - changed: (interfaces: readonly DevServerInterface[] | undefined) => boolean - commit: (interfaces: readonly DevServerInterface[] | undefined) => void -} { - let lastId = interfaceSetId(initial) - return { - changed: (interfaces) => interfaceSetId(interfaces) !== lastId, - commit: (interfaces) => { - lastId = interfaceSetId(interfaces) - }, - } -} diff --git a/packages/@sanity/cli/src/actions/dev/registration/startDevManifestWatcher.ts b/packages/@sanity/cli/src/actions/dev/registration/startDevManifestWatcher.ts index c5c1255c8..d9c1dc568 100644 --- a/packages/@sanity/cli/src/actions/dev/registration/startDevManifestWatcher.ts +++ b/packages/@sanity/cli/src/actions/dev/registration/startDevManifestWatcher.ts @@ -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 diff --git a/packages/@sanity/cli/src/actions/dev/registration/startDevServerRegistration.ts b/packages/@sanity/cli/src/actions/dev/registration/startDevServerRegistration.ts index 559ac8d93..65e5f20a2 100644 --- a/packages/@sanity/cli/src/actions/dev/registration/startDevServerRegistration.ts +++ b/packages/@sanity/cli/src/actions/dev/registration/startDevServerRegistration.ts @@ -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 { diff --git a/packages/@sanity/cli/src/actions/dev/workbench/__tests__/startWorkbenchDevServer.test.ts b/packages/@sanity/cli/src/actions/dev/workbench/__tests__/startWorkbenchDevServer.test.ts index c5a8f2ec2..13d6f14ea 100644 --- a/packages/@sanity/cli/src/actions/dev/workbench/__tests__/startWorkbenchDevServer.test.ts +++ b/packages/@sanity/cli/src/actions/dev/workbench/__tests__/startWorkbenchDevServer.test.ts @@ -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 `createInterfacesTracker` 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()), acquireWorkbenchLock: mockAcquireWorkbenchLock, getRegisteredServers: mockGetRegisteredServers, readWorkbenchLock: mockReadWorkbenchLock, diff --git a/packages/@sanity/cli/src/actions/dev/workbench/startWorkbenchDevServer.ts b/packages/@sanity/cli/src/actions/dev/workbench/startWorkbenchDevServer.ts index f85c726c2..1edee30ec 100644 --- a/packages/@sanity/cli/src/actions/dev/workbench/startWorkbenchDevServer.ts +++ b/packages/@sanity/cli/src/actions/dev/workbench/startWorkbenchDevServer.ts @@ -2,6 +2,7 @@ import {SANITY_CACHE_DIR} from '@sanity/cli-build/_internal/build' import {isWorkbenchApp, resolveLocalPackage} from '@sanity/cli-core' import { acquireWorkbenchLock, + createInterfacesTracker, type DevServerManifest, getRegisteredServers, readWorkbenchLock, @@ -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, @@ -234,22 +231,12 @@ async function createWorkbenchViteServer( ) }) - // A running app's declared interface set changing (a view/service added or - // removed in `sanity.cli.ts`) means its remote was rebuilt with new exposes. - // Module federation has the old remote-entry cached, so an in-place reconcile - // would load a stale remote (empty panel / no worker) — the page must reload - // 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() + // A changed interface set means the remote was rebuilt with new exposes — + // full-reload so the page drops the stale remote-entry; otherwise rebroadcast + // the app list for a soft reconcile. + const setTracker = createInterfacesTracker() 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.hasChanged(servers)) { server.ws.send({type: 'full-reload'}) return } diff --git a/packages/@sanity/workbench-cli/src/_exports/dev.ts b/packages/@sanity/workbench-cli/src/_exports/dev.ts index 873fa8fd3..da02a54c1 100644 --- a/packages/@sanity/workbench-cli/src/_exports/dev.ts +++ b/packages/@sanity/workbench-cli/src/_exports/dev.ts @@ -1,10 +1,13 @@ -// Node-only dev entry: the workbench dev-server registry the CLI's dev -// orchestration drives. It tracks running studio/app dev servers (single -// 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. +// Node-only dev entry: the dev-server registry the CLI drives and the workbench +// host watches (singleton lock + PID-liveness), plus the interface model derived +// from `unstable_defineApp`. export {canonicalizeWatchDir} from '../actions/dev/canonicalizeWatchDir.js' +export {deriveInterfaces, type DevServerInterface} from '../actions/dev/deriveInterfaces.js' +export { + createInterfacesTracker, + interfaceSetId, + trackInterfaceSet, +} from '../actions/dev/interfaceSetId.js' export { acquireWorkbenchLock, type DevServerManifest, diff --git a/packages/@sanity/cli/src/actions/dev/registration/__tests__/deriveInterfaces.test.ts b/packages/@sanity/workbench-cli/src/actions/dev/__tests__/deriveInterfaces.test.ts similarity index 97% rename from packages/@sanity/cli/src/actions/dev/registration/__tests__/deriveInterfaces.test.ts rename to packages/@sanity/workbench-cli/src/actions/dev/__tests__/deriveInterfaces.test.ts index 0b22461f4..23e2ee60c 100644 --- a/packages/@sanity/cli/src/actions/dev/registration/__tests__/deriveInterfaces.test.ts +++ b/packages/@sanity/workbench-cli/src/actions/dev/__tests__/deriveInterfaces.test.ts @@ -1,8 +1,8 @@ import {type CliConfig} from '@sanity/cli-core' import {describe, expect, test} from 'vitest' -import {workbenchApp} from '../../__tests__/testHelpers.js' import {deriveInterfaces} from '../deriveInterfaces.js' +import {workbenchApp} from './devTestHelpers.js' describe('deriveInterfaces', () => { test('returns undefined for a non-branded app (no unstable_defineApp)', () => { diff --git a/packages/@sanity/workbench-cli/src/actions/dev/__tests__/devTestHelpers.ts b/packages/@sanity/workbench-cli/src/actions/dev/__tests__/devTestHelpers.ts new file mode 100644 index 000000000..a5fdc2219 --- /dev/null +++ b/packages/@sanity/workbench-cli/src/actions/dev/__tests__/devTestHelpers.ts @@ -0,0 +1,13 @@ +import {type CliConfig} from '@sanity/cli-core' + +import {unstable_defineApp} from '../../../defineApp.js' + +/** A CliConfig `app` from a branded `unstable_defineApp(...)` — the workbench opt-in. */ +export function workbenchApp(overrides: Record = {}): CliConfig['app'] { + return unstable_defineApp({ + name: 'test-app', + organizationId: 'org-123', + title: 'Test App', + ...overrides, + }) as unknown as CliConfig['app'] +} diff --git a/packages/@sanity/cli/src/actions/dev/registration/__tests__/interfaceSetId.test.ts b/packages/@sanity/workbench-cli/src/actions/dev/__tests__/interfaceSetId.test.ts similarity index 66% rename from packages/@sanity/cli/src/actions/dev/registration/__tests__/interfaceSetId.test.ts rename to packages/@sanity/workbench-cli/src/actions/dev/__tests__/interfaceSetId.test.ts index 513c257b4..e310dfac7 100644 --- a/packages/@sanity/cli/src/actions/dev/registration/__tests__/interfaceSetId.test.ts +++ b/packages/@sanity/workbench-cli/src/actions/dev/__tests__/interfaceSetId.test.ts @@ -1,7 +1,8 @@ import {describe, expect, test} from 'vitest' import {type DevServerInterface} from '../deriveInterfaces.js' -import {interfaceSetId, trackInterfaceSet} from '../interfaceSetId.js' +import {createInterfacesTracker, interfaceSetId, trackInterfaceSet} from '../interfaceSetId.js' +import {type DevServerManifest} from '../registry.js' const panel = (name: string, src = `./src/${name}.tsx`): DevServerInterface => ({ entry_point: src, @@ -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', () => { @@ -69,3 +81,30 @@ describe('trackInterfaceSet', () => { expect(trackInterfaceSet(undefined).changed([])).toBe(false) }) }) + +describe('createInterfacesTracker', () => { + test('the first snapshot is never a rebuild — no app is known yet', () => { + const tracker = createInterfacesTracker() + expect(tracker.hasChanged([server('a', 1, [panel('x')])])).toBe(false) + }) + + test('a known app whose set changed signals a rebuild', () => { + const tracker = createInterfacesTracker() + tracker.hasChanged([server('a', 1, [panel('x')])]) + expect(tracker.hasChanged([server('a', 1, [panel('x'), panel('y')])])).toBe(true) + }) + + test('a newly appearing app is not a rebuild — it reconciles softly', () => { + const tracker = createInterfacesTracker() + tracker.hasChanged([server('a', 1, [panel('x')])]) + expect(tracker.hasChanged([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 = createInterfacesTracker() + tracker.hasChanged([server('a', 1, [panel('x'), worker('y')])]) + expect(tracker.hasChanged([server('a', 1, [worker('y'), panel('x')])])).toBe(false) + }) +}) diff --git a/packages/@sanity/workbench-cli/src/actions/dev/deriveInterfaces.ts b/packages/@sanity/workbench-cli/src/actions/dev/deriveInterfaces.ts new file mode 100644 index 000000000..6c8638421 --- /dev/null +++ b/packages/@sanity/workbench-cli/src/actions/dev/deriveInterfaces.ts @@ -0,0 +1,40 @@ +import {type CliConfig, isWorkbenchApp} from '@sanity/cli-core' + +import {type DevServerManifest} from './registry.js' + +/** One forwarded interface record on the dev-server registry entry. */ +export type DevServerInterface = NonNullable[number] + +/** + * Map an app's `unstable_defineApp` config to the interface records forwarded on + * its registry entry: `views` → panels, `services` → workers, `entry` → the + * navigable `app` view (`entry_point` is the raw `src`, not a resolved URL). + * `undefined` for a non-branded app; a studio that declares `entry` is rejected + * (FR-026, studio app views are not implemented yet). + */ +export function deriveInterfaces( + app: CliConfig['app'], + options: {isApp: boolean}, +): DevServerInterface[] | undefined { + if (!isWorkbenchApp(app)) return undefined + + if (!options.isApp && app.entry !== undefined) { + throw new Error('App views for studios are not implemented yet') + } + + return [ + ...(app.views?.map((view) => ({ + entry_point: view.src, + interface_type: view.type, + name: view.name, + })) ?? []), + ...(app.services?.map((service) => ({ + entry_point: service.src, + interface_type: service.type, + name: service.name, + })) ?? []), + ...(app.entry === undefined + ? [] + : [{entry_point: app.entry, interface_type: 'app' as const, name: app.name}]), + ] +} diff --git a/packages/@sanity/workbench-cli/src/actions/dev/interfaceSetId.ts b/packages/@sanity/workbench-cli/src/actions/dev/interfaceSetId.ts new file mode 100644 index 000000000..9392ead78 --- /dev/null +++ b/packages/@sanity/workbench-cli/src/actions/dev/interfaceSetId.ts @@ -0,0 +1,62 @@ +import {type DevServerInterface} from './deriveInterfaces.js' +import {type DevServerManifest} from './registry.js' + +/** + * Order-independent identity of an app's interface set. Reordering + * `views`/`services` keeps the same id (not a change); adding, removing, + * renaming, or repointing one changes it. `undefined` ids to the empty set. + */ +export function interfaceSetId(interfaces: readonly DevServerInterface[] | undefined): string { + if (!interfaces || interfaces.length === 0) return '' + return interfaces + .map((iface) => [iface.interface_type, iface.name, iface.entry_point].join('::')) + .toSorted() + .join('|') +} + +/** + * Tracks one app's interface set across config reloads. `changed` and `commit` + * are split so the caller commits only after the rebuild that depends on the new + * set succeeds — a thrown rebuild leaves it uncommitted, so the next save retries + * instead of skipping. Seed with the initially registered set. + */ +export function trackInterfaceSet(initial: readonly DevServerInterface[] | undefined): { + changed: (interfaces: readonly DevServerInterface[] | undefined) => boolean + commit: (interfaces: readonly DevServerInterface[] | undefined) => void +} { + let lastId = interfaceSetId(initial) + return { + changed: (interfaces) => interfaceSetId(interfaces) !== lastId, + commit: (interfaces) => { + lastId = interfaceSetId(interfaces) + }, + } +} + +const serverKey = (server: DevServerManifest): string => + `${server.id ?? ''}@${server.host ?? ''}:${server.port}` + +/** + * Tracks every registered app's interface set across registry snapshots (the + * multi-app counterpart to {@link trackInterfaceSet}). `hasChanged` is true when + * a *known* app's set changed since the last call — its remote was rebuilt with + * new exposes, so the workbench must full-reload to drop the stale remote-entry. + * A new/removed app or manifest-only edit isn't a rebuild. + */ +export function createInterfacesTracker(): { + hasChanged: (servers: readonly DevServerManifest[]) => boolean +} { + let known = new Map() + return { + hasChanged(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 + }, + } +}