Merge pull request #317 from pascalorg/feat/client-bootstrap-provider
apps/editor: mount node-registry bootstrap at the root layout
This commit is contained in:
@@ -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 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user