Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/plain-tables-stop-looping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@object-ui/components": patch
"@object-ui/plugin-dashboard": patch
---

fix(components,plugin-dashboard): a static-data `table` widget renders instead of crashing

A dashboard widget authored as `{ type: 'table', options: { data: [ … ] } }` fell into the
error boundary with "Maximum update depth exceeded" the moment its tile re-rendered, while
every chart family on the identical static surface rendered clean.

- `data-table` no longer re-renders itself to death. Its `columns` / `data` fallbacks are
module-scope empties instead of per-render array literals, and the prop→state column sync
re-seeds on a value change rather than on a new identity — so a consumer that derives its
columns each render (which both dashboard surfaces do) costs the table nothing.
- Both dashboard surfaces now give the static table the `columns` key `DataTableSchema`
requires, derived from the rows when the author declared none — the same derivation the
`provider: 'object'` half of the widget family already performed. Previously such a table
drew one empty row per record: no headers, no cells.
- `DashboardGridLayout` reads an authored `options.data` ARRAY for its static table, which
its `widgetData?.items` expression resolved to `[]`. `DashboardRenderer` had the arm all
along.
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#4618 — `data-table` must not re-render itself to death.
*
* The reported crash ("Maximum update depth exceeded", thrown at the
* `setMeasuredStickyLefts` call in the sticky-offset layout effect) is NOT
* about sticky offsets. The loop is three hops of prop→state synchronization,
* all inside `data-table.tsx`:
*
* 1. `columns: rawColumns = []` — the destructuring DEFAULT. When the schema
* carries no `columns` key, this evaluates a FRESH array literal on every
* render, so `rawColumns` has a new identity each time even though the
* schema object never changed.
* 2. `initialColumns = useMemo(() => rawColumns.map(…), [rawColumns])` — an
* identity-keyed memo over hop 1, so it recomputes to a new array each
* render.
* 3. `useEffect(() => setColumns(initialColumns), [initialColumns])` — the
* prop→state sync. New identity ⇒ new state ⇒ re-render ⇒ hop 1 again.
*
* Self-sustaining, because the unstable value is derived INSIDE the component:
* the table's own re-render regenerates it, so nothing external has to change.
* React throws from the layout effect keyed on `columns` (the synchronous
* nested-update path it counts), which is why the stack points at a line that
* has nothing to do with the cause.
*
* Mount alone is safe — `useState(initialColumns)` seeds from the very array
* the mount-render's effect then re-sets, so the first sync is an Object.is
* no-op. The loop starts at render #2, i.e. the first time ANY host re-renders
* the tile. That is why this went unseen: every existing suite renders once.
*
* The negative control below is the load-bearing half: with `columns` declared
* (a stable array off the schema), the same host churn must stay flat — the
* fix must not have been "stop syncing columns".
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, act } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import '../data-table';

afterEach(cleanup);

const ROWS = [
{ name: 'INV-1', amount: 100 },
{ name: 'INV-2', amount: 200 },
];

/**
* Render `schema` (a STABLE object, declared once) inside a host that can
* re-render on demand, counting the tile's commits. A host re-render is the
* most ordinary thing on a dashboard — a filter change, a resize, a parent
* state update — and must cost the tile a bounded number of commits.
*/
function renderUnderHostChurn(schema: Record<string, unknown>) {
const DataTable = ComponentRegistry.get('data-table') as React.ComponentType<{ schema: unknown }>;
if (!DataTable) throw new Error('data-table not registered');
let commits = 0;
let bump: (() => void) | null = null;
const Host = () => {
const [, setTick] = React.useState(0);
bump = () => setTick((n) => n + 1);
return (
<React.Profiler id="tile" onRender={() => { commits += 1; }}>
<DataTable schema={schema} />
</React.Profiler>
);
};
const utils = render(<Host />);
return {
...utils,
churn: (times: number) => {
for (let i = 0; i < times; i += 1) act(() => { bump!(); });
},
get commits() { return commits; },
};
}

