fix(studio): losing one member of a group no longer deselects all of it

After a move the preview re-syncs and the selection is re-resolved against the
new document. When the primary could not be found there, both re-resolve paths
cleared the entire selection — so a group of five, all still on screen, was
deselected because one of them failed to resolve. The trace showed the clear
landing 600ms after the drop with five members still held, and the timeline sync
running afterwards on an already-empty canvas, which ruled it out as the cause.

A live group now re-resolves as a group and keeps whoever survived, picking a new
primary from them; it only clears when nobody did. That is what
refreshDomEditGroupSelectionsFromPreview was written for — it existed and was
never called.

Both clears also say which one they are and how many members were held, so if
this is not the last of it the next trace names the path immediately.
This commit is contained in:
Miguel Angel Simon Sierra
2026-08-09 19:10:54 -04:00
parent e6cbb229e9
commit 23dcdd88dc
4 changed files with 52 additions and 0 deletions
@@ -8,13 +8,17 @@ import { findElementForSelection, type DomEditSelection } from "../components/ed
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
import type { PatchTarget } from "../utils/sourcePatcher";
import { logSelect } from "../utils/selectDebug";
interface UseDomEditPreviewSyncParams {
previewIframe: HTMLIFrameElement | null;
activeCompPath: string | null;
captionEditMode: boolean;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
domEditSelection: DomEditSelection | null;
/** Re-resolves a whole multi-selection against the current preview document. */
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; preserveGroup?: boolean },
@@ -35,8 +39,10 @@ export function useDomEditPreviewSync({
activeCompPath,
captionEditMode,
domEditSelectionRef,
domEditGroupSelectionsRef,
domEditSelection,
applyDomSelection,
refreshDomEditGroupSelectionsFromPreview,
buildDomSelectionFromTarget,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
@@ -72,6 +78,21 @@ export function useDomEditPreviewSync({
// Clear so overlay geometry isn't computed on a stale, detached node.
// (Drag-release-in-gray-zone is handled separately by
// suppressNextBoxClickRef; the dragged element still resolves here.)
//
// One lost member is not the whole selection though. A multi-select that
// loses its primary here used to be wiped entirely, so moving a group and
// having any one of its elements fail to re-resolve deselected all of
// them. Re-resolve the group instead and keep whoever survived; it only
// clears when nobody did.
const group = domEditGroupSelectionsRef.current;
logSelect("preview-sync-lost", {
target: currentSelection.selector ?? currentSelection.id ?? null,
group: group.length,
});
if (group.length > 1) {
await refreshDomEditGroupSelectionsFromPreview(group);
return;
}
applyDomSelection(null, { revealPanel: false });
return;
}
@@ -103,8 +124,10 @@ export function useDomEditPreviewSync({
applyDomSelection,
buildDomSelectionFromTarget,
captionEditMode,
domEditGroupSelectionsRef,
domEditSelectionRef,
previewIframe,
refreshDomEditGroupSelectionsFromPreview,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
@@ -129,6 +129,7 @@ export function useDomEditSession({
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
applyMarqueeSelection,
} = useDomSelection({
projectId,
@@ -385,6 +386,8 @@ export function useDomEditSession({
activeCompPath,
domEditSelection,
domEditSelectionRef,
domEditGroupSelectionsRef,
refreshDomEditGroupSelectionsFromPreview,
previewIframeRef,
previewIframe,
captionEditMode,
@@ -23,6 +23,8 @@ export interface UseDomEditWiringParams {
activeCompPath: string | null;
domEditSelection: DomEditSelection | null;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
previewIframe: HTMLIFrameElement | null;
captionEditMode: boolean;
@@ -115,6 +117,8 @@ export function useDomEditWiring({
activeCompPath,
domEditSelection,
domEditSelectionRef,
domEditGroupSelectionsRef,
refreshDomEditGroupSelectionsFromPreview,
previewIframeRef,
previewIframe,
captionEditMode,
@@ -254,8 +258,10 @@ export function useDomEditWiring({
activeCompPath,
captionEditMode,
domEditSelectionRef,
domEditGroupSelectionsRef,
domEditSelection,
applyDomSelection,
refreshDomEditGroupSelectionsFromPreview,
buildDomSelectionFromTarget,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
@@ -427,6 +427,13 @@ export function useDomSelection({
[applyDomSelection, buildDomSelectionForTimelineElement],
);
// Forward handle to the group refresher defined below: the single-selection
// refresher falls back to it when the primary is gone, and a ref keeps that from
// forcing either callback to be declared in the other's dependency list.
const refreshDomEditGroupSelectionsFromPreviewRef = useRef<
(selections: DomEditSelection[]) => Promise<void>
>(async () => {});
const refreshDomEditSelectionFromPreview = useCallback(
// fallow-ignore-next-line complexity
async (selection: DomEditSelection) => {
@@ -441,6 +448,17 @@ export function useDomSelection({
const element = findElementForSelection(doc, selection, activeCompPath);
if (!element) {
// Losing the primary is not losing the selection. When a group is live,
// re-resolve it and keep whoever still exists rather than wiping the lot.
const group = domEditGroupSelectionsRef.current;
logSelect("refresh-lost", {
target: selection.selector ?? selection.id ?? null,
group: group.length,
});
if (group.length > 1) {
await refreshDomEditGroupSelectionsFromPreviewRef.current(group);
return;
}
applyDomSelection(null, { revealPanel: false });
return;
}
@@ -493,6 +511,8 @@ export function useDomSelection({
[activeCompPath, announceTimelineSelection, buildDomSelectionFromTarget, previewIframeRef],
);
refreshDomEditGroupSelectionsFromPreviewRef.current = refreshDomEditGroupSelectionsFromPreview;
// ── Effects ──
// Clear hover unconditionally on composition/project/preview change