From a177471ed0652a33e553cee6068ae060ac5d3860 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sat, 16 May 2026 22:28:52 -0300 Subject: [PATCH] fix(sensor-panel): per-sensor state when switching between same-type sensors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a second photoresistor (or any second sensor of the same metadataId) showed the previously-clicked sensor's slider value because the panel was reused across clicks and its useState only ran once. The mount useEffect also unconditionally dispatched config defaults, which would have wiped any prior customisation if we naively remounted. Three changes: - SensorUpdateRegistry caches the last-dispatched values per componentId (and clears them on unregister) so the panel has a place to read from. - SensorControlPanel hydrates from that cache on mount, falling back to config defaults only when the sensor has never been touched. The default-dispatch useEffect skips when cached values already exist. - SimulatorCanvas keys the panel on sensorControlComponentId, forcing a fresh mount when the user switches sensors — without that, hydration wouldn't run on subsequent opens. --- .../simulator/SensorControlPanel.tsx | 22 +++++++++++----- .../components/simulator/SimulatorCanvas.tsx | 7 ++++- .../src/simulation/SensorUpdateRegistry.ts | 26 ++++++++++++++----- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/simulator/SensorControlPanel.tsx b/frontend/src/components/simulator/SensorControlPanel.tsx index f7e97e6f..7824545d 100644 --- a/frontend/src/components/simulator/SensorControlPanel.tsx +++ b/frontend/src/components/simulator/SensorControlPanel.tsx @@ -13,7 +13,7 @@ import { type SensorControl, type SliderControl, } from '../../simulation/sensorControlConfig'; -import { dispatchSensorUpdate } from '../../simulation/SensorUpdateRegistry'; +import { dispatchSensorUpdate, getLastSensorValues } from '../../simulation/SensorUpdateRegistry'; import './SensorControlPanel.css'; interface SensorControlPanelProps { @@ -51,14 +51,22 @@ export const SensorControlPanel: React.FC = ({ const { t } = useTranslation(); const def = SENSOR_CONTROLS[metadataId]; - // Local slider/button state — initialised from config defaults - const [values, setValues] = useState>( - def ? { ...def.defaultValues } : {}, - ); + // Local slider/button state — hydrated from the registry's last-known + // values for this componentId (so reopening a sensor or switching between + // two sensors of the same type shows each one's current state, not the + // previous panel's). Falls back to config defaults the first time a + // sensor is opened. + const [values, setValues] = useState>(() => { + const cached = getLastSensorValues(componentId); + if (cached) return { ...(def?.defaultValues ?? {}), ...cached }; + return def ? { ...def.defaultValues } : {}; + }); - // Push initial defaults into simulation on mount + // Push defaults into simulation on first open for this sensor. Skipped + // when the sensor already has cached values — the simulation still holds + // them, no need to clobber. useEffect(() => { - if (def && Object.keys(def.defaultValues).length > 0) { + if (def && Object.keys(def.defaultValues).length > 0 && !getLastSensorValues(componentId)) { dispatchSensorUpdate(componentId, def.defaultValues); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 462b9524..16551091 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -2382,13 +2382,18 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { : 'default', }} > - {/* Sensor Control Panel — shown when a sensor component is clicked during simulation */} + {/* Sensor Control Panel — shown when a sensor component is clicked during simulation. + key={sensorControlComponentId} forces a fresh mount when the user clicks a + different instance of the same sensor type (e.g. a second photoresistor); the + slider state is local and would otherwise show the previously-clicked sensor's + value until the user manually moved it. */} {sensorControlComponentId && sensorControlMetadataId && (() => { const meta = registry.getById(sensorControlMetadataId); return ( ) => void; +type SensorValues = Record; +type SensorUpdateCallback = (values: SensorValues) => void; const registry = new Map(); +const lastValues = new Map(); /** * Register a callback for a component. Called from inside attachEvents(). @@ -20,19 +22,31 @@ export function registerSensorUpdate(componentId: string, cb: SensorUpdateCallba /** * Dispatch new sensor values for a component. Called from SensorControlPanel. - * No-ops silently if the component has no registered callback. + * No-ops silently if the component has no registered callback. Values are + * also cached so the panel can rehydrate the slider when reopened on the + * same sensor (or when switching between sensors of the same type). */ -export function dispatchSensorUpdate( - componentId: string, - values: Record, -): void { +export function dispatchSensorUpdate(componentId: string, values: SensorValues): void { registry.get(componentId)?.(values); + const prev = lastValues.get(componentId); + lastValues.set(componentId, prev ? { ...prev, ...values } : { ...values }); +} + +/** + * Read the last values dispatched for a component. Returns undefined if the + * component has never received a dispatch. Used by SensorControlPanel to + * restore slider state when reopened. + */ +export function getLastSensorValues(componentId: string): SensorValues | undefined { + return lastValues.get(componentId); } /** * Unregister a component's callback. Called in the cleanup function returned * by attachEvents() so stale callbacks don't persist after simulation stops. + * Values are also cleared so a deleted/recreated component starts fresh. */ export function unregisterSensorUpdate(componentId: string): void { registry.delete(componentId); + lastValues.delete(componentId); }