describe('data-table survives host re-renders (#4618)', () => {
it('does not exceed the update depth when the schema declares no columns', () => {
// Pre-fix this threw on the FIRST host re-render, after ~50 nested commits:
// "Maximum update depth exceeded. This can happen when a component
// repeatedly calls setState inside componentWillUpdate or
// componentDidUpdate…"
const tile = renderUnderHostChurn({ data: ROWS, searchable: false, pagination: false, className: 'border-0' });
expect(() => tile.churn(3)).not.toThrow();
// Bounded, not merely finite: one commit per host render (plus the mount
// pair). A regression that re-introduced a per-render state write would
// still "not throw" while doubling this.
expect(tile.commits).toBeLessThanOrEqual(6);
});

it('keeps syncing declared columns — the negative control stays flat too', () => {
const tile = renderUnderHostChurn({
data: ROWS,
columns: [{ header: 'Name', accessorKey: 'name' }, { header: 'Amount', accessorKey: 'amount' }],
searchable: false,
pagination: false,
});
expect(() => tile.churn(3)).not.toThrow();
expect(tile.commits).toBeLessThanOrEqual(6);
// Declared columns still reach the DOM — the sync was hardened, not removed.
const headers = Array.from(tile.container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers.join('|')).toContain('Name');
expect(headers.join('|')).toContain('Amount');
expect(tile.container.textContent).toContain('INV-1');
});

it('re-syncs when the host actually changes the declared columns', () => {
const DataTable = ComponentRegistry.get('data-table') as React.ComponentType<{ schema: unknown }>;
let setCols: ((c: Array<Record<string, unknown>>) => void) | null = null;
const Host = () => {
const [cols, set] = React.useState<Array<Record<string, unknown>>>([
{ header: 'Name', accessorKey: 'name' },
]);
setCols = set;
return <DataTable schema={{ data: ROWS, columns: cols, searchable: false, pagination: false }} />;
};
const { container } = render(<Host />);
expect(container.textContent).toContain('Name');
expect(container.textContent).not.toContain('Amount');
act(() => { setCols!([{ header: 'Name', accessorKey: 'name' }, { header: 'Amount', accessorKey: 'amount' }]); });
expect(container.textContent).toContain('Amount');
expect(container.textContent).toContain('200');
});
});
69 changes: 63 additions & 6 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,54 @@ function resolveSelectionMode(selectable: DataTableSchema['selectable'] | 'none'
return 'multiple';
}

/**
* Shared empty fallbacks for the `columns` / `data` props (objectui#4618).
*
* A destructuring default (`columns: rawColumns = []`) or an inline fallback
* (`Array.isArray(raw) ? raw : []`) evaluates a FRESH array on every render, so
* an absent prop churns identity on a schema that never changed. Downstream
* that identity is a `useMemo` key and — for `columns` — a `useEffect` key whose
* body writes state, which closes a self-sustaining render loop: the table's own
* re-render regenerates the literal that scheduled it. Hoisting the empties to
* module scope makes "absent" a stable value, so the memo and the effect see
* what is actually true — nothing changed.
*
* Frozen so a consumer that mutates the array it was handed cannot corrupt the
* shared instance for every other table on the page.
*/
const EMPTY_COLUMNS = Object.freeze([]) as unknown as DataTableSchema['columns'];
const EMPTY_ROWS = Object.freeze([]) as unknown as DataTableSchema['data'];

/**
* Value-equality over two normalized column lists (objectui#4618).
*
* The prop→state sync below re-seeds `columns` whenever `initialColumns` is a
* new object. That is the right trigger for a real change and the wrong one for
* identity churn, which every consumer that derives its columns per render
* produces — `ObjectDataTable` and both dashboard surfaces build the node fresh
* on each of their renders. Comparing the values instead lets the sync stay
* exactly as eager as before for genuine edits (a renamed header, an added
* column, a hidden one) while a re-derived-but-identical list costs nothing.
*
* Shallow per column, on purpose: column entries carry render functions
* (`cell`, `render`), and comparing those by identity is what the sync already
* did — deep-comparing them is neither possible nor wanted.
*/
function columnsAreEquivalent(a: readonly unknown[], b: readonly unknown[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
return a.every((col, i) => {
const other = b[i];
if (col === other) return true;
if (!col || !other || typeof col !== 'object' || typeof other !== 'object') return false;
const left = col as Record<string, unknown>;
const right = other as Record<string, unknown>;
const keys = Object.keys(left);
if (keys.length !== Object.keys(right).length) return false;
return keys.every((k) => Object.is(left[k], right[k]));
});
}

/**
* Enterprise-level data table component with Airtable-like features.
*
Expand Down Expand Up @@ -634,8 +682,9 @@ function resolveSelectionMode(selectable: DataTableSchema['selectable'] | 'none'
const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
const {
caption,
columns: rawColumns = [],
data: rawData = [],
// Module-scope empties, never `[]` literals — see EMPTY_COLUMNS/EMPTY_ROWS.
columns: rawColumns = EMPTY_COLUMNS,
data: rawData = EMPTY_ROWS,
pagination = true,
pageSize: initialPageSize = 10,
pageSizeOptions,
Expand Down Expand Up @@ -715,8 +764,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
}, [language]);

// Ensure data is always an array – provider config objects or null/undefined
// must not reach array operations like .filter() / .some()
const data = Array.isArray(rawData) ? rawData : [];
// must not reach array operations like .filter() / .some(). The non-array
// fallback is the shared empty, so a provider-config schema does not re-key
// every downstream memo on each render (objectui#4618).
const data = Array.isArray(rawData) ? rawData : EMPTY_ROWS;

// Normalize columns to support legacy keys (label/name) from existing JSONs
const initialColumns = useMemo(() => {
Expand Down Expand Up @@ -862,9 +913,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
// pending change existed" from "the pending value was undefined".
const editRevertRef = useRef<{ had: boolean; value: any } | null>(null);

// Update columns when schema changes
// Update columns when schema changes.
//
// Re-seed only on a real change: `initialColumns` is a fresh array whenever
// its memo recomputes, and every consumer that derives columns per render
// hands us one. Writing state for a value-identical list re-renders the whole
// table for nothing, and — when the churn originates inside this component —
// schedules the render that regenerates the churn (objectui#4618).
useEffect(() => {
setColumns(initialColumns);
setColumns((prev) => (columnsAreEquivalent(prev, initialColumns) ? prev : initialColumns));
}, [initialColumns]);

// Clear the internal checkbox selection when the host bumps selectionResetKey.
Expand Down
15 changes: 13 additions & 2 deletions packages/plugin-dashboard/src/DashboardGridLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react';
import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react';
import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
import { isObjectProvider } from './utils';
import { isObjectProvider, deriveStaticTableColumns } from './utils';
import { classifyWidgetType } from './widgetDispatch';
import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget';
import { DatasetWidget } from './DatasetWidget';
Expand Down Expand Up @@ -313,10 +313,21 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
};
}

// Static (data-array) table. The `Array.isArray` arm is not decoration:
// `options.data` for this widget is authored as a plain ARRAY, and
// `widgetData?.items` is `undefined` for one — so this branch resolved
// every authored static table to `[]` while `DashboardRenderer`'s mirror
// of it read the array all along (objectui#4618).
const staticRows = Array.isArray(widgetData) ? widgetData : widgetData?.items || [];
return {
type: 'data-table',
...options,
data: widgetData?.items || [],
data: staticRows,
// See DashboardRenderer: `columns` is required, and an author who
// declared none got a table of empty rows. Explicit columns win.
columns: Array.isArray(options.columns) && options.columns.length > 0
? options.columns
: deriveStaticTableColumns(staticRows),
searchable: false,
pagination: false,
className: "border-0"
Expand Down
14 changes: 12 additions & 2 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { isObjectProvider } from './utils';
import { isObjectProvider, deriveStaticTableColumns } from './utils';
import { classifyWidgetType, METRIC_LIKE_TYPES } from './widgetDispatch';
import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget';
import { DatasetWidget } from './DatasetWidget';
Expand Down Expand Up @@ -686,10 +686,20 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
}

// Static (data-array) table — drill-to-record stays opt-in.
const staticRows = Array.isArray(widgetData) ? widgetData : widgetData?.items || [];
return {
type: 'data-table',
...options,
data: Array.isArray(widgetData) ? widgetData : widgetData?.items || [],
data: staticRows,
// `columns` is a REQUIRED key of `DataTableSchema`, and this
// branch used to omit it whenever the author declared none —
// a table of empty rows, no headers, no cells (objectui#4618).
// Derive from the rows, as the `provider: 'object'` half of
// this same family already does. An explicit list always wins:
// a declared column set is a whitelist, never a starting point.
columns: Array.isArray(options.columns) && options.columns.length > 0
? options.columns
: deriveStaticTableColumns(staticRows),
searchable: false,
pagination: false,
className: "border-0"
Expand Down
16 changes: 4 additions & 12 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { extractRecords, isDrillEnabled } from '@object-ui/core';
import type { DrillDownConfig } from '@object-ui/types';
import { Skeleton, RefreshIndicator, cn } from '@object-ui/components';
import { useSafeFieldLabel, useObjectTranslation, useLocalization, useDisplayLocale } from '@object-ui/i18n';
import { resolveFilterPlaceholders } from './utils';
import { resolveFilterPlaceholders, humanizeFieldKey } from './utils';
import {
buildFieldMeta,
renderFieldValue,
Expand Down Expand Up @@ -56,17 +56,9 @@ interface NormalizedColumn {
export function normalizeColumns(columns: (string | Record<string, any>)[]): NormalizedColumn[] {
return columns.map((col) => {
if (typeof col === 'string') {
return {
header: col
// snake_case → spaces
.replace(/_/g, ' ')
// camelCase → spaces before uppercase letters
.replace(/([A-Z])/g, ' $1')
.trim()
// Title Case each word
.replace(/\b\w/g, (c: string) => c.toUpperCase()),
accessorKey: col,
};
// Shared with the static-table derivation so both halves of the `table`
// widget family spell a header the same way (objectui#4618).
return { header: humanizeFieldKey(col), accessorKey: col };
}
return col as NormalizedColumn;
});
Expand Down
Loading
Loading