diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 8e4e8c7c..250a0f31 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -34,8 +34,10 @@ export function resolveLevelId(node: AnyNode, nodes: Record): s return 'default' // fallback for orphaned items } -// Call this once at app initialization -export function initSpatialGridSync() { +// Call this once at app initialization. Returns an unsubscribe function that +// detaches the scene-store listener (useful when the editor is unmounted so +// the spatial grid singleton does not hold stale references to old scenes). +export function initSpatialGridSync(): () => void { const store = useScene // 1. Initial sync - process all existing nodes const state = store.getState() @@ -48,7 +50,7 @@ export function initSpatialGridSync() { const markDirty = (id: AnyNodeId) => store.getState().markDirty(id) // Subscribe to all changes - store.subscribe((state, prevState) => { + const unsubscribe = store.subscribe((state, prevState) => { // Detect added nodes for (const [id, node] of Object.entries(state.nodes)) { if (!prevState.nodes[id as AnyNode['id']]) { @@ -113,6 +115,8 @@ export function initSpatialGridSync() { } } }) + + return unsubscribe } function arraysEqual(a: number[], b: number[]): boolean { diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index eeb841ad..659c3fb8 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1,7 +1,12 @@ 'use client' import { Icon } from '@iconify/react' -import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' +import { + initSpaceDetectionSync, + initSpatialGridSync, + spatialGridManager, + useScene, +} from '@pascal-app/core' import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { ViewerOverlay } from '../../components/viewer-overlay' @@ -52,16 +57,39 @@ import { SiteEdgeLabels } from './site-edge-labels' import { ThumbnailGenerator } from './thumbnail-generator' import { WallMeasurementLabel } from './wall-measurement-label' -let hasInitializedEditorRuntime = false const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' -function initializeEditorRuntime() { - if (hasInitializedEditorRuntime) return - initSpatialGridSync() - initSpaceDetectionSync(useScene, useEditor) +/** + * Wire up module-level singletons (spatial grid, space detection, SFX) for + * an Editor mount. Returns a teardown function that detaches the scene-store + * subscriptions and resets the shared singletons so a subsequent remount — + * including hot navigation back to the editor in the same tab — starts from + * a clean slate. Without this, the spatial-grid manager and viewer outliner + * accumulate stale references from the previous Editor instance and can + * freeze the app on re-entry. + */ +function initializeEditorRuntime(): () => void { + const unsubscribeSpatialGrid = initSpatialGridSync() + const unsubscribeSpaceDetection = initSpaceDetectionSync(useScene, useEditor) initSFXBus() - hasInitializedEditorRuntime = true + return () => { + unsubscribeSpatialGrid() + unsubscribeSpaceDetection?.() + + // Drop all entries the spatial-grid singleton accumulated for the + // previous scene so the next mount re-syncs from current state instead + // of layering on top of stale data. + spatialGridManager.clear() + + // The viewer outliner holds direct Object3D references used by the + // post-processing selection pass. Clearing the underlying arrays (we + // intentionally mutate in place — there is no setter by design) releases + // those refs so the disposed Three.js scene graph can be GC'd. + const outliner = useViewer.getState().outliner + outliner.selectedObjects.length = 0 + outliner.hoveredObjects.length = 0 + } } export interface EditorProps { // Layout version — 'v1' (default) or 'v2' (navbar + two-column) @@ -521,7 +549,8 @@ export default function Editor({ }, []) useEffect(() => { - initializeEditorRuntime() + const teardown = initializeEditorRuntime() + return teardown }, []) useEffect(() => { diff --git a/packages/editor/src/lib/sfx-bus.ts b/packages/editor/src/lib/sfx-bus.ts index 308718d2..6e838ce6 100644 --- a/packages/editor/src/lib/sfx-bus.ts +++ b/packages/editor/src/lib/sfx-bus.ts @@ -20,11 +20,15 @@ type SFXEvents = { */ export const sfxEmitter = mitt() +let sfxBusInitialized = false + /** - * Initialize SFX Bus - connects SFX events to actual sound playback - * Call once in your app initialization + * Initialize SFX Bus - connects SFX events to actual sound playback. + * Safe to call multiple times; re-registration is a no-op once initialized. */ export function initSFXBus() { + if (sfxBusInitialized) return + sfxBusInitialized = true // Map SFX events to sound playback sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap')) sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))