|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * objectui#4618 — `data-table` must not re-render itself to death. |
| 11 | + * |
| 12 | + * The reported crash ("Maximum update depth exceeded", thrown at the |
| 13 | + * `setMeasuredStickyLefts` call in the sticky-offset layout effect) is NOT |
| 14 | + * about sticky offsets. The loop is three hops of prop→state synchronization, |
| 15 | + * all inside `data-table.tsx`: |
| 16 | + * |
| 17 | + * 1. `columns: rawColumns = []` — the destructuring DEFAULT. When the schema |
| 18 | + * carries no `columns` key, this evaluates a FRESH array literal on every |
| 19 | + * render, so `rawColumns` has a new identity each time even though the |
| 20 | + * schema object never changed. |
| 21 | + * 2. `initialColumns = useMemo(() => rawColumns.map(…), [rawColumns])` — an |
| 22 | + * identity-keyed memo over hop 1, so it recomputes to a new array each |
| 23 | + * render. |
| 24 | + * 3. `useEffect(() => setColumns(initialColumns), [initialColumns])` — the |
| 25 | + * prop→state sync. New identity ⇒ new state ⇒ re-render ⇒ hop 1 again. |
| 26 | + * |
| 27 | + * Self-sustaining, because the unstable value is derived INSIDE the component: |
| 28 | + * the table's own re-render regenerates it, so nothing external has to change. |
| 29 | + * React throws from the layout effect keyed on `columns` (the synchronous |
| 30 | + * nested-update path it counts), which is why the stack points at a line that |
| 31 | + * has nothing to do with the cause. |
| 32 | + * |
| 33 | + * Mount alone is safe — `useState(initialColumns)` seeds from the very array |
| 34 | + * the mount-render's effect then re-sets, so the first sync is an Object.is |
| 35 | + * no-op. The loop starts at render #2, i.e. the first time ANY host re-renders |
| 36 | + * the tile. That is why this went unseen: every existing suite renders once. |
| 37 | + * |
| 38 | + * The negative control below is the load-bearing half: with `columns` declared |
| 39 | + * (a stable array off the schema), the same host churn must stay flat — the |
| 40 | + * fix must not have been "stop syncing columns". |
| 41 | + */ |
| 42 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 43 | +import { render, cleanup, act } from '@testing-library/react'; |
| 44 | +import React from 'react'; |
| 45 | +import { ComponentRegistry } from '@object-ui/core'; |
| 46 | +import '../data-table'; |
| 47 | + |
| 48 | +afterEach(cleanup); |
| 49 | + |
| 50 | +const ROWS = [ |
| 51 | + { name: 'INV-1', amount: 100 }, |
| 52 | + { name: 'INV-2', amount: 200 }, |
| 53 | +]; |
| 54 | + |
| 55 | +/** |
| 56 | + * Render `schema` (a STABLE object, declared once) inside a host that can |
| 57 | + * re-render on demand, counting the tile's commits. A host re-render is the |
| 58 | + * most ordinary thing on a dashboard — a filter change, a resize, a parent |
| 59 | + * state update — and must cost the tile a bounded number of commits. |
| 60 | + */ |
| 61 | +function renderUnderHostChurn(schema: Record<string, unknown>) { |
| 62 | + const DataTable = ComponentRegistry.get('data-table') as React.ComponentType<{ schema: unknown }>; |
| 63 | + if (!DataTable) throw new Error('data-table not registered'); |
| 64 | + let commits = 0; |
| 65 | + let bump: (() => void) | null = null; |
| 66 | + const Host = () => { |
| 67 | + const [, setTick] = React.useState(0); |
| 68 | + bump = () => setTick((n) => n + 1); |
| 69 | + return ( |
| 70 | + <React.Profiler id="tile" onRender={() => { commits += 1; }}> |
| 71 | + <DataTable schema={schema} /> |
| 72 | + </React.Profiler> |
| 73 | + ); |
| 74 | + }; |
| 75 | + const utils = render(<Host />); |
| 76 | + return { |
| 77 | + ...utils, |
| 78 | + churn: (times: number) => { |
| 79 | + for (let i = 0; i < times; i += 1) act(() => { bump!(); }); |
| 80 | + }, |
| 81 | + get commits() { return commits; }, |
| 82 | + }; |
| 83 | +} |
| 84 | + |
| 85 | +describe('data-table survives host re-renders (#4618)', () => { |
| 86 | + it('does not exceed the update depth when the schema declares no columns', () => { |
| 87 | + // Pre-fix this threw on the FIRST host re-render, after ~50 nested commits: |
| 88 | + // "Maximum update depth exceeded. This can happen when a component |
| 89 | + // repeatedly calls setState inside componentWillUpdate or |
| 90 | + // componentDidUpdate…" |
| 91 | + const tile = renderUnderHostChurn({ data: ROWS, searchable: false, pagination: false, className: 'border-0' }); |
| 92 | + expect(() => tile.churn(3)).not.toThrow(); |
| 93 | + // Bounded, not merely finite: one commit per host render (plus the mount |
| 94 | + // pair). A regression that re-introduced a per-render state write would |
| 95 | + // still "not throw" while doubling this. |
| 96 | + expect(tile.commits).toBeLessThanOrEqual(6); |
| 97 | + }); |
| 98 | + |
| 99 | + it('keeps syncing declared columns — the negative control stays flat too', () => { |
| 100 | + const tile = renderUnderHostChurn({ |
| 101 | + data: ROWS, |
| 102 | + columns: [{ header: 'Name', accessorKey: 'name' }, { header: 'Amount', accessorKey: 'amount' }], |
| 103 | + searchable: false, |
| 104 | + pagination: false, |
| 105 | + }); |
| 106 | + expect(() => tile.churn(3)).not.toThrow(); |
| 107 | + expect(tile.commits).toBeLessThanOrEqual(6); |
| 108 | + // Declared columns still reach the DOM — the sync was hardened, not removed. |
| 109 | + const headers = Array.from(tile.container.querySelectorAll('th')).map((th) => th.textContent); |
| 110 | + expect(headers.join('|')).toContain('Name'); |
| 111 | + expect(headers.join('|')).toContain('Amount'); |
| 112 | + expect(tile.container.textContent).toContain('INV-1'); |
| 113 | + }); |
| 114 | + |
| 115 | + it('re-syncs when the host actually changes the declared columns', () => { |
| 116 | + const DataTable = ComponentRegistry.get('data-table') as React.ComponentType<{ schema: unknown }>; |
| 117 | + let setCols: ((c: Array<Record<string, unknown>>) => void) | null = null; |
| 118 | + const Host = () => { |
| 119 | + const [cols, set] = React.useState<Array<Record<string, unknown>>>([ |
| 120 | + { header: 'Name', accessorKey: 'name' }, |
| 121 | + ]); |
| 122 | + setCols = set; |
| 123 | + return <DataTable schema={{ data: ROWS, columns: cols, searchable: false, pagination: false }} />; |
| 124 | + }; |
| 125 | + const { container } = render(<Host />); |
| 126 | + expect(container.textContent).toContain('Name'); |
| 127 | + expect(container.textContent).not.toContain('Amount'); |
| 128 | + act(() => { setCols!([{ header: 'Name', accessorKey: 'name' }, { header: 'Amount', accessorKey: 'amount' }]); }); |
| 129 | + expect(container.textContent).toContain('Amount'); |
| 130 | + expect(container.textContent).toContain('200'); |
| 131 | + }); |
| 132 | +}); |
0 commit comments