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>
80 lines
2.9 KiB
TypeScript
80 lines
2.9 KiB
TypeScript
import {
|
|
type AnyNodeDefinition,
|
|
discoverPlugins,
|
|
loadPlugin,
|
|
nodeRegistry,
|
|
registerNode,
|
|
} from '@pascal-app/core'
|
|
import { builtinPlugin } from '@pascal-app/nodes'
|
|
|
|
// 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
|
|
?.env
|
|
return env?.NODE_ENV !== 'production'
|
|
}
|
|
|
|
/**
|
|
* 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') {
|
|
// biome-ignore lint/suspicious/noConsole: dev-only verification log
|
|
console.info(
|
|
`[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})`,
|
|
)
|
|
}
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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()
|