Skip to content

Resize permanently stops working: pointer-up commit can resurrect an unregistered group into the mounted-groups map #729

Description

@fymemon

Summary

A pointer gesture (or a queued commit) that straddles a group re-registration can re-insert an already-deleted RegisteredGroup object into the module-global mounted-groups map, leaving two map entries under one group id. After that, by-id lookups and by-object lookups resolve to different entries, and the group is permanently broken until a full remount/reload:

  • The separator still reports data-separator="active" on pointerdown, but dragging never moves the panels (drag writes go to the resurrected stale entry; panel styles read the live one via first-id-match).
  • Keyboard resize on the separator is dead as well.
  • Imperative calls (usePanelRef().resize(...)) can silently target the wrong entry, so a commanded resize never renders.

Version: 4.12.2 (latest at time of writing). Reproduced in Chrome on macOS.

Root cause

lib/global/mutable-state/groups.ts keys the map by RegisteredGroup object identity, and updateMountedGroup() sets unconditionally:

export function updateMountedGroup(group: RegisteredGroup, next: State, meta?) {
  const prev = map.get(group);
  map = new Map(map);
  map.set(group, next);   // <-- re-inserts `group` even if it was deleted
  ...
}

Any Panel/Separator registration change re-runs the Group's mount effect, which deleteMutableGroup(oldGroup)s and registers a new RegisteredGroup object. But two dispatch sites hold on to the old object via hit regions captured at pointerdown:

  1. lib/global/utils/completeActivePointerResize.ts (canonical pointer-up):
interactionState.hitRegions.forEach((hitRegion) => {
  const groupState = getMountedGroupState(hitRegion.group.id, true); // resolves NEW entry by id
  updateMountedGroup(hitRegion.group, groupState, {                  // re-inserts OLD object
    isUserInteraction: true
  });
});
  1. The identical missed-pointerup fallback in lib/global/event-handlers/onDocumentPointerMove.ts (event.buttons === 0 branch).

If the group re-registered between pointerdown and this dispatch, hitRegion.group is the deleted object; getMountedGroupState(id, true) finds the new entry's state by id; updateMountedGroup(oldGroup, …) then re-inserts the deleted object. The map now permanently contains [newGroup, oldGroup] with the same id.

Why that permanently kills resizing

  • calculateHitRegions runs for every mounted group; both entries walk the same DOM (the old entry's registered panels/separators reference the same elements), producing hit regions at identical rects. The nearest-region selection (<= comparison) tie-breaks to the later-iterated entry — the stale one.
  • Drag updates therefore write to the stale entry's state, while Panel styles resolve through getMountedGroupState(group.id) — a first-id-match loop — which returns the live entry. Result: data-separator goes active, layout state changes in the stale entry, nothing renders.
  • getRegisteredGroup(id) / getMountedGroupState(id) first-match semantics also mean keyboard resize and the panel imperative API can target either entry depending on insertion order.

Observable fingerprints in the wedged state (how we confirmed duplicates):

  • During an "active" drag, no panel receives pointerEvents: "none"getPanelStyles only applies it when the winning hit region's group === the first id-match, which fails with duplicates.
  • The separator's aria-valuemin/max stay frozen at constraints converted against a stale groupSize (in our app: px→% conversions matching a group width from an earlier epoch, hundreds of px off the live width).
  • A rendered panel size below its current minSize (an imperative resize() was swallowed by the dying entry).

Reproduction conditions

Our app hits this with a resizable side panel that:

  • conditionally mounts the Separator ({isOpen && <Separator/>}), and
  • swaps the Panel's minSize/maxSize props per content type (each swap re-registers the Panel → re-registers the group), and
  • hosts heavy content (multiple TradingView iframes) that slows React commits, widening the race window.

Rapidly toggling open/closed and switching content types while dragging wedges it within a few seconds of interaction (a scripted stress loop reproduces it in 1–3 rounds). I was not able to reduce it to a single-interleaving minimal sandbox — the window is a queued commit landing between pointerdown and the final dispatch — but the duplicate-entry state is unambiguous once entered (fingerprints above), and I'm happy to help build a repro if useful.

Suggested fix

Skip hit regions whose group registration has been replaced — the final commit belongs to a dead epoch and the live entry already owns the current state. In both dispatch sites:

interactionState.hitRegions.forEach((hitRegion) => {
  if (!getMountedGroups().has(hitRegion.group)) {
    return; // group re-registered mid-gesture; don't resurrect the old entry
  }
  const groupState = getMountedGroupState(hitRegion.group.id, true);
  updateMountedGroup(hitRegion.group, groupState, { isUserInteraction: true });
});

We've been running exactly this as a patch-package patch against the 4.12.2 dist in our app: the stress harness that previously wedged the handle within ≤3 rounds now runs 16+ rounds clean, with no observed regressions in drag, keyboard resize, collapse, or onLayoutChanged (isUserInteraction still fires for normally-completed gestures).

A belt-and-suspenders alternative (or addition): make updateMountedGroup itself refuse to insert unknown groups (if (!map.has(group)) return;) — but that would need care around initial registration, which currently uses updateMountedGroup for the first insert.

Thanks for the library — happy to open a PR for the two-site guard if that's helpful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions