Skip to content

toggleConfig(force) no-op path flips settings state without emitting perspective-toggle-settings #3223

Description

@timkpaine

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:

  1. 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.

  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions