fix(editor): release scene singletons on Editor unmount (#214)
The spatial-grid manager, scene-store subscriptions, and the viewer outliner's Object3D arrays are module-level singletons that previously lived for the whole page lifetime. When an app unmounted the Editor (e.g. navigating away from the 3D editor and back in an SPA host) they retained references to the disposed Three.js scene graph, which caused the app to freeze on re-entry as the stale data was replayed against a fresh scene. - initSpatialGridSync now returns the unsubscribe handle from its scene-store subscription so callers can detach it. - initializeEditorRuntime becomes reversible: it wires up the spatial grid, space detection and SFX bus on mount and returns a teardown closure that detaches both store subscriptions, clears the spatial grid manager, and truncates the viewer outliner's selected/hovered arrays in place. - The Editor component's runtime-init effect now returns that teardown, so the singletons are reset cleanly every time the Editor unmounts. - initSFXBus is now idempotent via an internal guard so repeated Editor mounts do not stack duplicate mitt listeners. Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -34,8 +34,10 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
|
|||||||
return 'default' // fallback for orphaned items
|
return 'default' // fallback for orphaned items
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call this once at app initialization
|
// Call this once at app initialization. Returns an unsubscribe function that
|
||||||
export function initSpatialGridSync() {
|
// 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
|
const store = useScene
|
||||||
// 1. Initial sync - process all existing nodes
|
// 1. Initial sync - process all existing nodes
|
||||||
const state = store.getState()
|
const state = store.getState()
|
||||||
@@ -48,7 +50,7 @@ export function initSpatialGridSync() {
|
|||||||
const markDirty = (id: AnyNodeId) => store.getState().markDirty(id)
|
const markDirty = (id: AnyNodeId) => store.getState().markDirty(id)
|
||||||
|
|
||||||
// Subscribe to all changes
|
// Subscribe to all changes
|
||||||
store.subscribe((state, prevState) => {
|
const unsubscribe = store.subscribe((state, prevState) => {
|
||||||
// Detect added nodes
|
// Detect added nodes
|
||||||
for (const [id, node] of Object.entries(state.nodes)) {
|
for (const [id, node] of Object.entries(state.nodes)) {
|
||||||
if (!prevState.nodes[id as AnyNode['id']]) {
|
if (!prevState.nodes[id as AnyNode['id']]) {
|
||||||
@@ -113,6 +115,8 @@ export function initSpatialGridSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
}
|
}
|
||||||
|
|
||||||
function arraysEqual(a: number[], b: number[]): boolean {
|
function arraysEqual(a: number[], b: number[]): boolean {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Icon } from '@iconify/react'
|
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 { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||||
@@ -52,16 +57,39 @@ import { SiteEdgeLabels } from './site-edge-labels'
|
|||||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||||
import { WallMeasurementLabel } from './wall-measurement-label'
|
import { WallMeasurementLabel } from './wall-measurement-label'
|
||||||
|
|
||||||
let hasInitializedEditorRuntime = false
|
|
||||||
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
|
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
|
||||||
|
|
||||||
function initializeEditorRuntime() {
|
/**
|
||||||
if (hasInitializedEditorRuntime) return
|
* Wire up module-level singletons (spatial grid, space detection, SFX) for
|
||||||
initSpatialGridSync()
|
* an Editor mount. Returns a teardown function that detaches the scene-store
|
||||||
initSpaceDetectionSync(useScene, useEditor)
|
* 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()
|
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 {
|
export interface EditorProps {
|
||||||
// Layout version — 'v1' (default) or 'v2' (navbar + two-column)
|
// Layout version — 'v1' (default) or 'v2' (navbar + two-column)
|
||||||
@@ -521,7 +549,8 @@ export default function Editor({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeEditorRuntime()
|
const teardown = initializeEditorRuntime()
|
||||||
|
return teardown
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ type SFXEvents = {
|
|||||||
*/
|
*/
|
||||||
export const sfxEmitter = mitt<SFXEvents>()
|
export const sfxEmitter = mitt<SFXEvents>()
|
||||||
|
|
||||||
|
let sfxBusInitialized = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize SFX Bus - connects SFX events to actual sound playback
|
* Initialize SFX Bus - connects SFX events to actual sound playback.
|
||||||
* Call once in your app initialization
|
* Safe to call multiple times; re-registration is a no-op once initialized.
|
||||||
*/
|
*/
|
||||||
export function initSFXBus() {
|
export function initSFXBus() {
|
||||||
|
if (sfxBusInitialized) return
|
||||||
|
sfxBusInitialized = true
|
||||||
// Map SFX events to sound playback
|
// Map SFX events to sound playback
|
||||||
sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))
|
sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))
|
||||||
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
|
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
|
||||||
|
|||||||
Reference in New Issue
Block a user