diff --git a/CHANGELOG.md b/CHANGELOG.md index 679a6202..3c999061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다. - [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다. - [BE] 🛠️ **함수 인덱스 중복 오탐 수정**: `lower(email)` 등 expression index를 평문 컬럼 인덱스의 중복으로 잘못 판단하지 않도록 괄호 파서를 강화했습니다. - [Docs] README를 상용 기준 기능 설명으로 갱신 (MVP skeleton 표현 제거, share redaction·diff/export 반영). diff --git a/docs/doctoring/search-identity-and-sequential-polling.md b/docs/doctoring/search-identity-and-sequential-polling.md new file mode 100644 index 00000000..0e0ee190 --- /dev/null +++ b/docs/doctoring/search-identity-and-sequential-polling.md @@ -0,0 +1,39 @@ +# Search identity and sequential snapshot polling + +## Decision + +The ERD canvas treats the immutable `TableNodeData` object as the identity of the source table payload. While a normalized search query is active, a query-scoped `WeakMap` stores the derived highlight/dim payload. Re-rendering with the same source object reuses the exact derived reference; changing the normalized query replaces the cache, and replacing the source payload produces a new derived object. + +Snapshot status polling is one sequential asynchronous process per active `(selectedProjectId, snapshotId)` effect. The first request starts immediately. A non-terminal response schedules one `setTimeout` only after the request completes. Terminal states stop polling and may refresh the snapshot list. Cleanup marks the process obsolete and clears the pending timeout, so late success, refresh, or rejection continuations cannot update the current view. + +## Why + +React Flow can emit frequent position-only node updates. Reallocating every derived `node.data` object during those updates defeats reference-sensitive memoization below the canvas and creates garbage unrelated to actual table-content changes. `WeakMap` keys use object identity and do not keep otherwise unreachable key objects alive, which fits a cache whose lifetime follows the source payload. + +A fixed `setInterval` can start another request while the prior request is unresolved. Network responses are not guaranteed to complete in issue order, so an older non-terminal result can overwrite a newer terminal result. React's effect guidance explicitly recommends cleanup-scoped invalidation for manually fetched data because responses may arrive out of order. Completion-scheduled `setTimeout` polling additionally guarantees at most one in-flight status request per effect generation. + +## Invariants + +- The source `node.data` object is never mutated with search-only state. +- Equivalent normalized queries and position-only updates reuse the derived data reference. +- A changed query or changed source data object yields a fresh derived reference. +- At most one `getSnapshot` call is in flight for one effect generation. +- `succeeded`, `failed`, and `not_found` stop future status requests. +- Dependency change or unmount invalidates all pending continuations before they can publish snapshot, list, or error state. +- Polling errors remain visible only for the still-current snapshot process. + +## Verification + +`frontend/src/App.searchPolling.test.tsx` observes the `ReactFlow` node payload rather than implementation internals. It asserts reference identity with `toBe`, drives a position-only update and a source-data replacement, exercises reversed response order, rejects a superseded request with sensitive detail, and uses controlled timers to prove non-overlap and terminal shutdown. Repository CI remains authoritative for npm-only type checking, complete statement/branch/function/line coverage, and the production build. + +## Operational monitoring and rollback + +Monitor snapshot-status request concurrency, terminal-to-render latency, stale-response suppression, browser memory growth during long search/drag sessions, and frontend error rates. Roll back by restoring the prior uncached derivation and interval loop only if a verified regression requires it; doing so reopens the documented allocation and race risks and therefore requires a replacement isolation design and regression evidence. + +## References + +MDN Web Docs contributors. (2026). *WeakMap*. Mozilla. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap + +React Team. (n.d.). *useEffect*. React. Retrieved August 7, 2026, from https://react.dev/reference/react/useEffect + +Web Hypertext Application Technology Working Group. (2026, July 13). *HTML Standard: Timers*. https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers diff --git a/frontend/src/App.searchPolling.test.tsx b/frontend/src/App.searchPolling.test.tsx new file mode 100644 index 00000000..6c48313f --- /dev/null +++ b/frontend/src/App.searchPolling.test.tsx @@ -0,0 +1,290 @@ +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ + getMe: vi.fn(), + listProjects: vi.fn(), + listConnections: vi.fn(), + listSnapshots: vi.fn(), + createProject: vi.fn(), + createConnection: vi.fn(), + createSnapshot: vi.fn(), + getSnapshot: vi.fn(), + createShareLink: vi.fn(), +})) + +type CapturedNode = { + id: string + position: { x: number; y: number } + data: Record +} + +const flowCapture = vi.hoisted(() => ({ + renders: [] as CapturedNode[][], + setNodes: undefined as + | ((update: CapturedNode[] | ((current: CapturedNode[]) => CapturedNode[])) => void) + | undefined, +})) + +vi.mock('./api', () => api) +vi.mock('./erd/TableNode', () => ({ default: () => null })) +vi.mock('./erd/export', () => ({ + downloadText: vi.fn(), + exportDDL: vi.fn(() => ''), + exportDiagramSvg: vi.fn(() => ''), + exportDictionaryCsv: vi.fn(() => ''), + exportDictionaryMarkdown: vi.fn(() => ''), + exportPlantUml: vi.fn(() => ''), +})) +vi.mock('./erd/mermaid', () => ({ exportMermaid: vi.fn(() => '') })) +vi.mock('./erd/dbml', () => ({ exportDbml: vi.fn(() => '') })) +vi.mock('./erd/prisma', () => ({ exportPrisma: vi.fn(() => '') })) +vi.mock('./erd/autoInfer', () => ({ inferRelationships: vi.fn(() => []) })) +vi.mock('./components/modals', () => ({ + AddTableModal: () => null, + CardinalityModal: () => null, + EditEdgeModal: () => null, + EditTableModal: () => null, + ExportModal: () => null, + GroupModal: () => null, +})) + +vi.mock('@xyflow/react', async () => { + const React = await import('react') + return { + Background: () => null, + Controls: () => null, + MiniMap: () => null, + ReactFlow: (props: { nodes: CapturedNode[]; children?: React.ReactNode }) => { + flowCapture.renders.push(props.nodes) + return ( +
+ {props.nodes.map((node) => {String(node.data.title)})} + {props.children} +
+ ) + }, + addEdge: (edge: unknown, edges: unknown[]) => [...edges, edge], + useNodesState: (initial: CapturedNode[]) => { + const [nodes, setNodes] = React.useState(initial) + flowCapture.setNodes = setNodes as typeof flowCapture.setNodes + return [nodes, setNodes, vi.fn()] + }, + useEdgesState: (initial: unknown[]) => { + const [edges, setEdges] = React.useState(initial) + return [edges, setEdges, vi.fn()] + }, + } +}) + +const graphData = vi.hoisted(() => ({ + firstUsers: { + title: 'public.users', + columns: [{ column_name: 'id', data_type: 'bigint', is_not_null: true, is_pk: true }], + badges: { pk: true, fk: false }, + }, + firstOrders: { + title: 'public.orders', + columns: [{ column_name: 'user_id', data_type: 'bigint', is_not_null: true, is_pk: false }], + badges: { pk: false, fk: true }, + }, + secondAccounts: { + title: 'public.accounts', + columns: [{ column_name: 'account_id', data_type: 'bigint', is_not_null: true, is_pk: true }], + badges: { pk: true, fk: false }, + }, +})) + +vi.mock('./erd/convert', () => ({ + snapshotToGraph: vi.fn((snapshotJson: { marker?: string }) => snapshotJson.marker === 'second' + ? { nodes: [{ id: 'accounts', type: 'tableNode', position: { x: 0, y: 0 }, data: graphData.secondAccounts }], edges: [] } + : { + nodes: [ + { id: 'users', type: 'tableNode', position: { x: 0, y: 0 }, data: graphData.firstUsers }, + { id: 'orders', type: 'tableNode', position: { x: 200, y: 0 }, data: graphData.firstOrders }, + ], + edges: [], + }), +})) + +import App from './App' + +const projects = [{ project_space_uuid: 'project-one', project_name: 'Project One' }] +const snapshots = [ + { schema_snapshot_uuid: 'snapshot-one', status: 'running', schema_filter: null }, + { schema_snapshot_uuid: 'snapshot-two', status: 'succeeded', schema_filter: null }, +] + +function detail(id: string, marker: string, status = 'succeeded') { + return { + schema_snapshot_uuid: id, + status, + schema_filter: null, + error_message: null, + snapshot_json: { marker, relations: [], columns: [], pk_columns: [], fk_edges: [] }, + } +} + +async function renderReadyApp() { + render() + await waitFor(() => expect(api.listSnapshots).toHaveBeenCalledWith('project-one')) +} + +async function diagramOpenButtons() { + fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) + return screen.findAllByRole('button', { name: '열기' }) +} + +async function openSnapshot(index: number) { + const openButtons = await diagramOpenButtons() + fireEvent.click(openButtons[index]!) + await waitFor(() => expect(api.getSnapshot).toHaveBeenCalled()) +} + +function lastNodeData(nodeId: string): Record { + const data = flowCapture.renders.at(-1)?.find((node) => node.id === nodeId)?.data + if (!data) throw new Error(`No rendered data captured for ${nodeId}`) + return data +} + +describe('App search identity and polling isolation', () => { + beforeEach(() => { + vi.clearAllMocks() + flowCapture.renders.length = 0 + flowCapture.setNodes = undefined + api.getMe.mockResolvedValue({ subject: 'user-one', display_name: 'User One' }) + api.listProjects.mockResolvedValue(projects) + api.listConnections.mockResolvedValue([]) + api.listSnapshots.mockResolvedValue(snapshots) + api.createShareLink.mockResolvedValue({ url: 'https://example.test/share' }) + }) + + afterEach(() => { + vi.useRealTimers() + cleanup() + }) + + it('preserves decorated data identity for normalized-query and position-only updates', async () => { + api.getSnapshot.mockResolvedValue(detail('snapshot-one', 'first')) + await renderReadyApp() + await openSnapshot(0) + await screen.findByText('public.users') + + const search = screen.getByLabelText('테이블 또는 컬럼 검색') + fireEvent.change(search, { target: { value: 'users' } }) + await waitFor(() => expect(lastNodeData('users').isHighlighted).toBe(true)) + const firstDecorated = lastNodeData('users') + + fireEvent.change(search, { target: { value: ' users ' } }) + await waitFor(() => expect(search).toHaveValue(' users ')) + expect(lastNodeData('users')).toBe(firstDecorated) + + await act(async () => { + flowCapture.setNodes?.((current) => current.map((node) => ( + node.id === 'users' + ? { ...node, position: { x: node.position.x + 25, y: node.position.y } } + : node + ))) + }) + expect(lastNodeData('users')).toBe(firstDecorated) + + await act(async () => { + flowCapture.setNodes?.((current) => current.map((node) => ( + node.id === 'users' ? { ...node, data: { ...node.data } } : node + ))) + }) + const replacedSourceData = lastNodeData('users') + expect(replacedSourceData).not.toBe(firstDecorated) + + fireEvent.change(search, { target: { value: 'orders' } }) + await waitFor(() => expect(lastNodeData('orders').isHighlighted).toBe(true)) + expect(lastNodeData('users')).not.toBe(replacedSourceData) + expect(graphData.firstUsers).not.toHaveProperty('isHighlighted') + }) + + it('ignores a terminal response from a superseded snapshot request', async () => { + let resolveFirst!: (value: ReturnType) => void + api.getSnapshot.mockImplementation((snapshotId: string) => { + if (snapshotId === 'snapshot-one') { + return new Promise((resolve) => { resolveFirst = resolve }) + } + return Promise.resolve(detail('snapshot-two', 'second')) + }) + + await renderReadyApp() + await openSnapshot(0) + await waitFor(() => expect(api.getSnapshot).toHaveBeenCalledWith('snapshot-one')) + + await openSnapshot(1) + await screen.findByText('public.accounts') + const refreshCountAfterCurrentTerminal = api.listSnapshots.mock.calls.length + + await act(async () => { + resolveFirst(detail('snapshot-one', 'first')) + await Promise.resolve() + }) + + expect(screen.getByText('public.accounts')).toBeInTheDocument() + expect(screen.queryByText('public.users')).not.toBeInTheDocument() + expect(api.listSnapshots).toHaveBeenCalledTimes(refreshCountAfterCurrentTerminal) + }) + + it('does not publish a stale polling rejection after the selected snapshot changes', async () => { + let rejectFirst!: (reason: Error) => void + api.getSnapshot.mockImplementation((snapshotId: string) => { + if (snapshotId === 'snapshot-one') { + return new Promise((_, reject) => { rejectFirst = reject }) + } + return Promise.resolve(detail('snapshot-two', 'second')) + }) + + await renderReadyApp() + await openSnapshot(0) + await openSnapshot(1) + await screen.findByText('public.accounts') + + await act(async () => { + rejectFirst(new Error('stale polling failure with secret detail')) + await Promise.resolve() + }) + + expect(screen.getByText('public.accounts')).toBeInTheDocument() + expect(screen.queryByText(/stale polling failure with secret detail/i)).not.toBeInTheDocument() + }) + + it('waits for each non-terminal request before scheduling the next poll', async () => { + let resolveFirst!: (value: ReturnType) => void + let resolveSecond!: (value: ReturnType) => void + api.getSnapshot + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) + + await renderReadyApp() + const openButtons = await diagramOpenButtons() + vi.useFakeTimers() + + fireEvent.click(openButtons[0]!) + await act(async () => { await Promise.resolve() }) + expect(api.getSnapshot).toHaveBeenCalledTimes(1) + + await act(async () => { await vi.advanceTimersByTimeAsync(2000) }) + expect(api.getSnapshot).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveFirst(detail('snapshot-one', 'first', 'running')) + await Promise.resolve() + }) + await act(async () => { await vi.advanceTimersByTimeAsync(999) }) + expect(api.getSnapshot).toHaveBeenCalledTimes(1) + await act(async () => { await vi.advanceTimersByTimeAsync(1) }) + expect(api.getSnapshot).toHaveBeenCalledTimes(2) + + await act(async () => { + resolveSecond(detail('snapshot-one', 'first')) + await Promise.resolve() + }) + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + expect(api.getSnapshot).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0fa62ede..49812e44 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -198,20 +198,30 @@ export default function App() { const searchMatchedNodeIds = useMemo(() => { return findSearchMatchedNodeIds(nodes, normalizedNodeSearch); }, [nodes, normalizedNodeSearch]); + + // ⚡ Bolt: Cache decorated search state to preserve node.data identity during 60fps drag updates + const searchCache = useMemo(() => new WeakMap(), [normalizedNodeSearch]); + const visibleNodes = useMemo(() => { if (!normalizedNodeSearch) return nodes; + return nodes.map((node) => { - const isHighlighted = searchMatchedNodeIds.has(node.id); - return { - ...node, - data: { + let cachedData = searchCache.get(node.data); + if (!cachedData) { + const isHighlighted = searchMatchedNodeIds.has(node.id); + cachedData = { ...node.data, isDimmed: !isHighlighted, isHighlighted, - }, + }; + searchCache.set(node.data, cachedData); + } + return { + ...node, + data: cachedData, }; }); - }, [nodes, normalizedNodeSearch, searchMatchedNodeIds]); + }, [nodes, normalizedNodeSearch, searchMatchedNodeIds, searchCache]); const nodeSearchStatus = normalizedNodeSearch ? `${searchMatchedNodeIds.size}개 테이블 일치` : ""; @@ -309,22 +319,46 @@ export default function App() { useEffect(() => { if (!snapshotId) return; - const timer = setInterval(() => { - getSnapshot(snapshotId) - .then((s) => { - setSnapshot(s); - if (s.status === "succeeded" || s.status === "failed" || s.status === "not_found") { - clearInterval(timer); - if (selectedProjectId) { - listSnapshots(selectedProjectId) - .then(setSnapshots) - .catch((e) => setError(String(e))); + let isCurrent = true; + let timer: number | null = null; + + async function poll() { + try { + const s = await getSnapshot(snapshotId as string); + if (!isCurrent) return; + setSnapshot(s); + + if (s.status === "succeeded" || s.status === "failed" || s.status === "not_found") { + if (selectedProjectId) { + try { + const snaps = await listSnapshots(selectedProjectId); + if (isCurrent) setSnapshots(snaps); + } catch (e) { + if (isCurrent) setError(String(e)); } } - }) - .catch((e) => setError(String(e))); - }, 1000); - return () => clearInterval(timer); + return; + } + + if (isCurrent) { + timer = window.setTimeout(poll, 1000); + } + } catch (e) { + if (isCurrent) { + setError(String(e)); + timer = window.setTimeout(poll, 1000); + } + } + } + + poll(); + + return () => { + isCurrent = false; + if (timer !== null) { + clearTimeout(timer); + } + }; }, [selectedProjectId, snapshotId]); const graph = useMemo(() => {