apps/editor: mount node-registry bootstrap at the root layout

The standalone editor app loaded `lib/bootstrap.ts` as a side-effect
import only from `components/scene-loader.tsx`. That worked for the
`/edit/[sceneId]` route but left every other page (homepage, settings,
viewer-only routes) hitting `<Viewer>` with an empty client-side
registry — node materials resolved to `null` and React surfaced a
`<html>`-level hydration mismatch on first paint.

Fix mirrors the community-app side that landed in pascalorg/private-
editor#27:

 - New `app/client-bootstrap.tsx` — thin client wrapper that imports
   `../lib/bootstrap` and renders children.
 - `app/layout.tsx` mounts `<ClientBootstrap>` around `{children}` so
   every page in the standalone editor gets the registry populated
   before its first `<Viewer>` / `<Editor>` mounts.
 - `lib/bootstrap.ts` switched to **synchronous** built-in registration
   via `registerNode(def)` per kind instead of `await loadPlugin(...)`.
   The previous async kick-off only resolved in a microtask, letting
   the first SSR / hydration pass see an empty registry. External
   plugin discovery (`discoverPlugins()`) stays async and runs via its
   own `loadExternalPlugins()` path, gated by `externalsKickedOff` so
   HMR doesn't re-fetch.
 - `components/scene-loader.tsx` drops the per-page side-effect import
   — the root provider handles it now.

`bun.lock` syncs `@pascal-app/editor` into `@pascal-app/nodes`'s
peerDependencies + devDependencies (already declared in
`packages/nodes/package.json`; only the lockfile lagged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-20 07:17:30 -04:00
co-authored by Claude Opus 4.7
parent 0bcec8e6ba
commit c4001a656f
5 changed files with 80 additions and 28 deletions
+16
View File
@@ -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 `<Viewer>` /
// `<Editor>` 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
}
+2 -1
View File
@@ -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({
)}
</head>
<body className="font-sans">
{children}
<ClientBootstrap>{children}</ClientBootstrap>
{process.env.NODE_ENV === 'development' && <Agentation />}
</body>
</html>
+3 -1
View File
@@ -1,6 +1,8 @@
'use client'
import '../lib/bootstrap'
// Node registry bootstrap is loaded once at the root via
// `<ClientBootstrap>` in `app/layout.tsx` — no per-page side-effect
// import here.
import {
applySceneGraphToEditor,
Editor,
+55 -26
View File
@@ -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<string, string | undefined> } }).process
@@ -12,32 +19,36 @@ function isDev(): boolean {
return env?.NODE_ENV !== 'production'
}
export async function loadBuiltinNodes(): Promise<void> {
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 `<html>` 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<void> {
}
}
// 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<void> {
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()
+4
View File
@@ -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": {