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:
co-authored by
Claude Opus 4.7
parent
0bcec8e6ba
commit
c4001a656f
@@ -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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { GeistPixelSquare } from 'geist/font/pixel'
|
|||||||
import { Barlow } from 'next/font/google'
|
import { Barlow } from 'next/font/google'
|
||||||
import localFont from 'next/font/local'
|
import localFont from 'next/font/local'
|
||||||
import Script from 'next/script'
|
import Script from 'next/script'
|
||||||
|
import { ClientBootstrap } from './client-bootstrap'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const geistSans = localFont({
|
const geistSans = localFont({
|
||||||
@@ -41,7 +42,7 @@ export default function RootLayout({
|
|||||||
)}
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body className="font-sans">
|
<body className="font-sans">
|
||||||
{children}
|
<ClientBootstrap>{children}</ClientBootstrap>
|
||||||
{process.env.NODE_ENV === 'development' && <Agentation />}
|
{process.env.NODE_ENV === 'development' && <Agentation />}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'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 {
|
import {
|
||||||
applySceneGraphToEditor,
|
applySceneGraphToEditor,
|
||||||
Editor,
|
Editor,
|
||||||
|
|||||||
@@ -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'
|
import { builtinPlugin } from '@pascal-app/nodes'
|
||||||
|
|
||||||
// Idempotency guard: HMR can reload this module, but `registerNode` throws on
|
// Idempotency guards: HMR can reload this module, but `registerNode`
|
||||||
// duplicate kinds. The flag lives in the module closure so it's reset on a
|
// throws on duplicate kinds. Flags live in the module closure so they
|
||||||
// hard reload but survives within a session.
|
// reset on a hard reload but survive within a session.
|
||||||
let loaded = false
|
let builtinsLoaded = false
|
||||||
|
let externalsKickedOff = false
|
||||||
|
|
||||||
function isDev(): boolean {
|
function isDev(): boolean {
|
||||||
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
|
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
|
||||||
@@ -12,32 +19,36 @@ function isDev(): boolean {
|
|||||||
return env?.NODE_ENV !== 'production'
|
return env?.NODE_ENV !== 'production'
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadBuiltinNodes(): Promise<void> {
|
/**
|
||||||
if (loaded) return
|
* Synchronously register every built-in node kind. Runs as a side
|
||||||
loaded = true
|
* effect at module import time so the registry is populated *before*
|
||||||
await loadPlugin(builtinPlugin)
|
* any downstream React tree renders — the previous async kick-off
|
||||||
|
* (`void loadBuiltinNodes()`) only registered in a microtask, letting
|
||||||
// Phase 6 plugin discovery hook. Always called; default impl returns
|
* the first SSR / hydration pass see an empty registry. The mismatch
|
||||||
// `[]`. Apps that ship external node packs override the discovery via
|
* surfaced as a hydration error at the `<html>` element and every
|
||||||
// `setPluginDiscovery(...)` before this module loads. See
|
* `NodeRenderer` resolving to `null` until later renders.
|
||||||
// `wiki/editor-plugin-authoring.md` for the contract.
|
*
|
||||||
const externals = await discoverPlugins()
|
* `discoverPlugins()` (which may hit the network for external packs)
|
||||||
for (const plugin of externals) {
|
* stays async and runs separately via `loadExternalPlugins()`.
|
||||||
await loadPlugin(plugin)
|
*/
|
||||||
|
function loadBuiltinsSync(): void {
|
||||||
|
if (builtinsLoaded) return
|
||||||
|
builtinsLoaded = true
|
||||||
|
for (const def of builtinPlugin.nodes ?? []) {
|
||||||
|
registerNode(def as AnyNodeDefinition)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
const kinds = Array.from(nodeRegistry.entries(), ([k]) => k)
|
const kinds = Array.from(nodeRegistry.entries(), ([k]) => k)
|
||||||
if (typeof console !== 'undefined') {
|
if (typeof console !== 'undefined') {
|
||||||
// Visible in the browser dev console — the verification anchor for
|
// biome-ignore lint/suspicious/noConsole: dev-only verification log
|
||||||
// "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.
|
|
||||||
console.info(
|
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
|
// Expose the registry on globalThis for ad-hoc dev inspection. In
|
||||||
// registry is reachable through @pascal-app/core's exports only.
|
// prod the registry is reachable through @pascal-app/core's
|
||||||
|
// exports only.
|
||||||
if (typeof globalThis !== 'undefined') {
|
if (typeof globalThis !== 'undefined') {
|
||||||
;(globalThis as { __pascalNodeRegistry?: typeof nodeRegistry }).__pascalNodeRegistry =
|
;(globalThis as { __pascalNodeRegistry?: typeof nodeRegistry }).__pascalNodeRegistry =
|
||||||
nodeRegistry
|
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.
|
* Phase 6 plugin discovery hook — runs once, asynchronously, after the
|
||||||
void loadBuiltinNodes()
|
* 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()
|
||||||
|
|||||||
@@ -188,6 +188,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@pascal-app/core": "^0.8.0",
|
"@pascal-app/core": "^0.8.0",
|
||||||
|
"@pascal-app/editor": "^0.8.0",
|
||||||
"@pascal-app/viewer": "^0.8.0",
|
"@pascal-app/viewer": "^0.8.0",
|
||||||
"@pascal/typescript-config": "*",
|
"@pascal/typescript-config": "*",
|
||||||
"@types/bun": "^1.3.0",
|
"@types/bun": "^1.3.0",
|
||||||
@@ -198,11 +199,14 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@pascal-app/core": "^0.8.0",
|
"@pascal-app/core": "^0.8.0",
|
||||||
|
"@pascal-app/editor": "^0.8.0",
|
||||||
"@pascal-app/viewer": "^0.8.0",
|
"@pascal-app/viewer": "^0.8.0",
|
||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
|
"lucide-react": "^1",
|
||||||
"react": "^18 || ^19",
|
"react": "^18 || ^19",
|
||||||
"three": "^0.184",
|
"three": "^0.184",
|
||||||
|
"zustand": "^5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/typescript-config": {
|
"packages/typescript-config": {
|
||||||
|
|||||||
Reference in New Issue
Block a user