Skip to content

Commit 1dbcb64

Browse files
veilletteclaude
andcommitted
fix: sync view lifecycle with model to prevent memory leaks
Add elementDisposedEmitter/elementCreatedEmitter listeners so views are properly disposed on undo and recreated on redo. Reorder carousel callback to register the view before adding to the model, preventing duplicate view creation. Unlink rebuildEmitter listener on dispose. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7485ba1 commit 1dbcb64

1 file changed

Lines changed: 74 additions & 9 deletions

File tree

src/common/view/SimScreenView.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,12 @@ export class RayTracingCommonView extends ScreenView {
212212
);
213213
const modelViewTransform = this.modelViewTransform;
214214

215-
this.selectedElementProperty = new Property<OpticalElement | null>(null);
215+
this.selectedElementProperty = new Property<OpticalElement | null>(null, {
216+
// The fuzz tester can fire overlapping input events (e.g. carousel drag-create
217+
// sets null while a simultaneous down event selects an element) during a single
218+
// dispatch cycle, causing reentrant sets. Allow reentry with queued notifications.
219+
reentrant: true,
220+
});
216221

217222
// ── Grid (model + preferences stay in sync for PhET-iO and global prefs) ─
218223
model.scene.showGridProperty.value = opticsLabQueryParameters.showGrid;
@@ -366,16 +371,20 @@ export class RayTracingCommonView extends ScreenView {
366371
// Deselect any currently selected element so the edit panel hides.
367372
this.selectedElementProperty.value = null;
368373

369-
// Add to model
370-
model.scene.addElement(element);
371-
372-
// Create and add corresponding view
374+
// Create and register the view BEFORE adding to the model so that
375+
// the elementCreatedEmitter listener (used for undo/redo) sees the
376+
// view already exists and skips duplicate creation.
373377
const tandemName = element.id.replace(/-(\d+)$/, (_, n) => n);
374378
const elementTandem = tandem?.createTandem(tandemName) ?? Tandem.OPTIONAL;
375379
const view = createOpticalElementView(element, modelViewTransform, elementTandem);
376380
if (view) {
377381
this._setupView(element, view);
378382
}
383+
384+
// Add to model (fires elementCreatedEmitter — listener will no-op
385+
// because the view is already in elementViewMap).
386+
model.scene.addElement(element);
387+
379388
return view;
380389
},
381390
carouselComponents,
@@ -449,6 +458,42 @@ export class RayTracingCommonView extends ScreenView {
449458
}
450459
}
451460

