Skip to content
Merged
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
3 changes: 3 additions & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
extendPluginDiscovery,
getHostRefFields,
getNodePluginId,
getRegistryVersion,
getSelectableKinds,
hasRegistry3DMoveTool,
isDrawnViaTool,
Expand All @@ -34,6 +35,7 @@ export {
kindsWithFloorplanScope,
loadPlugin,
nodeRegistry,
onRegistryChange,
type PluginDiscovery,
registerNode,
resolveFacingIndicator,
Expand Down Expand Up @@ -153,3 +155,4 @@ export type {
ToolHintChip,
Vec2,
} from './types'
export { useRegistryVersion } from './use-registry-version'
38 changes: 38 additions & 0 deletions packages/core/src/registry/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,4 +245,42 @@ describe('loadPlugin', () => {
).rejects.toThrow(/duplicate node kind/)
})
})

// GATE (late-plugin subscriptions): plugins register via async dynamic
// imports AFTER consumers mount. The selection managers rebuild their
// `getSelectableKinds()` emitter subscriptions off this change signal —
// without it, a plugin kind selects but never hovers in prod (the outline
// subscription list froze pre-registration).
test('registerNode bumps the registry version and notifies subscribers', async () => {
const { getRegistryVersion, onRegistryChange } = await import('./registry')
const before = getRegistryVersion()
let notified = 0
const unsubscribe = onRegistryChange(() => {
notified += 1
})

registerNode(makeDefinition('late:kind', { capabilities: { selectable: {} } }))
expect(getRegistryVersion()).toBe(before + 1)
expect(notified).toBe(1)

// A consumer re-deriving on the notification now sees the new kind.
const { getSelectableKinds } = await import('./registry')
expect(getSelectableKinds()).toContain('late:kind')

unsubscribe()
registerNode(makeDefinition('late:kind-2'))
expect(getRegistryVersion()).toBe(before + 2)
expect(notified).toBe(1) // unsubscribed — no further calls
})

test('loadPlugin notifies once per registered kind', async () => {
const { getRegistryVersion } = await import('./registry')
const before = getRegistryVersion()
await loadPlugin({
id: 'pack',
apiVersion: 1,
nodes: [makeDefinition('pack:a'), makeDefinition('pack:b')],
})
expect(getRegistryVersion()).toBe(before + 2)
})
})
39 changes: 39 additions & 0 deletions packages/core/src/registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ const BUILTIN_PLUGIN_ID = 'pascal:core'

const pluginIdsByKind = new Map<string, string>()

// ---------------------------------------------------------------------------
// Registry change notification. Plugin kinds register ASYNCHRONOUSLY (app
// bootstraps discover them via dynamic imports — see `discoverPlugins`), so
// any consumer that snapshots the registry at mount (the selection managers'
// `getSelectableKinds()` subscription lists) goes stale the moment a plugin
// loads after it. `_register` / `_reset` bump a monotonic version and notify
// listeners; `useRegistryVersion()` (registry/use-registry-version.ts) turns
// that into a React re-render so effects can re-derive their kind lists.
// ---------------------------------------------------------------------------

let registryVersion = 0
const registryListeners = new Set<() => void>()

function notifyRegistryChanged(): void {
registryVersion += 1
// Copy before iterating — a listener may unsubscribe (or subscribe) as a
// consequence of the notification.
for (const listener of [...registryListeners]) listener()
}

/** Monotonic counter, bumped on every kind registration (and test reset). */
export function getRegistryVersion(): number {
return registryVersion
}

/**
* Subscribe to registry changes (a kind registered via {@link registerNode}
* / {@link loadPlugin}, or a test reset). Returns the unsubscribe function.
* `useSyncExternalStore`-compatible.
*/
export function onRegistryChange(listener: () => void): () => void {
registryListeners.add(listener)
return () => {
registryListeners.delete(listener)
}
}

// True in dev / test builds, false in production. Tries Vite's
// `import.meta.env.DEV` first (the editor app's bundler) and falls back
// to `process.env.NODE_ENV !== 'production'` for Node test runners.
Expand Down Expand Up @@ -72,12 +109,14 @@ class NodeRegistryImpl implements NodeRegistry {
}
}
this.defs.set(def.kind, def)
notifyRegistryChanged()
}

// Test-only — clears the registry. Not exported from the package barrel.
_reset(): void {
this.defs.clear()
pluginIdsByKind.clear()
notifyRegistryChanged()
}
}

Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/registry/use-registry-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client'

import { useSyncExternalStore } from 'react'
import { getRegistryVersion, onRegistryChange } from './registry'

/**
* React binding for the node registry's change counter. Re-renders the
* consumer whenever a kind registers (plugin kinds arrive asynchronously via
* dynamic-import discovery, AFTER the first mount). Effects that snapshot
* registry-derived lists — `getSelectableKinds()` subscription lists in the
* selection managers — add the returned version to their dependency array so
* a late plugin load rebuilds the subscriptions instead of leaving the
* plugin's kinds without hover / click handlers.
*/
export function useRegistryVersion(): number {
return useSyncExternalStore(onRegistryChange, getRegistryVersion, getRegistryVersion)
}
27 changes: 22 additions & 5 deletions packages/editor/src/components/editor/selection-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type StairSurfaceMaterialRole,
sceneRegistry,
useLiveNodeOverrides,
useRegistryVersion,
useScene,
} from '@pascal-app/core'

Expand Down Expand Up @@ -782,6 +783,12 @@ export const SelectionManager = () => {

const movingNode = useMovingNode()
const isCurveReshape = useIsCurveReshape()
// Plugin kinds register AFTER mount (async dynamic-import discovery), so
// every effect below that snapshots `getSelectableKinds()` into an emitter
// subscription list depends on this version — a late plugin load re-runs
// them and picks up the new kinds (hover / click / double-click / paint /
// pointerdown). Without it, plugin nodes select-but-never-hover in prod.
const registryVersion = useRegistryVersion()

useEffect(() => {
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
Expand All @@ -793,6 +800,8 @@ export const SelectionManager = () => {
}, [mode, setHoverHighlightMode])

useEffect(() => {
// re-subscribe when plugin kinds register after mount (async plugin load)
void registryVersion
if (mode !== 'material-paint') return
if (movingNode || isCurveReshape) return

Expand Down Expand Up @@ -1188,7 +1197,7 @@ export const SelectionManager = () => {
setHoverHighlightMode('default')
useEditor.getState().setPaintHover(null)
}
}, [isCurveReshape, mode, movingNode, setHoverHighlightMode])
}, [isCurveReshape, mode, movingNode, setHoverHighlightMode, registryVersion])

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
Expand Down Expand Up @@ -1224,6 +1233,8 @@ export const SelectionManager = () => {
}, [])

useEffect(() => {
// re-subscribe when plugin kinds register after mount (async plugin load)
void registryVersion
if (mode !== 'select') return
if (movingNode || isCurveReshape) return

Expand Down Expand Up @@ -1363,7 +1374,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:pointerdown` as any, onPointerDown as any)
}
}
}, [isCurveReshape, mode, movingNode, camera, raycaster, glDomElement])
}, [isCurveReshape, mode, movingNode, camera, raycaster, glDomElement, registryVersion])

// Move cursor over the selected movable node: the visual cue that clicking it
// picks it up (replaces the removed move-cross gizmo). Reacts only when the
Expand Down Expand Up @@ -1529,6 +1540,8 @@ export const SelectionManager = () => {
}, [isCurveReshape, mode, movingNode])

useEffect(() => {
// re-subscribe when plugin kinds register after mount (async plugin load)
void registryVersion
if (mode !== 'select') return
if (movingNode || isCurveReshape) return

Expand Down Expand Up @@ -1781,10 +1794,12 @@ export const SelectionManager = () => {
})
emitter.off('grid:click', onGridClick)
}
}, [isCurveReshape, mode, movingNode])
}, [isCurveReshape, mode, movingNode, registryVersion])

// Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => {
// re-subscribe when plugin kinds register after mount (async plugin load)
void registryVersion
if (mode !== 'select') return
if (movingNode || isCurveReshape) return

Expand Down Expand Up @@ -1936,10 +1951,12 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
})
}
}, [isCurveReshape, mode, movingNode])
}, [isCurveReshape, mode, movingNode, registryVersion])

// Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => {
// re-subscribe when plugin kinds register after mount (async plugin load)
void registryVersion
if (mode !== 'delete') return

const onClick = (event: NodeEvent) => {
Expand Down Expand Up @@ -2012,7 +2029,7 @@ export const SelectionManager = () => {
}
useViewer.setState({ hoveredId: null })
}
}, [mode])
}, [mode, registryVersion])

return (
<>
Expand Down
8 changes: 7 additions & 1 deletion packages/viewer/src/components/viewer/selection-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
pointInPolygon,
resolveSelectionProxyId,
sceneRegistry,
useRegistryVersion,
useScene,
type WallNode,
type ZoneNode,
Expand Down Expand Up @@ -306,8 +307,13 @@ const getStrategy = (): SelectionStrategy | null => {
export const SelectionManager = () => {
const selection = useViewer((s) => s.selection)
const clickHandledRef = useRef(false)
// Plugin kinds register AFTER mount (async dynamic-import discovery) —
// re-derive the `getSelectableKinds()` subscription list when they land.
const registryVersion = useRegistryVersion()

useEffect(() => {
// re-subscribe when plugin kinds register after mount (async plugin load)
void registryVersion
const onEnter = (event: NodeEvent) => {
const strategy = getStrategy()
if (!strategy) return
Expand Down Expand Up @@ -396,7 +402,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:click` as any, onClick as any)
}
}
}, [])
}, [registryVersion])

return (
<>
Expand Down
Loading