Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/editor/app/client-bootstrap.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 1 deletion apps/editor/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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>
Expand Down
4 changes: 3 additions & 1 deletion apps/editor/components/scene-loader.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
81 changes: 55 additions & 26 deletions apps/editor/lib/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,79 @@
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
?.env
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
}
}
}

// 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 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading