Affected versions
@perspective-dev/viewer 5.2.0 (root-caused below in its shipped src/rust).
- 5.3.0 restructures this path and appears to fix it (details at the end) — please confirm; a regression test would lock it in.
What happens
viewer.toggleConfig(force) with an explicit force argument does not behave like the argument-less toggle:
- An argument-less
toggleConfig() always dispatches perspective-toggle-settings.
- A forced call whose
force matches the current state ("no-op", e.g. toggleConfig(true) when already open) dispatches no perspective-toggle-settings — but it is not actually a no-op: it flips the internal is_settings_open state and the host's settings attribute, and fires perspective-toggle-settings-before with the flipped (wrong) value.
- Because the internal state is now inverted, the next forced call with a real change reads the corrupted state, is misjudged as a no-op, and is silently dropped: the panel doesn't move and no event fires.
Any wrapper tracking settings-panel state via events desyncs on the first idempotent forced toggle.
Root cause
All 5.2.0 sources, paths relative to the published @perspective-dev/viewer package.
toggleConfig (src/rust/custom_elements/viewer.rs:1765-1777) funnels into the same message as the toolbar:
let force = force.map(SettingsUpdate::Update);
...
PerspectiveViewerMsg::ToggleSettingsInit(force, Some(sender)),
The handler, init_toggle_settings_task (src/rust/components/viewer/settings.rs:149-163), mutates state before deciding whether the call is a no-op:
let is_open = ctx.props().presentation.is_settings_open();
ctx.props().presentation.set_settings_before_open(!is_open); // unconditional!
match force {
Some(force) if is_open == force => {
if let Some(sender) = sender {
sender.send(Ok(JsValue::UNDEFINED)).unwrap(); // early return: no event
}
},
Some(_) | None => { ... presentation.set_settings_open(!is_open) ... }
The asymmetry:
-
set_settings_before_open (src/rust/presentation.rs:233-239) is — misleadingly, given its name — the mutator of the is_settings_open source of truth, and it also sets the host settings attribute and fires the "before" event:
pub fn set_settings_before_open(&self, open: bool) {
if *self.is_settings_open.borrow() != open {
*self.is_settings_open.borrow_mut() = open;
self.set_settings_attribute(open);
self.settings_before_open_changed.emit(open);
}
}
Since it is always called with !is_open, it always flips — even when the match then takes the no-op arm.
-
Only set_settings_open (src/rust/presentation.rs:241-243) leads to the public event: it emits settings_open_changed, whose listener (src/rust/custom_events.rs:219-227) does dispatch_event(&elem, "toggle-settings", open), and dispatch_event (src/rust/custom_events.rs:34-39) prefixes the name — perspective-toggle-settings. The argument-less path always reaches it; the forced no-op arm never does.
So a forced no-op emits perspective-toggle-settings-before with an inverted payload, emits no perspective-toggle-settings, and leaves is_settings_open() inverted relative to the actual panel.
Reproduction
// panel currently OPEN
viewer.addEventListener("perspective-toggle-settings", (e) => console.log(e.detail));
await viewer.toggleConfig(true); // idempotent force-open: NO event, but the host
// `settings` attribute and internal flag flip to "closed",
// and perspective-toggle-settings-before fires with `false`
await viewer.toggleConfig(false); // real close request: internal state reads "closed",
// judged a no-op — the panel STAYS OPEN, no event
The same corruption is reachable without calling toggleConfig directly: restoreWorkspace with a layout whose sidebar is closed, applied to an already-closed element, drives the same forced no-op path.
Affected versions
@perspective-dev/viewer5.2.0 (root-caused below in its shippedsrc/rust).What happens
viewer.toggleConfig(force)with an explicitforceargument does not behave like the argument-less toggle:toggleConfig()always dispatchesperspective-toggle-settings.forcematches the current state ("no-op", e.g.toggleConfig(true)when already open) dispatches noperspective-toggle-settings— but it is not actually a no-op: it flips the internalis_settings_openstate and the host'ssettingsattribute, and firesperspective-toggle-settings-beforewith the flipped (wrong) value.Any wrapper tracking settings-panel state via events desyncs on the first idempotent forced toggle.
Root cause
All 5.2.0 sources, paths relative to the published
@perspective-dev/viewerpackage.toggleConfig(src/rust/custom_elements/viewer.rs:1765-1777) funnels into the same message as the toolbar:The handler,
init_toggle_settings_task(src/rust/components/viewer/settings.rs:149-163), mutates state before deciding whether the call is a no-op:The asymmetry:
set_settings_before_open(src/rust/presentation.rs:233-239) is — misleadingly, given its name — the mutator of theis_settings_opensource of truth, and it also sets the hostsettingsattribute and fires the "before" event:Since it is always called with
!is_open, it always flips — even when the match then takes the no-op arm.Only
set_settings_open(src/rust/presentation.rs:241-243) leads to the public event: it emitssettings_open_changed, whose listener (src/rust/custom_events.rs:219-227) doesdispatch_event(&elem, "toggle-settings", open), anddispatch_event(src/rust/custom_events.rs:34-39) prefixes the name —perspective-toggle-settings. The argument-less path always reaches it; the forced no-op arm never does.So a forced no-op emits
perspective-toggle-settings-beforewith an inverted payload, emits noperspective-toggle-settings, and leavesis_settings_open()inverted relative to the actual panel.Reproduction
The same corruption is reachable without calling
toggleConfigdirectly:restoreWorkspacewith a layout whose sidebar is closed, applied to an already-closed element, drives the same forced no-op path.