fix(native): register the plugin's tab types when the registry service arrives
The native seat declares `sidebar.right.pane.tab` BEFORE it provides `sidebarRightTabs`, so driving the registration off the slot declaration read the service as missing and registered nothing — permanently, since a declaration never collapses. Observed on a real DSH profile (0.1.5-alpha.1, `dsh web` on the web profile): the plugin host and its other slot registrations mounted, the native guide page stayed empty, and a probe printed `sidebarRightTabs= undefined` at declaration time with the registry present three seconds later. The scratch mount lane never saw it because there the plugin activates after the native package finished providing. `registerNativeSurface` now waits on the SERVICE (`ctx.inject`), which re-runs whenever the registry appears/reappears, and keeps the per-body slot registrations on `slots.inject` (they only need the declaration). Regression test: `tests/native-surface.spec.ts` models the real order (slot callback fires first, registry arrives later) and fails on the old implementation. Verified on the real web profile: all guide entries (files / editor / git / subagent / sidechat / terminal / browser) appear and the terminal tab renders.
This commit is contained in:
+15
-12
@@ -85,15 +85,18 @@ export interface NativeSurfaceDeps {
|
||||
*/
|
||||
export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
const { ctx, store, service, records } = deps
|
||||
const disposers: Array<() => void> = []
|
||||
// The native seat declares `sidebar.right.pane.tab` when its client half
|
||||
// activates, which may be after this plugin; the slot registry's `inject`
|
||||
// re-runs on every declaration lifetime, and the tab-type registry
|
||||
// (`ctx.sidebarRightTabs`) is provided by that same package, so one wait
|
||||
// covers both.
|
||||
const disposeSeat = ctx.slots.inject('sidebar.right.pane.tab', () => {
|
||||
const tabs = ctx.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined
|
||||
if (tabs === undefined) return () => {}
|
||||
// Wait for the tab-type REGISTRY (a service), not for the slot declaration:
|
||||
// the native seat declares `sidebar.right.pane.tab` BEFORE it provides
|
||||
// `sidebarRightTabs`, so a declaration-triggered registration reads the
|
||||
// service as missing and — because the declaration never collapses —
|
||||
// registers nothing, permanently. Observed on a real profile: the
|
||||
// declaration callback fired with both `sidebarRight` and
|
||||
// `sidebarRightTabs` undefined, and the registry appeared a moment later.
|
||||
// `ctx.inject` re-runs this body whenever the service appears/reappears,
|
||||
// which is the lifecycle the registrations need.
|
||||
const seat = ctx.inject(['sidebarRightTabs'], (injected) => {
|
||||
const tabs = injected.get('sidebarRightTabs') as unknown as NativeTabRegistry | undefined
|
||||
if (tabs === undefined) return
|
||||
const live = new Map<string, Registration>()
|
||||
|
||||
const fileParamsOf = (info: NativeTabInfo): NativeTabParams | undefined => {
|
||||
@@ -213,16 +216,16 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
}
|
||||
}
|
||||
|
||||
disposers.push(service.subscribe(sync), store.subscribe(sync))
|
||||
const disposeSubscriptions = [service.subscribe(sync), store.subscribe(sync)]
|
||||
sync()
|
||||
return () => {
|
||||
for (const registration of live.values()) registration.dispose()
|
||||
live.clear()
|
||||
for (const dispose of disposeSubscriptions.reverse()) dispose()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
disposeSeat()
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
void seat.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createNativeTabRecords } from '../src/client/native/tab-adapter.tsx'
|
||||
import { registerNativeSurface } from '../src/client/native/index.ts'
|
||||
import { createBetterSidebarService, type SidebarSurface } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
@@ -149,3 +150,66 @@ describe('service routing into the native surface', () => {
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerNativeSurface lifecycle (service-driven registration)', () => {
|
||||
it('registers the native tab types when the tab-type registry ARRIVES after the slot declaration', () => {
|
||||
// Regression: the native seat declares `sidebar.right.pane.tab` before it
|
||||
// provides `sidebarRightTabs`, so a registration driven by the slot
|
||||
// declaration reads the service as missing and registers nothing —
|
||||
// observed on a real DSH profile (the guide page stayed empty while the
|
||||
// same build worked in the scratch mount lane, where activation order
|
||||
// happened to differ). The registration must follow the SERVICE.
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'terminal', title: 'Terminal', component: () => null })
|
||||
service.registerTab({ id: 'editor', title: 'Files', component: () => null })
|
||||
const records = createNativeTabRecords()
|
||||
|
||||
const registered: Array<{ id: string; kind: string }> = []
|
||||
const slotKeys: string[] = []
|
||||
// The registry is ABSENT while the slot callback fires and appears later
|
||||
// (that ordering is the regression): a holder keeps the timing honest
|
||||
// without a reassigned binding.
|
||||
const registry: { current: { register: (definition: { id: string; kind: string }) => () => void } | undefined } = { current: undefined }
|
||||
let runInjected: (() => void) | undefined
|
||||
|
||||
const ctx = {
|
||||
inject: (deps: readonly string[], callback: (injected: { get: (name: string) => unknown }) => void) => {
|
||||
expect(deps).toEqual(['sidebarRightTabs'])
|
||||
runInjected = () => { callback({ get: () => registry.current }) }
|
||||
return { dispose: () => { runInjected = undefined } }
|
||||
},
|
||||
get: () => registry.current,
|
||||
slots: {
|
||||
// The slot is already declared when this plugin activates: the
|
||||
// callback runs immediately, with no service in sight.
|
||||
inject: (_key: string, callback: () => () => void) => callback(),
|
||||
register: (options: { name: string; key?: string }) => {
|
||||
slotKeys.push(options.key ?? options.name)
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const dispose = registerNativeSurface({ ctx: ctx as never, store, service, records })
|
||||
|
||||
// Nothing may register while the registry is absent…
|
||||
expect(registered).toHaveLength(0)
|
||||
expect(slotKeys).toHaveLength(0)
|
||||
|
||||
// …and everything registers once it appears.
|
||||
registry.current = {
|
||||
register: (definition) => {
|
||||
registered.push({ id: definition.id, kind: definition.kind })
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
runInjected?.()
|
||||
expect(registered.map(entry => entry.kind).sort()).toEqual(['editor', 'files', 'terminal'])
|
||||
expect(registered.map(entry => entry.id)).toContain('dsh-better-sidebar:files')
|
||||
expect(slotKeys).toContain('dsh-better-sidebar:terminal')
|
||||
expect(slotKeys).toContain('dsh-better-sidebar:files')
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user