Skip to content

🐛(frontend) Range selection freezes when there are many items in the list#662

Open
PanchoutNathan wants to merge 3 commits intomainfrom
fix/124
Open

🐛(frontend) Range selection freezes when there are many items in the list#662
PanchoutNathan wants to merge 3 commits intomainfrom
fix/124

Conversation

@PanchoutNathan
Copy link
Copy Markdown
Contributor

Purpose

cf #124

Temporary document describing the root cause of the range-selection lag
on large folders and the rationale for moving to a useSyncExternalStore
based selection store with per-id listeners.

Kept as the first commit purely to help reviewers follow the diff. It
will be removed from the branch before merge — it is not meant to land
on main.
Introduce a lightweight external store compatible with
useSyncExternalStore. It exposes:

- a global subscription for consumers that need the full list or count
- per-id subscriptions so a row can bail out automatically when its own
  isSelected boolean has not changed
- setSelectedItems that diffs the previous and next id maps and
  notifies only the listeners whose status actually flipped

No React state, no context fan-out: a marquee tick that flips N rows
notifies exactly N listeners instead of invalidating the whole tree.
Drop selectedItems / selectedItemsMap from GlobalExplorerContext and
move every call-site to the selection store introduced in the previous
commit. Rows of the embedded explorer grid are extracted into a memoed
EmbeddedExplorerGridRow that subscribes to its own id via
useIsItemSelected, and the name/actions/mobile cells are memoed and
read their selection status the same way.

High-level consumers that only need a boolean or a count use
useHasSelection / useSelectionCount so they no longer re-render on
every marquee tick:

- AppExplorerInner uses useHasSelection to toggle the selection bar
- ExplorerDndProvider splits the count consumers into dedicated child
  components (drag overlay and move confirmation modal)
- useTableKeyboardNavigation subscribes imperatively through
  selectionStore.subscribe so the focus effect runs without
  invalidating the host component

EmbeddedExplorer creates its own local store so the move modal and the
SDK picker keep their selection scoped and do not leak into the main
explorer selection.

Fixes #124
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@NathanVss NathanVss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement 🙏

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc was ultra useful for the review. So I think you should make a proper english version that resume our approach on this pattern as a doc inside an architecture folder at the root of the repo under docs/... so we could in the future tell why we implemented this approach.

if (listeners) listeners.forEach((l) => l());
});

this.globalListeners.forEach((l) => l());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should'n' this call be bypassed if there are no changeIds?

Comment on lines -83 to -84
openMoveModal: () => void;
closeMoveModal: () => void;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

damn :)

Comment on lines +221 to +362
const applyShiftRangeSelect = useCallback(
(row: Row<Item>) => {
const rows = table.getRowModel().rows;
const lastSelectedIndex = rows.findIndex(
(r) => r.id === lastSelectedRowRef.current,
);
const currentIndex = rows.findIndex((r) => r.id === row.id);
if (lastSelectedIndex === -1 || currentIndex === -1) {
return;
}

const startIndex = Math.min(lastSelectedIndex, currentIndex);
const endIndex = Math.max(lastSelectedIndex, currentIndex);
const newSelection = [...selectionStore.getSelectedItems()];
for (let i = startIndex; i <= endIndex; i++) {
if (!selectionStore.isSelected(rows[i].original.id)) {
newSelection.push(rows[i].original);
}
}
selectionStore.setSelectedItems(newSelection);
},
[selectionStore, table],
);

const toggleRowSelection = useCallback(
(row: Row<Item>) => {
const wasSelected = selectionStore.isSelected(row.original.id);
selectionStore.setSelectedItems((value) => {
if (value.some((item) => item.id === row.original.id)) {
return value.filter((item) => item.id !== row.original.id);
}
return [...value, row.original];
});
if (!wasSelected) {
lastSelectedRowRef.current = row.id;
}
},
[selectionStore],
);

const replaceRowSelection = useCallback(
(row: Row<Item>) => {
selectionStore.setSelectedItems([row.original]);
lastSelectedRowRef.current = row.id;
props.setRightPanelForcedItem?.(undefined);
},
[selectionStore, props.setRightPanelForcedItem],
);

const openRow = useCallback(
(row: Row<Item>) => {
if (row.original.type === ItemType.FOLDER) {
props.onNavigate({
type: NavigationEventType.ITEM,
item: row.original,
});
} else {
props.onFileClick?.(row.original);
}
},
[props.onNavigate, props.onFileClick],
);

const handleRowClick = useCallback(
(e: React.MouseEvent<HTMLTableRowElement>, row: Row<Item>) => {
if (row.original.upload_state === ItemUploadState.DUPLICATING) {
return;
}

// Because if we use modals or other components, even with a Portal, React triggers events on the original parent.
// So we check that the clicked element is indeed an element of the table.
if (!(e.target as HTMLElement).closest("tr")) {
return;
}

// In SDK mode we want the popup to behave like desktop. For instance we want the simple click to
// trigger selection, not to open a file as it is the case on mobile.
const isMobile = isTablet() && props.displayMode !== "sdk";

if (isMobile || e.detail === 2) {
openRow(row);
return;
}

if (e.detail !== 1 || !canSelect(row.original)) {
return;
}

const metaActive =
props.enableMetaKeySelection &&
(e.metaKey || e.ctrlKey || props.displayMode === "sdk");

if (
props.enableMetaKeySelection &&
e.shiftKey &&
lastSelectedRowRef.current
) {
applyShiftRangeSelect(row);
} else if (metaActive) {
toggleRowSelection(row);
} else {
replaceRowSelection(row);
}
},
[
props,
selectedItemsMap,
moveModal.open,
moveModal.close,
isActionModalOpen,
props.displayMode,
props.enableMetaKeySelection,
canSelect,
openRow,
applyShiftRangeSelect,
toggleRowSelection,
replaceRowSelection,
],
);

const handleRowContextMenu = useCallback(
(e: React.MouseEvent<HTMLTableRowElement>, row: Row<Item>) => {
if (props.displayMode === "sdk") {
return;
}

e.preventDefault();
e.stopPropagation();

if (row.original.upload_state === ItemUploadState.DUPLICATING) {
return;
}

selectionStore.setSelectedItems([row.original]);

contextMenu.open({
position: { x: e.clientX, y: e.clientY },
items: getItemActionMenuItems(row.original),
});
},
[
props.displayMode,
selectionStore,
contextMenu,
getItemActionMenuItems,
],
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great to put that into the row component

const localSelectedItems = useSyncExternalStore(
itemsExplorer.selectionStore.subscribe,
itemsExplorer.selectionStore.getSelectedItems,
itemsExplorer.selectionStore.getSelectedItems,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the third argument needed?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Range selection freezes when there are many items in the list

2 participants