From 8e42b84355ad7904343f023286a932afc41d4c88 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 20 Aug 2026 08:43:17 -0400 Subject: [PATCH 1/2] chore(core): evict the last runtime R3F import from the core barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ElevatorRuntimeSystem (a useFrame wrapper) moves to @pascal-app/nodes' elevator system, its only consumer; stepElevatorRuntimes stays exported from core. A new architecture test fails core on any runtime three / @react-three import (type-only imports are erased and allowed) — proven red on a probe file with named, side-effect, and mixed type/value forms. Importing the core barrel from a Next.js route handler no longer evaluates R3F under the RSC server condition (the capture-upload 500 class of bug). Co-Authored-By: Claude Fable 5 --- packages/core/src/architecture.test.ts | 52 +++++++++++++++++++ packages/core/src/index.ts | 1 - .../elevator/elevator-runtime-system.tsx | 10 ---- packages/nodes/src/elevator/system.tsx | 13 ++++- 4 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/architecture.test.ts delete mode 100644 packages/core/src/systems/elevator/elevator-runtime-system.tsx diff --git a/packages/core/src/architecture.test.ts b/packages/core/src/architecture.test.ts new file mode 100644 index 0000000000..7a1b5e0199 --- /dev/null +++ b/packages/core/src/architecture.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test' +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +/** + * Layer rule (AGENTS.md): core is pure logic — no Three.js, no rendering. + * A runtime `three`/`@react-three/*` import in core evaluates R3F (and thus + * React client context) in every consumer of the barrel, which crashes + * Next.js route handlers under the RSC server condition (capture uploads + * 500'd this way once). Type-only imports are erased at build and allowed. + */ +const SRC = resolve(import.meta.dir) + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name) + if (entry.isDirectory()) return sourceFiles(full) + if (/\.test\.tsx?$/.test(entry.name)) return [] + return /\.tsx?$/.test(entry.name) ? [full] : [] + }) +} + +const BANNED_SPEC = String.raw`(?:three(?:\/[^'"]*)?|@react-three\/[^'"]*)` +// `import`/`export … from 'three…'` — group 1 captures a whole-clause `type` +// qualifier, the only form guaranteed to be erased by the compiler. +const FROM_RE = new RegExp( + String.raw`(?:import|export)\s+(type\s)?[\w*{}\s,$]*?from\s*['"]${BANNED_SPEC}['"]`, + 'g', +) +// Bare side-effect form: `import 'three…'` — always a runtime import. +const SIDE_EFFECT_RE = new RegExp(String.raw`import\s*['"]${BANNED_SPEC}['"]`, 'g') + +describe('architecture', () => { + test('core has no runtime three/@react-three imports', () => { + const files = sourceFiles(SRC) + const offenders: string[] = [] + + for (const file of files) { + const src = readFileSync(file, 'utf8') + for (const match of src.matchAll(FROM_RE)) { + if (!match[1]) offenders.push(`${relative(SRC, file)}: ${match[0].replaceAll('\n', ' ')}`) + } + for (const match of src.matchAll(SIDE_EFFECT_RE)) { + offenders.push(`${relative(SRC, file)}: ${match[0]}`) + } + } + + expect(offenders).toEqual([]) + // Guard against the walk passing vacuously. + expect(files.length).toBeGreaterThan(100) + }) +}) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 72ff0972fa..8b9b1f4a98 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -361,7 +361,6 @@ export { stepElevatorRuntimeState, stepElevatorRuntimes, } from './systems/elevator/elevator-runtime' -export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system' export { type ElevatorLevelEntry, resolveElevatorBuildingLevels, diff --git a/packages/core/src/systems/elevator/elevator-runtime-system.tsx b/packages/core/src/systems/elevator/elevator-runtime-system.tsx deleted file mode 100644 index 91c897a23a..0000000000 --- a/packages/core/src/systems/elevator/elevator-runtime-system.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useFrame } from '@react-three/fiber' -import { stepElevatorRuntimes } from './elevator-runtime' - -export function ElevatorRuntimeSystem() { - useFrame(({ clock }, delta) => { - stepElevatorRuntimes(clock.getElapsedTime() * 1000, delta) - }, 2) - - return null -} diff --git a/packages/nodes/src/elevator/system.tsx b/packages/nodes/src/elevator/system.tsx index 1a799b2514..e45853b982 100644 --- a/packages/nodes/src/elevator/system.tsx +++ b/packages/nodes/src/elevator/system.tsx @@ -1,7 +1,18 @@ 'use client' -import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core' +import { ElevatorOpeningSystem, stepElevatorRuntimes } from '@pascal-app/core' import { ElevatorInteractionSystem } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' + +/** Cab travel + door state machine, stepped once per frame. Lives here rather + * than in core so the core barrel stays free of runtime R3F imports. */ +function ElevatorRuntimeSystem() { + useFrame(({ clock }, delta) => { + stepElevatorRuntimes(clock.getElapsedTime() * 1000, delta) + }, 2) + + return null +} /** * Composite system for elevator — bundles three per-frame systems: From cab7f9a2a0c9183cf42fed98da2bb5d556f45671 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 20 Aug 2026 08:45:17 -0400 Subject: [PATCH 2/2] chore(core): keep the registry barrel React-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useRegistryVersion moves its export from registry/index.ts to the core barrel: the registry barrel is reachable from server-safe graphs (schema → spatial grid → registry), and the hook's react import crashed Next.js route handlers that import @pascal-app/core/schema. Public API unchanged — consumers import it from '@pascal-app/core' as before. Co-Authored-By: Claude Fable 5 --- packages/core/src/index.ts | 4 ++++ packages/core/src/registry/index.ts | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8b9b1f4a98..4839bd50dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -281,6 +281,10 @@ export type { FloorPlacedFootprintsResolver, } from './registry' export * from './registry' +// Exported here rather than from the registry barrel: that barrel is +// reachable from server-safe graphs (schema → spatial grid → registry) +// and must stay free of React imports. +export { useRegistryVersion } from './registry/use-registry-version' export * from './schema' export * from './services' export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index da45fac0a6..83a1a7305c 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -162,4 +162,3 @@ export type { ToolHintChip, Vec2, } from './types' -export { useRegistryVersion } from './use-registry-version'