Registry-first dispatch in NodeRenderer (shim 2/4)

NodeRenderer now checks the registry first. Registered kinds load
their renderer module via React.lazy (cached by RendererSource so the
Suspense boundary is stable across re-renders). Unregistered kinds
fall through to the legacy chain below.

Today the registry is empty (Phase 0 builtinPlugin.nodes is []), so
every node still hits the legacy chain — no behavior change. The
moment a kind registers in @pascal-app/nodes (Phase 2 onward), the
registry path takes over without further edits here.

GLB / instanced-GLB RendererSource kinds are typed but not yet
honored — they get their built-in renderers in Phase 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-14 11:32:05 -04:00
co-authored by Claude Opus 4.7
parent 4c3feea052
commit 0fd7af216c
@@ -1,6 +1,7 @@
'use client'
import { useScene, type AnyNode } from '@pascal-app/core'
import { type AnyNode, nodeRegistry, type RendererSource, useScene } from '@pascal-app/core'
import { type ComponentType, lazy, Suspense } from 'react'
import { BuildingRenderer } from './building/building-renderer'
import { CeilingRenderer } from './ceiling/ceiling-renderer'
import { ColumnRenderer } from './column/column-renderer'
@@ -10,23 +11,60 @@ import { FenceRenderer } from './fence/fence-renderer'
import { GuideRenderer } from './guide/guide-renderer'
import { ItemRenderer } from './item/item-renderer'
import { LevelRenderer } from './level/level-renderer'
import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
import { RoofRenderer } from './roof/roof-renderer'
import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
import { ScanRenderer } from './scan/scan-renderer'
import { SiteRenderer } from './site/site-renderer'
import { SlabRenderer } from './slab/slab-renderer'
import { SpawnRenderer } from './spawn/spawn-renderer'
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
import { StairRenderer } from './stair/stair-renderer'
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
import { WallRenderer } from './wall/wall-renderer'
import { WindowRenderer } from './window/window-renderer'
import { ZoneRenderer } from './zone/zone-renderer'
// Cache lazy components by their RendererSource so React.lazy isn't re-invoked
// on every render — that would create a new Suspense boundary each time.
const lazyCache = new WeakMap<RendererSource<AnyNode>, ComponentType<{ node: AnyNode }>>()
function getRegistryRenderer(
source: RendererSource<AnyNode>,
): ComponentType<{ node: AnyNode }> | null {
const cached = lazyCache.get(source)
if (cached) return cached
// GLB / instanced-GLB sources lower onto built-in renderers landed in
// Phase 5 — for now only parametric (lazy module) sources are honored.
if (source.kind !== 'parametric') return null
const Comp = lazy(source.module) as unknown as ComponentType<{ node: AnyNode }>
lazyCache.set(source, Comp)
return Comp
}
function RegistryRenderer({ node }: { node: AnyNode }) {
const def = nodeRegistry.get(node.type)
if (!def) return null
const Renderer = getRegistryRenderer(def.renderer as RendererSource<AnyNode>)
if (!Renderer) return null
return (
<Suspense fallback={null}>
<Renderer node={node} />
</Suspense>
)
}
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
const node = useScene((state) => state.nodes[nodeId])
if (!node) return null
// Registry-first: if a NodeDefinition is registered for this kind (via
// @pascal-app/nodes or a future plugin), it owns the render. Falls through
// to the legacy chain below for kinds not yet migrated. Legacy chain is
// removed in Phase 6 once every kind is registry-backed.
if (nodeRegistry.has(node.type)) {
return <RegistryRenderer node={node} />
}
return (
<>
{node.type === 'site' && <SiteRenderer node={node} />}