fix(sensor-panel): per-sensor state when switching between same-type sensors

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.
This commit is contained in:
David Montero Crespo 2026-05-16 22:28:52 -03:00
parent f73697c59b
commit a177471ed0
3 changed files with 41 additions and 14 deletions

View File

@ -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<SensorControlPanelProps> = ({
const { t } = useTranslation();
const def = SENSOR_CONTROLS[metadataId];
// Local slider/button state — initialised from config defaults
const [values, setValues] = useState<Record<string, number | boolean>>(
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<Record<string, number | boolean>>(() => {
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

View File

@ -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 (
<SensorControlPanel
key={sensorControlComponentId}
componentId={sensorControlComponentId}
metadataId={sensorControlMetadataId}
sensorName={meta?.name ?? sensorControlMetadataId}

View File

@ -6,9 +6,11 @@
* running simulation without any React/Zustand dependency in the simulation layer.
*/
type SensorUpdateCallback = (values: Record<string, number | boolean>) => void;
type SensorValues = Record<string, number | boolean>;
type SensorUpdateCallback = (values: SensorValues) => void;
const registry = new Map<string, SensorUpdateCallback>();
const lastValues = new Map<string, SensorValues>();
/**
* 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<string, number | boolean>,
): 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);
}