From 2acec49b29dbc0768d02a52f10e64dfe8b435729 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 19 May 2026 22:05:59 -0700 Subject: [PATCH] refactor: Clone context when a view is cloned (#2493) --- src/card-controller/call/manager.ts | 7 ++----- src/components/viewer/carousel.ts | 6 ++++-- src/view/view.ts | 6 +++--- tests/view/view.test.ts | 21 ++++++++++++++++++++- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/card-controller/call/manager.ts b/src/card-controller/call/manager.ts index 545ff1e1..bbe0cc6e 100644 --- a/src/card-controller/call/manager.ts +++ b/src/card-controller/call/manager.ts @@ -1,4 +1,3 @@ -import { cloneDeep } from 'lodash-es'; import { createNotificationFromText } from '../../components-lib/notification/factory'; import { ConditionStateChange } from '../../conditions/types'; import { localize } from '../../localize/localize'; @@ -88,12 +87,10 @@ export class CallManager { // Store the previous view so it can be restored later. A call superseding // another inherits the earlier call's previous view -- the user never left - // the call. `queryResults` are dropped (re-fetched fresh on restore); - // `context` is deep-cloned so the call engaging its own substream below - // cannot mutate the snapshot. + // the call. `queryResults` are dropped (re-fetched fresh on restore). const previousView = existingCall ? existingCall.previousView - : view.evolve({ queryResults: null, context: cloneDeep(view.context) }); + : view.evolve({ queryResults: null }); const needsNavigation = !view.is('live') || view.camera !== parentID; diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index ed6a9085..94fa65e0 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -383,8 +383,10 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { // on media load, since the media may or may not have been loaded at // this point). if ( - this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !== - this.viewManagerEpoch?.oldView?.context?.mediaViewer + this.viewManagerEpoch?.manager + .getView() + ?.context?.mediaViewer?.seek?.getTime() !== + this.viewManagerEpoch?.oldView?.context?.mediaViewer?.seek?.getTime() ) { this._seekHandler(); } diff --git a/src/view/view.ts b/src/view/view.ts index cf200973..e1dc0fa0 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -1,4 +1,4 @@ -import { merge } from 'lodash-es'; +import { cloneDeep, merge } from 'lodash-es'; import { ViewContext } from 'view'; import { AdvancedCameraCardView } from '../config/schema/common/const'; import { ViewDisplayMode } from '../config/schema/common/display'; @@ -95,7 +95,7 @@ export class View { camera: this.camera, query: this.query?.clone() ?? null, queryResults: this.queryResults?.clone() ?? null, - context: this.context, + context: cloneDeep(this.context), displayMode: this.displayMode, }); } @@ -114,7 +114,7 @@ export class View { params.queryResults !== undefined ? params.queryResults : this.queryResults?.clone() ?? null, - context: params.context !== undefined ? params.context : this.context, + context: params.context !== undefined ? params.context : cloneDeep(this.context), displayMode: params.displayMode !== undefined ? params.displayMode : this.displayMode, }); diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index 6c88a4a7..3f6c4808 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -87,7 +87,10 @@ describe('View Basics', () => { expect(evolved.view).toBe(view.view); expect(evolved.camera).toBe(view.camera); - expect(evolved.context).toBe(view.context); + + // Context is deep-cloned if not set, so the evolved view owns it. + expect(evolved.context).not.toBe(view.context); + expect(evolved.context).toEqual(view.context); // Query and QueryResults are cloned if not set. expect(evolved.query).not.toBe(view.query); @@ -96,6 +99,22 @@ describe('View Basics', () => { expect(evolved.queryResults).toEqual(view.queryResults); }); + it('should isolate inherited context from mutation on clone and evolve', () => { + const view = createView({ + view: 'live', + camera: 'camera-1', + context: { live: { overrides: new Map([['camera-1', 'sub-1']]) } }, + }); + + const cloned = view.clone(); + const evolved = view.evolve({}); + + cloned.context?.live?.overrides?.set('camera-1', 'mutated-clone'); + evolved.context?.live?.overrides?.delete('camera-1'); + + expect(view.context?.live?.overrides?.get('camera-1')).toBe('sub-1'); + }); + it('should not clone query and queryResults with nothing set', () => { const view = createView(); const evolved = view.evolve({});