Fix optimistic rendering issues on board and table (#2846)

* Fix optimistic rendering issues on board and table

* Remove dead code

* Improve re-renders of Table

* Remove re-renders on board
This commit is contained in:
Charles Bochet
2023-12-05 22:29:27 +01:00
committed by GitHub
parent 976e058328
commit 69f48ea330
28 changed files with 606 additions and 465 deletions

View File

@ -11,7 +11,6 @@ import { RecordBoardInternalEffect } from '@/ui/object/record-board/components/R
import { RecordBoardContextMenu } from '@/ui/object/record-board/context-menu/components/RecordBoardContextMenu';
import { useRecordBoardScopedStates } from '@/ui/object/record-board/hooks/internal/useRecordBoardScopedStates';
import { useSetRecordBoardCardSelectedInternal } from '@/ui/object/record-board/hooks/internal/useSetRecordBoardCardSelectedInternal';
import { useUpdateRecordBoardCardIdsInternal } from '@/ui/object/record-board/hooks/internal/useUpdateRecordBoardCardIdsInternal';
import { RecordBoardScope } from '@/ui/object/record-board/scopes/RecordBoardScope';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
@ -94,21 +93,14 @@ export const RecordBoard = ({
callback: unselectAllActiveCards,
});
const updateBoardCardIds = useUpdateRecordBoardCardIdsInternal({
recordBoardScopeId,
});
const onDragEnd: OnDragEndResponder = useCallback(
async (result) => {
if (!boardColumns) return;
updateBoardCardIds(result);
try {
const draggedEntityId = result.draggableId;
const destinationColumnId = result.destination?.droppableId;
// TODO: abstract
if (
draggedEntityId &&
destinationColumnId &&
@ -123,7 +115,7 @@ export const RecordBoard = ({
logError(e);
}
},
[boardColumns, updatePipelineProgressStageInDB, updateBoardCardIds],
[boardColumns, updatePipelineProgressStageInDB],
);
const sortedBoardColumns = [...boardColumns].sort((a, b) => {

View File

@ -1,24 +1,17 @@
import React, { useState } from 'react';
import React from 'react';
import styled from '@emotion/styled';
import { Draggable, Droppable, DroppableProvided } from '@hello-pangea/dnd';
import { useRecoilValue } from 'recoil';
import { IconDotsVertical } from '@/ui/display/icon';
import { Tag } from '@/ui/display/tag/components/Tag';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { RecordBoardCard } from '@/ui/object/record-board/components/RecordBoardCard';
import { RecordBoardColumnHeader } from '@/ui/object/record-board/components/RecordBoardColumnHeader';
import { BoardCardIdContext } from '@/ui/object/record-board/contexts/BoardCardIdContext';
import { BoardColumnDefinition } from '@/ui/object/record-board/types/BoardColumnDefinition';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { BoardColumnContext } from '../contexts/BoardColumnContext';
import { recordBoardCardIdsByColumnIdFamilyState } from '../states/recordBoardCardIdsByColumnIdFamilyState';
import { recordBoardColumnTotalsFamilySelector } from '../states/selectors/recordBoardColumnTotalsFamilySelector';
import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope';
import { BoardOptions } from '../types/BoardOptions';
import { RecordBoardColumnDropdownMenu } from './RecordBoardColumnDropdownMenu';
const StyledPlaceholder = styled.div`
min-height: 1px;
`;
@ -47,40 +40,6 @@ const StyledColumn = styled.div<{ isFirstColumn: boolean }>`
position: relative;
`;
const StyledHeader = styled.div`
align-items: center;
cursor: pointer;
display: flex;
flex-direction: row;
height: 24px;
justify-content: left;
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledAmount = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledNumChildren = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.rounded};
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
height: 20px;
justify-content: center;
line-height: ${({ theme }) => theme.text.lineHeight.lg};
margin-left: auto;
width: 16px;
`;
const StyledHeaderActions = styled.div`
display: flex;
margin-left: auto;
`;
type BoardColumnCardsContainerProps = {
children: React.ReactNode;
droppableProvided: DroppableProvided;
@ -119,30 +78,6 @@ export const RecordBoardColumn = ({
onDelete,
onTitleEdit,
}: RecordBoardColumnProps) => {
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const handleBoardColumnMenuOpen = () => {
setIsBoardColumnMenuOpen(true);
setHotkeyScopeAndMemorizePreviousScope(BoardColumnHotkeyScope.BoardColumn, {
goto: false,
});
};
const handleBoardColumnMenuClose = () => {
goBackToPreviousHotkeyScope();
setIsBoardColumnMenuOpen(false);
};
const boardColumnTotal = useRecoilValue(
recordBoardColumnTotalsFamilySelector(recordBoardColumnId),
);
const cardIds = useRecoilValue(
recordBoardCardIdsByColumnIdFamilyState(recordBoardColumnId),
);
@ -165,53 +100,12 @@ export const RecordBoardColumn = ({
<Droppable droppableId={recordBoardColumnId}>
{(droppableProvided) => (
<StyledColumn isFirstColumn={isFirstColumn}>
<StyledHeader
onMouseEnter={() => setIsHeaderHovered(true)}
onMouseLeave={() => setIsHeaderHovered(false)}
>
<Tag
onClick={handleBoardColumnMenuOpen}
color={columnDefinition.colorCode ?? 'gray'}
text={columnDefinition.title}
/>
{!!boardColumnTotal && (
<StyledAmount>${boardColumnTotal}</StyledAmount>
)}
{!isHeaderHovered && (
<StyledNumChildren>{cardIds.length}</StyledNumChildren>
)}
{isHeaderHovered && (
<StyledHeaderActions>
<LightIconButton
accent="tertiary"
Icon={IconDotsVertical}
onClick={handleBoardColumnMenuOpen}
/>
{/* <LightIconButton
accent="tertiary"
Icon={IconPlus}
onClick={() => {}}
/> */}
</StyledHeaderActions>
)}
</StyledHeader>
{isBoardColumnMenuOpen && (
<RecordBoardColumnDropdownMenu
onClose={handleBoardColumnMenuClose}
onDelete={onDelete}
onTitleEdit={handleTitleEdit}
stageId={recordBoardColumnId}
/>
)}
{isBoardColumnMenuOpen && (
<RecordBoardColumnDropdownMenu
onClose={handleBoardColumnMenuClose}
onDelete={onDelete}
onTitleEdit={handleTitleEdit}
stageId={recordBoardColumnId}
/>
)}
<RecordBoardColumnHeader
recordBoardColumnId={recordBoardColumnId}
columnDefinition={columnDefinition}
onDelete={onDelete}
onTitleEdit={handleTitleEdit}
/>
<BoardColumnCardsContainer droppableProvided={droppableProvided}>
{cardIds.map((cardId, index) => (
<BoardCardIdContext.Provider value={cardId} key={cardId}>

View File

@ -0,0 +1,136 @@
import React, { useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconDotsVertical } from '@/ui/display/icon';
import { Tag } from '@/ui/display/tag/components/Tag';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { recordBoardColumnTotalsFamilySelector } from '@/ui/object/record-board/states/selectors/recordBoardColumnTotalsFamilySelector';
import { BoardColumnDefinition } from '@/ui/object/record-board/types/BoardColumnDefinition';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { recordBoardCardIdsByColumnIdFamilyState } from '../states/recordBoardCardIdsByColumnIdFamilyState';
import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope';
import { RecordBoardColumnDropdownMenu } from './RecordBoardColumnDropdownMenu';
const StyledHeader = styled.div`
align-items: center;
cursor: pointer;
display: flex;
flex-direction: row;
height: 24px;
justify-content: left;
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledAmount = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledNumChildren = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.rounded};
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
height: 20px;
justify-content: center;
line-height: ${({ theme }) => theme.text.lineHeight.lg};
margin-left: auto;
width: 16px;
`;
const StyledHeaderActions = styled.div`
display: flex;
margin-left: auto;
`;
type RecordBoardColumnHeaderProps = {
recordBoardColumnId: string;
columnDefinition: BoardColumnDefinition;
onDelete?: (columnId: string) => void;
onTitleEdit: (columnId: string, title: string, color: string) => void;
};
export const RecordBoardColumnHeader = ({
recordBoardColumnId,
columnDefinition,
onDelete,
onTitleEdit,
}: RecordBoardColumnHeaderProps) => {
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const handleBoardColumnMenuOpen = () => {
setIsBoardColumnMenuOpen(true);
setHotkeyScopeAndMemorizePreviousScope(BoardColumnHotkeyScope.BoardColumn, {
goto: false,
});
};
const handleBoardColumnMenuClose = () => {
goBackToPreviousHotkeyScope();
setIsBoardColumnMenuOpen(false);
};
const boardColumnTotal = useRecoilValue(
recordBoardColumnTotalsFamilySelector(recordBoardColumnId),
);
const cardIds = useRecoilValue(
recordBoardCardIdsByColumnIdFamilyState(recordBoardColumnId),
);
const handleTitleEdit = (title: string, color: string) => {
onTitleEdit(recordBoardColumnId, title, color);
};
return (
<>
<StyledHeader
onMouseEnter={() => setIsHeaderHovered(true)}
onMouseLeave={() => setIsHeaderHovered(false)}
>
<Tag
onClick={handleBoardColumnMenuOpen}
color={columnDefinition.colorCode ?? 'gray'}
text={columnDefinition.title}
/>
{!!boardColumnTotal && <StyledAmount>${boardColumnTotal}</StyledAmount>}
{!isHeaderHovered && (
<StyledNumChildren>{cardIds.length}</StyledNumChildren>
)}
{isHeaderHovered && (
<StyledHeaderActions>
<LightIconButton
accent="tertiary"
Icon={IconDotsVertical}
onClick={handleBoardColumnMenuOpen}
/>
{/* <LightIconButton
accent="tertiary"
Icon={IconPlus}
onClick={() => {}}
/> */}
</StyledHeaderActions>
)}
</StyledHeader>
{isBoardColumnMenuOpen && (
<RecordBoardColumnDropdownMenu
onClose={handleBoardColumnMenuClose}
onDelete={onDelete}
onTitleEdit={handleTitleEdit}
stageId={recordBoardColumnId}
/>
)}
</>
);
};

View File

@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useObjectRecordBoard } from '@/object-record/hooks/useObjectRecordBoard';
import { useObjectRecordBoard } from '@/object-record/hooks/useObjectRecordBoard.1';
import { useRecordBoardActionBarEntriesInternal } from '@/ui/object/record-board/hooks/internal/useRecordBoardActionBarEntriesInternal';
import { useRecordBoardContextMenuEntriesInternal } from '@/ui/object/record-board/hooks/internal/useRecordBoardContextMenuEntriesInternal';
import { useRecordBoardScopedStates } from '@/ui/object/record-board/hooks/internal/useRecordBoardScopedStates';
@ -18,7 +18,24 @@ export const RecordBoardInternalEffect = ({}) => {
const { setActionBarEntries } = useRecordBoardActionBarEntriesInternal();
const { setContextMenuEntries } = useRecordBoardContextMenuEntriesInternal();
const { fetchMoreOpportunities, fetchMoreCompanies } = useObjectRecordBoard();
const {
savedPipelineStepsState,
savedOpportunitiesState,
savedCompaniesState,
} = useRecordBoardScopedStates();
const { fetchMoreOpportunities, fetchMoreCompanies, opportunities } =
useObjectRecordBoard();
const [savedOpportunities, setSavedOpportunities] = useRecoilState(
savedOpportunitiesState,
);
const savedPipelineSteps = useRecoilValue(savedPipelineStepsState);
const savedCompanies = useRecoilValue(savedCompaniesState);
useEffect(() => {
setSavedOpportunities(opportunities);
}, [opportunities, setSavedOpportunities]);
useEffect(() => {
if (isDefined(fetchMoreOpportunities)) {
@ -32,16 +49,6 @@ export const RecordBoardInternalEffect = ({}) => {
}
}, [fetchMoreCompanies]);
const {
savedPipelineStepsState,
savedOpportunitiesState,
savedCompaniesState,
} = useRecordBoardScopedStates();
const savedPipelineSteps = useRecoilValue(savedPipelineStepsState);
const savedOpportunities = useRecoilValue(savedOpportunitiesState);
const savedCompanies = useRecoilValue(savedCompaniesState);
useEffect(() => {
if (savedOpportunities && savedCompanies) {
setActionBarEntries();

View File

@ -1,32 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { Opportunity } from '@/pipeline/types/Opportunity';
import { useRecordBoardScopedStates } from '@/ui/object/record-board/hooks/internal/useRecordBoardScopedStates';
import { recordBoardCardIdsByColumnIdFamilyState } from '@/ui/object/record-board/states/recordBoardCardIdsByColumnIdFamilyState';
export const useUpdateCompanyBoardCardIdsInternal = () => {
const { boardColumnsState } = useRecordBoardScopedStates();
return useRecoilCallback(
({ snapshot, set }) =>
(pipelineProgresses: Pick<Opportunity, 'pipelineStepId' | 'id'>[]) => {
const boardColumns = snapshot
.getLoadable(boardColumnsState)
.valueOrThrow();
for (const boardColumn of boardColumns) {
const boardCardIds = pipelineProgresses
.filter((pipelineProgressToFilter) => {
return pipelineProgressToFilter.pipelineStepId === boardColumn.id;
})
.map((pipelineProgress) => pipelineProgress.id);
set(
recordBoardCardIdsByColumnIdFamilyState(boardColumn.id),
boardCardIds,
);
}
},
[boardColumnsState],
);
};

View File

@ -1,106 +0,0 @@
import { DropResult } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350
import { useRecoilCallback } from 'recoil';
import { useRecordBoardScopedStates } from '@/ui/object/record-board/hooks/internal/useRecordBoardScopedStates';
import { RecordBoardScopeInternalContext } from '@/ui/object/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { recordBoardCardIdsByColumnIdFamilyState } from '../../states/recordBoardCardIdsByColumnIdFamilyState';
import { BoardColumnDefinition } from '../../types/BoardColumnDefinition';
type useUpdateRecordBoardCardIdsInternalProps = {
recordBoardScopeId?: string;
};
export const useUpdateRecordBoardCardIdsInternal = (
props: useUpdateRecordBoardCardIdsInternalProps,
) => {
const scopeId = useAvailableScopeIdOrThrow(
RecordBoardScopeInternalContext,
props?.recordBoardScopeId,
);
const { boardColumnsState } = useRecordBoardScopedStates({
recordBoardScopeId: scopeId,
});
return useRecoilCallback(
({ snapshot, set }) =>
(result: DropResult) => {
const currentBoardColumns = snapshot
.getLoadable(boardColumnsState)
.valueOrThrow();
const newBoardColumns = [...currentBoardColumns];
const { destination, source } = result;
if (!destination) return;
const sourceColumnIndex = newBoardColumns.findIndex(
(boardColumn: BoardColumnDefinition) =>
boardColumn.id === source.droppableId,
);
const sourceColumn = newBoardColumns[sourceColumnIndex];
const destinationColumnIndex = newBoardColumns.findIndex(
(boardColumn: BoardColumnDefinition) =>
boardColumn.id === destination.droppableId,
);
const destinationColumn = newBoardColumns[destinationColumnIndex];
if (!destinationColumn || !sourceColumn) return;
const sourceCardIds = [
...snapshot
.getLoadable(
recordBoardCardIdsByColumnIdFamilyState(sourceColumn.id),
)
.valueOrThrow(),
];
const destinationCardIds = [
...snapshot
.getLoadable(
recordBoardCardIdsByColumnIdFamilyState(destinationColumn.id),
)
.valueOrThrow(),
];
const destinationIndex =
destination.index >= destinationCardIds.length
? destinationCardIds.length - 1
: destination.index;
if (sourceColumn.id === destinationColumn.id) {
const [deletedCardId] = sourceCardIds.splice(source.index, 1);
sourceCardIds.splice(destinationIndex, 0, deletedCardId);
set(
recordBoardCardIdsByColumnIdFamilyState(sourceColumn.id),
sourceCardIds,
);
} else {
const [removedCardId] = sourceCardIds.splice(source.index, 1);
destinationCardIds.splice(destinationIndex, 0, removedCardId);
set(
recordBoardCardIdsByColumnIdFamilyState(sourceColumn.id),
sourceCardIds,
);
set(
recordBoardCardIdsByColumnIdFamilyState(destinationColumn.id),
destinationCardIds,
);
}
return newBoardColumns;
},
[boardColumnsState],
);
};

View File

@ -1,9 +1,6 @@
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import {
RecordTableRow,
@ -11,33 +8,34 @@ import {
} from '@/ui/object/record-table/components/RecordTableRow';
import { RowIdContext } from '@/ui/object/record-table/contexts/RowIdContext';
import { RowIndexContext } from '@/ui/object/record-table/contexts/RowIndexContext';
import { useRecordTableScopedStates } from '@/ui/object/record-table/hooks/internal/useRecordTableScopedStates';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { isFetchingRecordTableDataState } from '@/ui/object/record-table/states/isFetchingRecordTableDataState';
import { useRecordTable } from '../hooks/useRecordTable';
import { tableRowIdsState } from '../states/tableRowIdsState';
import { tableRowIdsState } from '@/ui/object/record-table/states/tableRowIdsState';
import { getRecordTableScopedStates } from '@/ui/object/record-table/utils/getRecordTableScopedStates';
export const RecordTableBody = () => {
const { ref: lastTableRowRef, inView: lastTableRowIsVisible } = useInView();
const { scopeId } = useRecordTable();
const onLastRowVisible = useRecoilCallback(
({ set }) =>
async (inView: boolean) => {
const { tableLastRowVisibleState } = getRecordTableScopedStates({
recordTableScopeId: scopeId,
});
set(tableLastRowVisibleState, inView);
},
[scopeId],
);
const { ref: lastTableRowRef } = useInView({
onChange: onLastRowVisible,
});
const tableRowIds = useRecoilValue(tableRowIdsState);
const { scopeId: objectNamePlural } = useRecordTable();
const { tableLastRowVisibleState } = useRecordTableScopedStates();
const setTableLastRowVisible = useSetRecoilState(tableLastRowVisibleState);
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
});
const { objectMetadataItem: foundObjectMetadataItem } = useObjectMetadataItem(
{
objectNameSingular,
},
);
const [isFetchingMoreObjects] = useRecoilState(
isFetchingMoreRecordsFamilyState(foundObjectMetadataItem?.namePlural),
isFetchingMoreRecordsFamilyState(scopeId),
);
const isFetchingRecordTableData = useRecoilValue(
@ -45,10 +43,6 @@ export const RecordTableBody = () => {
);
const lastRowId = tableRowIds[tableRowIds.length - 1];
useEffect(() => {
setTableLastRowVisible(lastTableRowIsVisible);
}, [lastTableRowIsVisible, setTableLastRowVisible]);
if (isFetchingRecordTableData) {
return <></>;
}
@ -60,7 +54,11 @@ export const RecordTableBody = () => {
<RowIndexContext.Provider value={rowIndex}>
<RecordTableRow
key={rowId}
ref={rowId === lastRowId ? lastTableRowRef : undefined}
ref={
rowId === lastRowId && rowIndex > 30
? lastTableRowRef
: undefined
}
rowId={rowId}
/>
</RowIndexContext.Provider>

View File

@ -6,10 +6,18 @@ import { useRecordTableScopedStates } from '@/ui/object/record-table/hooks/inter
import { isDefined } from '~/utils/isDefined';
export const RecordTableBodyEffect = () => {
const { fetchMoreRecords: fetchMoreObjects } = useObjectRecordTable();
const {
fetchMoreRecords: fetchMoreObjects,
records,
setRecordTableData,
} = useObjectRecordTable();
const { tableLastRowVisibleState } = useRecordTableScopedStates();
const tableLastRowVisible = useRecoilValue(tableLastRowVisibleState);
useEffect(() => {
setRecordTableData(records);
}, [records, setRecordTableData]);
useEffect(() => {
if (tableLastRowVisible && isDefined(fetchMoreObjects)) {
fetchMoreObjects();

View File

@ -1,6 +1,7 @@
import { useRecoilCallback } from 'recoil';
import { entityFieldsFamilyState } from '@/ui/object/field/states/entityFieldsFamilyState';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isFetchingRecordTableDataState } from '../../states/isFetchingRecordTableDataState';
import { numberOfTableRowsState } from '../../states/numberOfTableRowsState';
@ -29,16 +30,13 @@ export const useSetRecordTableData = ({
set(entityFieldsFamilyState(entity.id), entity);
}
}
const currentRowIds = snapshot.getLoadable(tableRowIdsState).getValue();
const entityIds = newEntityArray.map((entity) => entity.id);
set(tableRowIdsState, (currentRowIds) => {
if (JSON.stringify(currentRowIds) !== JSON.stringify(entityIds)) {
return entityIds;
}
return currentRowIds;
});
if (!isDeeplyEqual(currentRowIds, entityIds)) {
set(tableRowIdsState, entityIds);
}
resetTableRowSelection();

View File

@ -1,19 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { tableRowIdsState } from '../states/tableRowIdsState';
// Used only in company table and people table
// Remove after refactoring
export const useUpsertTableRowId = () =>
useRecoilCallback(
({ set, snapshot }) =>
(rowId: string) => {
const currentRowIds = snapshot
.getLoadable(tableRowIdsState)
.valueOrThrow();
set(tableRowIdsState, Array.from(new Set([rowId, ...currentRowIds])));
},
[],
);