diff --git a/apps/editor/app/client-bootstrap.tsx b/apps/editor/app/client-bootstrap.tsx new file mode 100644 index 000000000..cc0bdda3b --- /dev/null +++ b/apps/editor/app/client-bootstrap.tsx @@ -0,0 +1,16 @@ +'use client' + +// Loads `@pascal-app/nodes`' built-in plugin into the node registry on the +// client. Mounted from `layout.tsx` so every page in the standalone +// editor gets the registry populated before its first `` / +// `` mounts — without this the registry is empty on the client +// (the server registers in its own module instance, which is unreachable +// from hydrated pages) and every `NodeRenderer` resolves to `null`. The +// `loaded` guard inside `../lib/bootstrap` keeps the side effect +// idempotent under HMR. +import '../lib/bootstrap' +import type { ReactNode } from 'react' + +export function ClientBootstrap({ children }: { children: ReactNode }) { + return children +} diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 5257d59bf..175ba1564 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -3,6 +3,7 @@ import { GeistPixelSquare } from 'geist/font/pixel' import { Barlow } from 'next/font/google' import localFont from 'next/font/local' import Script from 'next/script' +import { ClientBootstrap } from './client-bootstrap' import './globals.css' const geistSans = localFont({ @@ -41,7 +42,7 @@ export default function RootLayout({ )} - {children} + {children} {process.env.NODE_ENV === 'development' && } diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 22b655ade..1d8dd224f 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -1,6 +1,8 @@ 'use client' -import '../lib/bootstrap' +// Node registry bootstrap is loaded once at the root via +// `` in `app/layout.tsx` — no per-page side-effect +// import here. import { applySceneGraphToEditor, Editor, diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 93d8336f7..5e9b71417 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -1,10 +1,17 @@ -import { discoverPlugins, loadPlugin, nodeRegistry } from '@pascal-app/core' +import { + type AnyNodeDefinition, + discoverPlugins, + loadPlugin, + nodeRegistry, + registerNode, +} from '@pascal-app/core' import { builtinPlugin } from '@pascal-app/nodes' -// Idempotency guard: HMR can reload this module, but `registerNode` throws on -// duplicate kinds. The flag lives in the module closure so it's reset on a -// hard reload but survives within a session. -let loaded = false +// Idempotency guards: HMR can reload this module, but `registerNode` +// throws on duplicate kinds. Flags live in the module closure so they +// reset on a hard reload but survive within a session. +let builtinsLoaded = false +let externalsKickedOff = false function isDev(): boolean { const env = (globalThis as { process?: { env?: Record } }).process @@ -12,32 +19,36 @@ function isDev(): boolean { return env?.NODE_ENV !== 'production' } -export async function loadBuiltinNodes(): Promise { - if (loaded) return - loaded = true - await loadPlugin(builtinPlugin) - - // Phase 6 plugin discovery hook. Always called; default impl returns - // `[]`. Apps that ship external node packs override the discovery via - // `setPluginDiscovery(...)` before this module loads. See - // `wiki/editor-plugin-authoring.md` for the contract. - const externals = await discoverPlugins() - for (const plugin of externals) { - await loadPlugin(plugin) +/** + * Synchronously register every built-in node kind. Runs as a side + * effect at module import time so the registry is populated *before* + * any downstream React tree renders — the previous async kick-off + * (`void loadBuiltinNodes()`) only registered in a microtask, letting + * the first SSR / hydration pass see an empty registry. The mismatch + * surfaced as a hydration error at the `` element and every + * `NodeRenderer` resolving to `null` until later renders. + * + * `discoverPlugins()` (which may hit the network for external packs) + * stays async and runs separately via `loadExternalPlugins()`. + */ +function loadBuiltinsSync(): void { + if (builtinsLoaded) return + builtinsLoaded = true + for (const def of builtinPlugin.nodes ?? []) { + registerNode(def as AnyNodeDefinition) } if (isDev()) { const kinds = Array.from(nodeRegistry.entries(), ([k]) => k) if (typeof console !== 'undefined') { - // Visible in the browser dev console — the verification anchor for - // "which path is running this kind?" Empty array = every kind is on - // the legacy path. Kind in the array = registry path is live for it. + // biome-ignore lint/suspicious/noConsole: dev-only verification log console.info( - `[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})${externals.length > 0 ? ` + ${externals.length} discovered plugin(s)` : ''}`, + `[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})`, ) } - // Expose the registry on window for ad-hoc dev inspection. In prod the - // registry is reachable through @pascal-app/core's exports only. + // Expose the registry on globalThis for ad-hoc dev inspection. In + // prod the registry is reachable through @pascal-app/core's + // exports only. if (typeof globalThis !== 'undefined') { ;(globalThis as { __pascalNodeRegistry?: typeof nodeRegistry }).__pascalNodeRegistry = nodeRegistry @@ -45,6 +56,24 @@ export async function loadBuiltinNodes(): Promise { } } -// Run as a side effect on first import so any consumer of this module gets a -// populated registry without remembering to call the function explicitly. -void loadBuiltinNodes() +/** + * Phase 6 plugin discovery hook — runs once, asynchronously, after the + * synchronous builtins are already registered. Apps that ship external + * node packs override the discovery via `setPluginDiscovery(...)` + * before this module loads. See `wiki/architecture/plugin-authoring.md`. + */ +export async function loadExternalPlugins(): Promise { + if (externalsKickedOff) return + externalsKickedOff = true + const externals = await discoverPlugins() + for (const plugin of externals) { + await loadPlugin(plugin) + } + if (isDev() && externals.length > 0 && typeof console !== 'undefined') { + // biome-ignore lint/suspicious/noConsole: dev-only verification log + console.info(`[pascal:registry] + ${externals.length} discovered plugin(s)`) + } +} + +loadBuiltinsSync() +void loadExternalPlugins() diff --git a/bun.lock b/bun.lock index bd4fb1baa..4023ab6db 100644 --- a/bun.lock +++ b/bun.lock @@ -188,6 +188,7 @@ "version": "0.1.0", "devDependencies": { "@pascal-app/core": "^0.8.0", + "@pascal-app/editor": "^0.8.0", "@pascal-app/viewer": "^0.8.0", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", @@ -198,11 +199,14 @@ }, "peerDependencies": { "@pascal-app/core": "^0.8.0", + "@pascal-app/editor": "^0.8.0", "@pascal-app/viewer": "^0.8.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", + "lucide-react": "^1", "react": "^18 || ^19", "three": "^0.184", + "zustand": "^5", }, }, "packages/typescript-config": {