461+
// ── Model→View synchronization (undo/redo) ─────────────────────────────
462+
// When the model adds an element (e.g. redo of a remove, or undo of a
463+
// delete), create the corresponding view if one doesn't already exist.
464+
model.scene.opticalElementsGroup.elementCreatedEmitter.addListener((wrapper) => {
465+
const element = wrapper.opticalElement;
466+
if (this.elementViewMap.has(element.id)) {
467+
return; // view already exists (normal add via carousel)
468+
}
469+
const tn = element.id.replace(/-(\d+)$/, (_, n: string) => n);
470+
const et = tandem?.createTandem(tn) ?? Tandem.OPTIONAL;
471+
const view = createOpticalElementView(element, modelViewTransform, et);
472+
if (view) {
473+
this._setupView(element, view);
474+
}
475+
});
476+
477+
// When the model removes an element (e.g. undo of an add), dispose the
478+
// corresponding view so it can be garbage collected.
479+
model.scene.opticalElementsGroup.elementDisposedEmitter.addListener((wrapper) => {
480+
const element = wrapper.opticalElement;
481+
const view = this.elementViewMap.get(element.id);
482+
if (!view) {
483+
return; // already cleaned up (normal delete via _deleteElement)
484+
}
485+
if (this.selectedElementProperty.value === element) {
486+
this.selectedElementProperty.value = null;
487+
}
488+
if (this.elementsLayer.children.includes(view)) {
489+
this.elementsLayer.removeChild(view);
490+
} else if (this.dragLayer.children.includes(view)) {
491+
this.dragLayer.removeChild(view);
492+
}
493+
this.elementViewMap.delete(element.id);
494+
view.dispose();
495+
});
496+
452497
// ── Tools ─────────────────────────────────────────────────────────────────
453498
const measuringTapeVisibleProperty = new BooleanProperty(opticsLabQueryParameters.showMeasuringTape);
454499
const protractorVisibleProperty = new BooleanProperty(opticsLabQueryParameters.showProtractor);
@@ -866,9 +911,17 @@ export class RayTracingCommonView extends ScreenView {
866911
// For views that can change geometry via drag handles, sync the edit panel
867912
// and clear any completed detector acquisitions (scene geometry changed).
868913
if (view instanceof BaseOpticalElementView) {
869-
view.rebuildEmitter.addListener(() => {
914+
const rebuildListener = (): void => {
870915
this.editContainerNode.refresh();
871916
this._clearAllDetectorAcquisitions();
917+
// Element positions are plain objects (not axon Properties), so dragging
918+
// does not automatically mark the scene dirty. Invalidate here so the
919+
// ray tracer re-runs on the next step() rather than showing a stale result.
920+
this.model.scene.invalidate();
921+
};
922+
view.rebuildEmitter.addListener(rebuildListener);
923+
view.disposeEmitter.addListener(() => {
924+
view.rebuildEmitter.removeListener(rebuildListener);
872925
});
873926
}
874927

@@ -890,7 +943,7 @@ export class RayTracingCommonView extends ScreenView {
890943
// reparenting and for the return-to-carousel detection.
891944
let inDragLayer = false;
892945

893-
view.bodyDragListener.isPressedProperty.lazyLink((isPressed) => {
946+
const pressedListener = (isPressed: boolean): void => {
894947
if (isPressed) {
895948
// Reparent to the drag layer so the element renders above the carousel.
896949
this.elementsLayer.removeChild(view);
@@ -917,6 +970,13 @@ export class RayTracingCommonView extends ScreenView {
917970
this.elementsLayer.addChild(view);
918971
inDragLayer = false;
919972
}
973+
};
974+
view.bodyDragListener.isPressedProperty.lazyLink(pressedListener);
975+
976+
// Unlink when the view is disposed so the closure (which captures view,
977+
// element, and this SimScreenView) doesn't prevent garbage collection.
978+
view.disposeEmitter.addListener(() => {
979+
view.bodyDragListener.isPressedProperty.unlink(pressedListener);
920980
});
921981

922982
// Give each element an accessible name so screen readers can identify it.
@@ -956,13 +1016,18 @@ export class RayTracingCommonView extends ScreenView {
9561016
.some((el) => el instanceof DetectorElement && el.isAcquiring);
9571017
if (anyAcquiring) {
9581018
for (let i = 0; i < ACQUISITION_PASSES_PER_FRAME; i++) {
959-
this.model.scene.invalidate();
1019+
// No invalidate() needed: simulate() bypasses its cache when anyAcquiring,
1020+
// and jitter is applied fresh each call via the jitter: anyAcquiring config flag.
9601021
this.model.scene.simulate();
9611022
}
9621023
}
9631024

9641025
// Final pass: simulate and update the view with the result.
965-
this.model.scene.invalidate();
1026+
// No unconditional invalidate() here — the scene marks itself dirty via
1027+
// rebuildEmitter (drag) or its Multilink (property changes). Removing the
1028+
// forced invalidation allows the cached TraceResult to be reused for static
1029+
// scenes, eliminating per-frame allocation of TracedSegment arrays and the
1030+
// deduplication Sets in findImagesInSequence.
9661031
const result = this.model.scene.simulate();
9671032
const currentMode = this.model.scene.modeProperty.value;
9681033
this.rayPropagationView.setSegments(result.segments, currentMode);

0 commit comments

Comments
 (0)