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:
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
});
});
- 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.
Summary
A pointer gesture (or a queued commit) that straddles a group re-registration can re-insert an already-deleted
RegisteredGroupobject 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: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).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.tskeys the map byRegisteredGroupobject identity, andupdateMountedGroup()sets unconditionally:Any Panel/Separator registration change re-runs the Group's mount effect, which
deleteMutableGroup(oldGroup)s and registers a newRegisteredGroupobject. But two dispatch sites hold on to the old object via hit regions captured at pointerdown:lib/global/utils/completeActivePointerResize.ts(canonical pointer-up):lib/global/event-handlers/onDocumentPointerMove.ts(event.buttons === 0branch).If the group re-registered between pointerdown and this dispatch,
hitRegion.groupis 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
calculateHitRegionsruns 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.getMountedGroupState(group.id)— a first-id-match loop — which returns the live entry. Result:data-separatorgoes 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):
pointerEvents: "none"—getPanelStylesonly applies it when the winning hit region's group===the first id-match, which fails with duplicates.aria-valuemin/maxstay frozen at constraints converted against a stalegroupSize(in our app: px→% conversions matching a group width from an earlier epoch, hundreds of px off the live width).minSize(an imperativeresize()was swallowed by the dying entry).Reproduction conditions
Our app hits this with a resizable side panel that:
Separator({isOpen && <Separator/>}), andminSize/maxSizeprops per content type (each swap re-registers the Panel → re-registers the group), andRapidly 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:
We've been running exactly this as a
patch-packagepatch 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, oronLayoutChanged(isUserInteractionstill fires for normally-completed gestures).A belt-and-suspenders alternative (or addition): make
updateMountedGroupitself refuse to insert unknown groups (if (!map.has(group)) return;) — but that would need care around initial registration, which currently usesupdateMountedGroupfor the first insert.Thanks for the library — happy to open a PR for the two-site guard if that's helpful.