From 52a874df988d9ede4b8df668ee6a8f501d889794 Mon Sep 17 00:00:00 2001 From: yyh Date: Wed, 21 Jan 2026 15:42:38 +0800 Subject: [PATCH] add tanstack react query and migrate page selector --- .../page-selector/index.tsx | 240 +++++++++++------- web/eslint-suppressions.json | 10 - web/package.json | 1 + web/pnpm-lock.yaml | 19 +- 4 files changed, 154 insertions(+), 116 deletions(-) diff --git a/web/app/components/base/notion-page-selector/page-selector/index.tsx b/web/app/components/base/notion-page-selector/page-selector/index.tsx index 311cdc689c..b53a66270f 100644 --- a/web/app/components/base/notion-page-selector/page-selector/index.tsx +++ b/web/app/components/base/notion-page-selector/page-selector/index.tsx @@ -1,9 +1,8 @@ -import type { ListChildComponentProps } from 'react-window' import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' -import { memo, useEffect, useMemo, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { memo, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { areEqual, FixedSizeList as List } from 'react-window' import { cn } from '@/utils/classnames' import Checkbox from '../../checkbox' import NotionIcon from '../../notion-icon' @@ -32,6 +31,23 @@ type NotionPageItem = { depth: number } & DataSourceNotionPage +type ItemProps = { + index: number + virtualStart: number + virtualSize: number + current: NotionPageItem + handleToggle: (index: number) => void + checkedIds: Set + disabledCheckedIds: Set + handleCheck: (index: number) => void + canPreview?: boolean + handlePreview: (index: number) => void + listMapWithChildrenAndDescendants: NotionPageTreeMap + searchValue: string + previewPageId: string + pagesMap: DataSourceNotionPageMap +} + const recursivePushInParentDescendants = ( pagesMap: DataSourceNotionPageMap, listTreeMap: NotionPageTreeMap, @@ -69,34 +85,23 @@ const recursivePushInParentDescendants = ( } } -const ItemComponent = ({ index, style, data }: ListChildComponentProps<{ - dataList: NotionPageItem[] - handleToggle: (index: number) => void - checkedIds: Set - disabledCheckedIds: Set - handleCheck: (index: number) => void - canPreview?: boolean - handlePreview: (index: number) => void - listMapWithChildrenAndDescendants: NotionPageTreeMap - searchValue: string - previewPageId: string - pagesMap: DataSourceNotionPageMap -}>) => { +const ItemComponent = ({ + index, + virtualStart, + virtualSize, + current, + handleToggle, + checkedIds, + disabledCheckedIds, + handleCheck, + canPreview, + handlePreview, + listMapWithChildrenAndDescendants, + searchValue, + previewPageId, + pagesMap, +}: ItemProps) => { const { t } = useTranslation() - const { - dataList, - handleToggle, - checkedIds, - disabledCheckedIds, - handleCheck, - canPreview, - handlePreview, - listMapWithChildrenAndDescendants, - searchValue, - previewPageId, - pagesMap, - } = data - const current = dataList[index] const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id] const hasChild = currentWithChildrenAndDescendants.descendants.size > 0 const ancestors = currentWithChildrenAndDescendants.ancestors @@ -132,7 +137,15 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{ return (
) } -const Item = memo(ItemComponent, areEqual) +const Item = memo(ItemComponent) const PageSelector = ({ value, @@ -193,31 +206,10 @@ const PageSelector = ({ onPreview, }: PageSelectorProps) => { const { t } = useTranslation() - const [dataList, setDataList] = useState([]) + const parentRef = useRef(null) + const [expandedIds, setExpandedIds] = useState>(() => new Set()) const [localPreviewPageId, setLocalPreviewPageId] = useState('') - useEffect(() => { - setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => { - return { - ...item, - expand: false, - depth: 0, - } - })) - }, [list]) - - const searchDataList = list.filter((item) => { - return item.page_name.includes(searchValue) - }).map((item) => { - return { - ...item, - expand: false, - depth: 0, - } - }) - const currentDataList = searchValue ? searchDataList : dataList - const currentPreviewPageId = previewPageId === undefined ? localPreviewPageId : previewPageId - const listMapWithChildrenAndDescendants = useMemo(() => { return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => { const pageId = next.page_id @@ -229,33 +221,72 @@ const PageSelector = ({ }, {}) }, [list, pagesMap]) + // Compute visible data list based on expanded state + const dataList = useMemo(() => { + const result: NotionPageItem[] = [] + + const buildVisibleList = (parentId: string | null, depth: number) => { + const items = parentId === null + ? list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]) + : list.filter(item => item.parent_id === parentId) + + for (const item of items) { + const isExpanded = expandedIds.has(item.page_id) + result.push({ + ...item, + expand: isExpanded, + depth, + }) + if (isExpanded) { + buildVisibleList(item.page_id, depth + 1) + } + } + } + + buildVisibleList(null, 0) + return result + }, [list, pagesMap, expandedIds]) + + const searchDataList = useMemo(() => list.filter((item) => { + return item.page_name.includes(searchValue) + }).map((item) => { + return { + ...item, + expand: false, + depth: 0, + } + }), [list, searchValue]) + + const currentDataList = searchValue ? searchDataList : dataList + const currentPreviewPageId = previewPageId === undefined ? localPreviewPageId : previewPageId + + const virtualizer = useVirtualizer({ + count: currentDataList.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 28, + overscan: 5, + getItemKey: index => currentDataList[index].page_id, + }) + const handleToggle = (index: number) => { const current = dataList[index] const pageId = current.page_id const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId] - const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants) - const childrenIds = Array.from(currentWithChildrenAndDescendants.children) - let newDataList = [] - if (current.expand) { - current.expand = false - - newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id)) - } - else { - current.expand = true - - newDataList = [ - ...dataList.slice(0, index + 1), - ...childrenIds.map(item => ({ - ...pagesMap[item], - expand: false, - depth: listMapWithChildrenAndDescendants[item].depth, - })), - ...dataList.slice(index + 1), - ] - } - setDataList(newDataList) + setExpandedIds((prev) => { + const next = new Set(prev) + if (prev.has(pageId)) { + // Collapse: remove current and all descendants + next.delete(pageId) + for (const descendantId of currentWithChildrenAndDescendants.descendants) + next.delete(descendantId) + } + else { + // Expand: add current + next.add(pageId) + } + return next + }) } const copyValue = new Set(value) @@ -303,29 +334,42 @@ const PageSelector = ({ } return ( - data.dataList[index].page_id} - itemData={{ - dataList: currentDataList, - handleToggle, - checkedIds: value, - disabledCheckedIds: disabledValue, - handleCheck, - canPreview, - handlePreview, - listMapWithChildrenAndDescendants, - searchValue, - previewPageId: currentPreviewPageId, - pagesMap, - }} + style={{ height: 296, width: '100%', overflow: 'auto' }} > - {Item} - +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const current = currentDataList[virtualRow.index] + return ( + + ) + })} +
+
) } diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index e430ea6739..12cc3ec896 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1309,11 +1309,6 @@ "count": 2 } }, - "app/components/base/notion-page-selector/page-selector/index.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 1 - } - }, "app/components/base/pagination/index.tsx": { "unicorn/prefer-number-properties": { "count": 1 @@ -4252,11 +4247,6 @@ "count": 1 } }, - "middleware.ts": { - "node/prefer-global/buffer": { - "count": 1 - } - }, "models/common.ts": { "ts/no-explicit-any": { "count": 3 diff --git a/web/package.json b/web/package.json index 4ebba2ed8e..f224a38351 100644 --- a/web/package.json +++ b/web/package.json @@ -80,6 +80,7 @@ "@tailwindcss/typography": "0.5.19", "@tanstack/react-form": "1.23.7", "@tanstack/react-query": "5.90.5", + "@tanstack/react-virtual": "3.13.18", "abcjs": "6.5.2", "ahooks": "3.9.5", "class-variance-authority": "0.7.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f1fdb091a8..3c4bdd91f5 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: '@tanstack/react-query': specifier: 5.90.5 version: 5.90.5(react@19.2.3) + '@tanstack/react-virtual': + specifier: 3.13.18 + version: 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) abcjs: specifier: 6.5.2 version: 6.5.2 @@ -3467,8 +3470,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-virtual@3.13.13': - resolution: {integrity: sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==} + '@tanstack/react-virtual@3.13.18': + resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -3476,8 +3479,8 @@ packages: '@tanstack/store@0.7.7': resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} - '@tanstack/virtual-core@3.13.13': - resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==} + '@tanstack/virtual-core@3.13.18': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -10092,7 +10095,7 @@ snapshots: '@floating-ui/react': 0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-aria/focus': 3.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-aria/interactions': 3.25.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-virtual': 3.13.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-virtual': 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -11855,15 +11858,15 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) - '@tanstack/react-virtual@3.13.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-virtual@3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/virtual-core': 3.13.13 + '@tanstack/virtual-core': 3.13.18 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) '@tanstack/store@0.7.7': {} - '@tanstack/virtual-core@3.13.13': {} + '@tanstack/virtual-core@3.13.18': {} '@testing-library/dom@10.4.1': dependencies: