fix(editor): null-safe cursor-group access in placement coordinator (#323)
Guards every `cursorGroupRef.current` dereference in the placement coordinator against the null window where mitt listeners are live but the `<group>` is unmounted (mount/teardown race) — the EDITOR-BC/BD crash family. `getContext()` falls back to the draft's rotation, so the validation/revalidate path (Shift keys, onKeyUp) is safe; only the cursor *writes* are guarded, so Escape/right-click cancel, Shift reset, leave-state cleanup and transition state still run unconditionally. Also guards `wallPreviewRef` inside `WallTool.stopDrafting()` (the double-click/cancel path that the earlier diff missed). Reimplemented against current main (the original branch conflicted with #366 and used over-broad handler guards that silently dropped cancel/Shift). Verified: `tsc -p apps/editor` clean, biome clean on touched files.
This commit is contained in:
@@ -345,8 +345,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
||||||
gridPosition.current.y = 0
|
gridPosition.current.y = 0
|
||||||
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.y = 0
|
cursorGroupRef.current.position.y = 0
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
@@ -356,7 +358,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
draftItem: draftNode.current,
|
draftItem: draftNode.current,
|
||||||
gridPosition: gridPosition.current,
|
gridPosition: gridPosition.current,
|
||||||
state: { ...placementState.current },
|
state: { ...placementState.current },
|
||||||
currentCursorRotationY: cursorGroupRef.current.rotation.y,
|
currentCursorRotationY:
|
||||||
|
cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const getActiveValidators = () =>
|
const getActiveValidators = () =>
|
||||||
@@ -390,12 +393,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
|
|
||||||
const c = worldToBuildingLocal(...result.cursorPosition)
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
||||||
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
||||||
if (result.cursorRotation) {
|
if (result.cursorRotation) {
|
||||||
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
||||||
} else {
|
} else {
|
||||||
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@@ -408,12 +413,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const ensureDraft = (result: TransitionResult) => {
|
const ensureDraft = (result: TransitionResult) => {
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
const c = worldToBuildingLocal(...result.cursorPosition)
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
||||||
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
||||||
if (result.cursorRotation) {
|
if (result.cursorRotation) {
|
||||||
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
||||||
} else {
|
} else {
|
||||||
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initRotation: [number, number, number] = result.cursorRotation ?? [
|
const initRotation: [number, number, number] = result.cursorRotation ?? [
|
||||||
0,
|
0,
|
||||||
@@ -453,6 +460,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const worldPos = new Vector3()
|
const worldPos = new Vector3()
|
||||||
mesh.getWorldPosition(worldPos)
|
mesh.getWorldPosition(worldPos)
|
||||||
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
||||||
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.copy(localPos)
|
cursorGroupRef.current.position.copy(localPos)
|
||||||
if (draftNode.current.asset.attachTo) {
|
if (draftNode.current.asset.attachTo) {
|
||||||
// Wall/ceiling items: extract world Y rotation (handles wall-parented items correctly)
|
// Wall/ceiling items: extract world Y rotation (handles wall-parented items correctly)
|
||||||
@@ -466,7 +474,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// box mis-rotated until the first cursor move.
|
// box mis-rotated until the first cursor move.
|
||||||
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
} else if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.copy(gridPosition.current)
|
cursorGroupRef.current.position.copy(gridPosition.current)
|
||||||
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
||||||
}
|
}
|
||||||
@@ -486,6 +495,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
has3DPointerDrivenMoveRef.current = true
|
has3DPointerDrivenMoveRef.current = true
|
||||||
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
||||||
|
if (!cursorGroupRef.current) return
|
||||||
const result = floorStrategy.move(getContext(), event)
|
const result = floorStrategy.move(getContext(), event)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
@@ -530,7 +540,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
// Preserve cursor rotation for the next draft
|
// Preserve cursor rotation for the next draft
|
||||||
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
|
const currentRotation: [number, number, number] = [
|
||||||
|
0,
|
||||||
|
cursorGroupRef.current?.rotation.y ?? draftNode.current?.rotation[1] ?? 0,
|
||||||
|
0,
|
||||||
|
]
|
||||||
|
|
||||||
// Clear live transform before commit
|
// Clear live transform before commit
|
||||||
if (draftNode.current) {
|
if (draftNode.current) {
|
||||||
@@ -581,6 +595,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
has3DPointerDrivenMoveRef.current = true
|
has3DPointerDrivenMoveRef.current = true
|
||||||
|
if (!cursorGroupRef.current) return
|
||||||
const ctx = getContext()
|
const ctx = getContext()
|
||||||
|
|
||||||
if (ctx.state.surface !== 'wall') {
|
if (ctx.state.surface !== 'wall') {
|
||||||
@@ -761,7 +776,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
||||||
gridPosition.current.set(wx, 0, wz)
|
gridPosition.current.set(wx, 0, wz)
|
||||||
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.set(wx, 0, wz)
|
cursorGroupRef.current.position.set(wx, 0, wz)
|
||||||
|
}
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@@ -795,6 +812,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const onItemMove = (event: ItemEvent) => {
|
const onItemMove = (event: ItemEvent) => {
|
||||||
if (event.node.id === draftNode.current?.id) return
|
if (event.node.id === draftNode.current?.id) return
|
||||||
has3DPointerDrivenMoveRef.current = true
|
has3DPointerDrivenMoveRef.current = true
|
||||||
|
if (!cursorGroupRef.current) return
|
||||||
const ctx = getContext()
|
const ctx = getContext()
|
||||||
|
|
||||||
if (ctx.state.surface !== 'item-surface') {
|
if (ctx.state.surface !== 'item-surface') {
|
||||||
@@ -1019,6 +1037,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const onCeilingMove = (event: CeilingEvent) => {
|
const onCeilingMove = (event: CeilingEvent) => {
|
||||||
has3DPointerDrivenMoveRef.current = true
|
has3DPointerDrivenMoveRef.current = true
|
||||||
|
if (!cursorGroupRef.current) return
|
||||||
if (!draftNode.current && placementState.current.surface === 'ceiling') {
|
if (!draftNode.current && placementState.current.surface === 'ceiling') {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||||
@@ -1254,7 +1273,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
||||||
|
|
||||||
// Ref + cursor mesh + item mesh — no store update during drag
|
// Ref + cursor mesh + item mesh — no store update during drag
|
||||||
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.rotation.y = newRotationY
|
cursorGroupRef.current.rotation.y = newRotationY
|
||||||
|
}
|
||||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||||
if (mesh) mesh.rotation.y = newRotationY
|
if (mesh) mesh.rotation.y = newRotationY
|
||||||
|
|
||||||
@@ -1268,8 +1289,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
|
const z = snapToGrid(lastRawPos.current.z, swapDims ? dimX : dimZ)
|
||||||
gridPosition.current.set(x, gridPosition.current.y, z)
|
gridPosition.current.set(x, gridPosition.current.y, z)
|
||||||
draft.position = [x, gridPosition.current.y, z]
|
draft.position = [x, gridPosition.current.y, z]
|
||||||
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.x = x
|
cursorGroupRef.current.position.x = x
|
||||||
cursorGroupRef.current.position.z = z
|
cursorGroupRef.current.position.z = z
|
||||||
|
}
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
mesh.position.x = x
|
mesh.position.x = x
|
||||||
mesh.position.z = z
|
mesh.position.z = z
|
||||||
@@ -1292,7 +1315,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
worldSnapped.y,
|
worldSnapped.y,
|
||||||
worldSnapped.z,
|
worldSnapped.z,
|
||||||
)
|
)
|
||||||
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
cursorGroupRef.current.position.set(localSnapped.x, localSnapped.y, localSnapped.z)
|
||||||
|
}
|
||||||
if (mesh) mesh.position.set(x, y, z)
|
if (mesh) mesh.position.set(x, y, z)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1300,13 +1325,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// Update live transform for 2D floorplan with post-snap position
|
// Update live transform for 2D floorplan with post-snap position
|
||||||
const currentLive = useLiveTransforms.getState().get(draft.id)
|
const currentLive = useLiveTransforms.getState().get(draft.id)
|
||||||
if (currentLive) {
|
if (currentLive) {
|
||||||
useLiveTransforms.getState().set(draft.id, {
|
const livePosition: [number, number, number] = cursorGroupRef.current
|
||||||
...currentLive,
|
? [
|
||||||
position: [
|
|
||||||
cursorGroupRef.current.position.x,
|
cursorGroupRef.current.position.x,
|
||||||
cursorGroupRef.current.position.y,
|
cursorGroupRef.current.position.y,
|
||||||
cursorGroupRef.current.position.z,
|
cursorGroupRef.current.position.z,
|
||||||
] as [number, number, number],
|
]
|
||||||
|
: [draft.position[0], draft.position[1], draft.position[2]]
|
||||||
|
useLiveTransforms.getState().set(draft.id, {
|
||||||
|
...currentLive,
|
||||||
|
position: livePosition,
|
||||||
rotation: newRotationY,
|
rotation: newRotationY,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1471,6 +1499,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
if (!draftNode.current) return
|
if (!draftNode.current) return
|
||||||
|
if (!cursorGroupRef.current) return
|
||||||
// The mesh-position lerp below only makes sense once this coordinator
|
// The mesh-position lerp below only makes sense once this coordinator
|
||||||
// owns the move via a 3D pointer event. Skip until then so that
|
// owns the move via a 3D pointer event. Skip until then so that
|
||||||
// external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
|
// external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
|
||||||
|
|||||||
@@ -431,7 +431,9 @@ export const WallTool: React.FC = () => {
|
|||||||
|
|
||||||
const stopDrafting = () => {
|
const stopDrafting = () => {
|
||||||
buildingState.current = 0
|
buildingState.current = 0
|
||||||
|
if (wallPreviewRef.current) {
|
||||||
wallPreviewRef.current.visible = false
|
wallPreviewRef.current.visible = false
|
||||||
|
}
|
||||||
setDraftMeasurement(null)
|
setDraftMeasurement(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user