diff --git a/.changeset/6460-objectview-fetch-view-identity.md b/.changeset/6460-objectview-fetch-view-identity.md new file mode 100644 index 0000000000..0b919f24c1 --- /dev/null +++ b/.changeset/6460-objectview-fetch-view-identity.md @@ -0,0 +1,30 @@ +--- +'@object-ui/plugin-view': patch +--- + +`ObjectView`'s non-grid fetch no longer re-queries once per parent render when the host +passes an inline `views` array (objectui#6460). + +The effect that fetches rows for the six non-grid view types (kanban, calendar, gallery, +timeline, gantt, map) listed `activeView` — an **element of the `views` prop array** — among +its dependencies. A host writing `views={[{ id: 'cal', type: 'calendar', label: … }]}`, which +is how this component's own docs write it, produces a fresh element object on every one of +its own renders, so the dependency changed identity every render and a new `find()` went out +each time. Measured with an instrumented adapter and three parent re-renders: **4 queries +where a hoisted array gives 1**. Because `ObjectView` hands its rows to the child view as +`data={data}`, each extra query also re-delivered a fresh row array downstream — the +"duplicate events in child views like the calendar" hazard, from the re-run direction. + +The effect now depends on the **values it reads** — the active view's `filter` and `sort`, +plus its `id` — held at a steady reference while they are structurally unchanged, instead of +on the view object's identity. Asking hosts to hoist the array was considered and rejected: +that is a contract change on every caller of a published component, and it leaves the defect +live for every host that does not comply. + +Nothing about precedence moves: a named `listViews` config's `filter`/`sort` still outrank +the view's, which still outrank `table.filter`/`table.sort` and their deprecated aliases. +Changing a view's filter, changing its sort, and switching the active view all still +re-fetch. The comparison never serializes, so it stays correct for filter and sort values +that have no faithful stringification — a `Date`, a function, a `Map`, `NaN`, or plain +key-order instability — and every case it cannot model resolves to "changed", which costs a +redundant query rather than withholding a needed one. diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index fa765c8316..9a27f5d17f 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -67,6 +67,7 @@ import { import { SchemaRenderer as ImportedSchemaRenderer } from '@object-ui/react'; import { ViewSwitcher } from './ViewSwitcher'; import { deriveRecordSurface } from './recordSurface'; +import { useStableIdentity } from './stableIdentity'; /** * SchemaRenderer from @object-ui/react, used to render sub-view schemas. @@ -719,6 +720,44 @@ export const ObjectView: React.FC = ({ const currentActiveViewId = activeViewId || viewsPropResolved?.[0]?.id; const activeView = viewsPropResolved?.find(v => v.id === currentActiveViewId) || viewsPropResolved?.[0]; + /** + * ⭐ objectui#6460 — everything the NON-GRID FETCH EFFECT below needs from the + * active view, as ONE reference that only changes when one of those values + * changes. + * + * `activeView` is an ELEMENT of the `views` prop array, so a host that builds + * that array inline (`views={[{ id: 'cal', type: 'calendar', label: … }]}` — + * how this component's own docs write it) hands over a fresh object every + * time it renders. Listing `activeView` itself made the fetch effect re-run + * once per PARENT render: measured 4 `find` calls where a hoisted array gives + * 1, and because `ObjectView` passes `data={data}` down, each of those also + * re-delivered a fresh row array to the child view. + * + * ⚠️ The three members are not interchangeable with "whatever the effect + * touches", and objectui#6460's own body got this wrong — it said the effect + * reads `filter` and `type`. Measured in the effect body, it reads `filter` + * and **`sort`** (`type` reaches it only via `currentViewType`, its own + * dependency). Dropping `sort` would stop a host that changes only a view's + * sort from ever re-querying — a worse defect than the churn, and invisible + * to any test written from that sentence. + * + * `id` is carried deliberately even though the effect does not read it: it is + * the host's own answer to "which view is active", it is a string and so + * cannot churn, and pinning it keeps switching views observably re-fetching + * even between two views whose filter and sort happen to coincide. Same + * ingredients as the display key this file already derives further down + * (`${schema.objectName}-${activeNamedView || activeView?.id || 'default'}-…`). + * + * Precedence is NOT flattened here. Both values still lose to + * `currentNamedViewConfig` at the read sites in the effect, exactly as before; + * this only decides WHEN the effect re-runs, never which source wins. + */ + const activeViewQueryInputs = useStableIdentity( + activeView + ? { id: activeView.id, filter: activeView.filter, sort: activeView.sort } + : undefined, + ); + // Current view type from named view, multi-view prop, or default const currentViewType: string = useMemo(() => { if (currentNamedViewConfig?.type) return currentNamedViewConfig.type; @@ -822,7 +861,7 @@ export const ObjectView: React.FC = ({ // one only as its alias (objectui#5102). The two view segments ahead of // it are untouched — this extends the last segment only. const finalFilter = mergeFilterNodes( - currentNamedViewConfig?.filter || activeView?.filter + currentNamedViewConfig?.filter || activeViewQueryInputs?.filter || schema.table?.filter || schema.table?.defaultFilters, ); @@ -857,7 +896,7 @@ export const ObjectView: React.FC = ({ // Precedence is unchanged: the canonical `table.sort` still outranks the // deprecated `table.defaultSort`, and both still lose to a view's sort — // the same order the grid path and `mergedSort` express. - const sort = currentNamedViewConfig?.sort || activeView?.sort + const sort = currentNamedViewConfig?.sort || activeViewQueryInputs?.sort || schema.table?.sort || (schema.table?.defaultSort ? [schema.table.defaultSort] : undefined); @@ -913,7 +952,7 @@ export const ObjectView: React.FC = ({ // before the gate opens return above without querying. }, [ schema.objectName, dataSource, currentViewType, refreshKey, - currentNamedViewConfig, activeView, renderListView, + currentNamedViewConfig, activeViewQueryInputs, renderListView, objectSchemaReady, objectSchema, ]); diff --git a/packages/plugin-view/src/__tests__/ObjectView.viewIdentityDeps.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.viewIdentityDeps.test.tsx new file mode 100644 index 0000000000..d62146065a --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.viewIdentityDeps.test.tsx @@ -0,0 +1,338 @@ +/** + * 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#6460 — `ObjectView`'s non-grid fetch effect re-ran once per PARENT + * render whenever the host built its `views` array inline. + * + * ## The defect + * + * `activeView` is an ELEMENT of the `views` prop array + * (`viewsPropResolved?.find(...) || viewsPropResolved?.[0]`), and it was listed + * in the effect's dependency array. A host writing + * `views={[{ id: 'cal', type: 'calendar', label: … }]}` builds a fresh element + * object on every one of its own renders, so the dependency changed identity + * every render and a new `find()` went out each time. Measured on the merge + * base of this branch with the harness below (3 parent re-renders after the + * first query settles): + * + * FRESH `views` array literal find calls: 4 + * STABLE (hoisted) array find calls: 1 ← control + * + * The control is what makes this a defect rather than a property of + * re-rendering. Beyond the query count it also matters downstream: + * `ObjectView` hands rows to the child as `data={data}`, so every extra + * `find()` re-delivers a fresh row array — the "duplicate events in child + * views like the calendar" hazard. + * + * ## ⚠️ The dependency that a fix written from the card's text would drop + * + * objectui#6460's body claims the effect "only ever reads `activeView?.filter` + * and `activeView?.type`". It does not. Measured inside the effect body, + * `activeView` is read at exactly two sites and the second is **`sort`**: + * + * currentNamedViewConfig?.filter || activeView?.filter || schema.table?.… + * currentNamedViewConfig?.sort || activeView?.sort || schema.table?.… + * + * A fix built on that sentence would depend on `id` + filter and silently drop + * `sort`, so a host changing only a view's sort would stop re-fetching — a + * worse defect than the churn, and one that passes every test written from the + * card's own wording. `re-fetches when only the view's SORT changes` below is + * the pin that holds that shut; it is a control, not a nice-to-have. + * + * ## ⚠️ Ghost-assertion guard + * + * "Exactly 1 query" would also pass if the view stopped fetching altogether. + * So every count here is reached only after waiting for a real call, the + * controls assert the query PARAMS that came back (not merely that a call + * happened), and one test asserts rows really reach the child view. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, act } from '@testing-library/react'; +import { ObjectView } from '../ObjectView'; +import type { ObjectViewSchema } from '@object-ui/types'; + +const deliveries: any[][] = []; + +vi.mock('@object-ui/react', async () => { + const React = await import('react'); + return { + SchemaRenderer: ({ schema, data }: any) => { + if (Array.isArray(data) && data.length > 0) { + const g = (globalThis as any).__objectViewChurnDeliveries as any[][] | undefined; + if (g && g[g.length - 1] !== data) g.push(data); + } + return
{schema?.type}
; + }, + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', () => ({ ObjectGrid: () =>
})); +vi.mock('@object-ui/plugin-form', () => ({ ObjectForm: () =>
})); + +const TASK_SCHEMA = { + name: 'task', + label: 'Task', + fields: { name: { type: 'text', label: 'Name' } }, +}; + +const ROWS = [{ id: 't1', name: 'Ship it' }]; + +function makeAdapter(): Record { + return { + getObjectSchema: vi.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return TASK_SCHEMA; + }), + // A FRESH array per response, as the wire produces: one shared array would + // make `setData` a reference-equal no-op and hide every extra delivery. + find: vi.fn(async () => ({ data: ROWS.map((r) => ({ ...r })), total: ROWS.length })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; +} + +/** The schema object is HOISTED, so the only moving part is the `views` array. */ +const SCHEMA = { + type: 'object-view', + objectName: 'task', + defaultViewType: 'calendar', +} as ObjectViewSchema; + +/** The control: one array, one element object, for the whole test. */ +const STABLE_VIEWS = [{ id: 'cal', label: 'Calendar', type: 'calendar' as const }]; + +/** What a host that writes the array inline produces — a fresh object each call. */ +const freshViews = () => [{ id: 'cal', label: 'Calendar', type: 'calendar' as const }]; + +const paramsOf = (adapter: Record) => + adapter.find.mock.calls.map((c: any[]) => c[1] ?? {}); + +beforeEach(() => { + vi.clearAllMocks(); + deliveries.length = 0; + (globalThis as any).__objectViewChurnDeliveries = deliveries; +}); + +/** + * Drive N parent re-renders through the SAME element factory, so a factory that + * builds `views` inline hands `ObjectView` a fresh array every time and a + * hoisted one hands it the same array every time. `tick` exists only to make + * each render a real one. + */ +async function reRender(rerender: (ui: React.ReactElement) => void, ui: (tick: number) => React.ReactElement, times: number) { + for (let i = 1; i <= times; i++) { + await act(async () => { + rerender(ui(i)); + await new Promise((r) => setTimeout(r, 20)); + }); + } +} + +describe('ObjectView non-grid fetch does not churn on an inline `views` array (objectui#6460)', () => { + it('issues ONE query across three parent re-renders with a FRESH `views` array', async () => { + const adapter = makeAdapter(); + const ui = (tick: number) => ( +
+ +
+ ); + const { rerender } = render(ui(0)); + + // Targets a real call, so "stopped fetching" times out rather than reading + // as success. + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + await reRender(rerender, ui, 3); + + // RED before the fix: 4. + expect(adapter.find).toHaveBeenCalledTimes(1); + // And the child view was handed exactly one row array. + expect(deliveries).toHaveLength(1); + }); + + it('issues ONE query across three parent re-renders with a STABLE `views` array (control)', async () => { + const adapter = makeAdapter(); + const ui = (tick: number) => ( +
+ +
+ ); + const { rerender } = render(ui(0)); + + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + await reRender(rerender, ui, 3); + + expect(adapter.find).toHaveBeenCalledTimes(1); + expect(deliveries).toHaveLength(1); + }); + + it('re-fetches when the view’s FILTER changes, and the new query carries it', async () => { + const adapter = makeAdapter(); + const ui = (filter: any[]) => ( + + ); + const { rerender } = render(ui([['status', '=', 'open']])); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + + await act(async () => { + rerender(ui([['status', '=', 'closed']])); + await new Promise((r) => setTimeout(r, 20)); + }); + + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(2)); + // Params, not just a count: the second query must carry the NEW filter. + expect(JSON.stringify(paramsOf(adapter)[1].$filter)).toContain('closed'); + expect(JSON.stringify(paramsOf(adapter)[0].$filter)).toContain('open'); + }); + + it('re-fetches when only the view’s SORT changes — the dependency the card’s wording would have dropped', async () => { + const adapter = makeAdapter(); + const ui = (order: 'asc' | 'desc') => ( + + ); + const { rerender } = render(ui('asc')); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + + await act(async () => { + rerender(ui('desc')); + await new Promise((r) => setTimeout(r, 20)); + }); + + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(2)); + expect(paramsOf(adapter)[0].$orderby).toEqual({ name: 'asc' }); + expect(paramsOf(adapter)[1].$orderby).toEqual({ name: 'desc' }); + }); + + it('re-fetches when the ACTIVE VIEW ID changes, even between two views of the same type', async () => { + const adapter = makeAdapter(); + const VIEWS = [ + { id: 'cal-a', label: 'A', type: 'calendar' as const }, + { id: 'cal-b', label: 'B', type: 'calendar' as const }, + ]; + const ui = (activeViewId: string) => ( + + ); + const { rerender } = render(ui('cal-a')); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + + await act(async () => { + rerender(ui('cal-b')); + await new Promise((r) => setTimeout(r, 20)); + }); + + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(2)); + }); + + it('keeps the named-view config outranking the view’s own filter and sort', async () => { + const adapter = makeAdapter(); + render( + , + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + const p = paramsOf(adapter)[0]; + expect(JSON.stringify(p.$filter)).toContain('named-wins'); + expect(JSON.stringify(p.$filter)).not.toContain('view-loses'); + expect(p.$orderby).toEqual({ created: 'desc' }); + }); + + it('issues ONE query when the inline view carries a FILTER and a SORT rebuilt every render', async () => { + // The case a fix keyed on `activeView?.id` alone would MISS: the id is a + // string and stable, but a host that inlines the array also inlines the + // filter and sort objects inside it, so an identity-only dependency churns + // exactly as before. This is why the dependency compares by structure. + const adapter = makeAdapter(); + const ui = (tick: number) => ( +
+ =', new Date('2026-01-01')]], + sort: [{ field: 'name', order: 'asc' as const }], + }]} + /> +
+ ); + const { rerender } = render(ui(0)); + + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + await reRender(rerender, ui, 3); + + expect(adapter.find).toHaveBeenCalledTimes(1); + // And the one query it did issue still carried the authored filter and sort. + const p = paramsOf(adapter)[0]; + expect(JSON.stringify(p.$filter)).toContain('open'); + expect(p.$orderby).toEqual({ name: 'asc' }); + }); + + it('re-fetches when a filter’s DATE moves to a different instant', async () => { + // The other half of the test above: holding the reference steady must not + // blind the effect to a real change in a value that has no stable + // stringification. + const adapter = makeAdapter(); + const ui = (day: string) => ( + =', new Date(day)]], + }]} + /> + ); + const { rerender } = render(ui('2026-01-01')); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + + await act(async () => { + rerender(ui('2026-02-01')); + await new Promise((r) => setTimeout(r, 20)); + }); + + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/packages/plugin-view/src/stableIdentity.test.ts b/packages/plugin-view/src/stableIdentity.test.ts new file mode 100644 index 0000000000..1a1857915e --- /dev/null +++ b/packages/plugin-view/src/stableIdentity.test.ts @@ -0,0 +1,103 @@ +/** + * 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#6460 — the comparison under `ObjectView`'s non-grid fetch dependency. + * + * Two properties are pinned here, and the FIRST is the load-bearing one: + * + * 1. **Nothing that differs is ever reported equal.** A false "equal" is a + * re-fetch that never happens — silent, and worse than the churn this + * mechanism exists to remove. Every case below that `JSON.stringify` would + * collapse is asserted NOT equal, with the stringify collapse asserted + * alongside it so the test states what it is protecting against rather than + * merely asserting a boolean. + * 2. Values that are the same are reported equal even when a naive key would + * say otherwise (key order), which is what actually removes the churn. + */ + +import { describe, it, expect } from 'vitest'; +import { isStructurallyEqual } from './stableIdentity'; + +describe('isStructurallyEqual — values a stringified key gets WRONG', () => { + it('keeps `{ a: undefined }` distinct from `{}` — stringify collapses them', () => { + expect(JSON.stringify({ a: undefined })).toBe(JSON.stringify({})); + expect(isStructurallyEqual({ a: undefined }, {})).toBe(false); + }); + + it('keeps a function-valued key distinct from an absent one', () => { + const withFn = { where: () => true }; + expect(JSON.stringify(withFn)).toBe('{}'); + expect(isStructurallyEqual(withFn, {})).toBe(false); + // Two DIFFERENT functions are never equal (identity only) — the + // conservative direction: an extra query, never a missed one. + expect(isStructurallyEqual({ where: () => true }, { where: () => true })).toBe(false); + // The same function reference is unchanged. + const same = () => true; + expect(isStructurallyEqual({ where: same }, { where: same })).toBe(true); + }); + + it('keeps a `Map` distinct from a plain empty object', () => { + expect(JSON.stringify({ m: new Map([['a', 1]]) })).toBe(JSON.stringify({ m: {} })); + expect(isStructurallyEqual({ m: new Map([['a', 1]]) }, { m: {} })).toBe(false); + }); + + it('keeps `NaN` distinct from `null`, and treats `NaN` as unchanged from itself', () => { + expect(JSON.stringify({ n: NaN })).toBe(JSON.stringify({ n: null })); + expect(isStructurallyEqual({ n: NaN }, { n: null })).toBe(false); + expect(isStructurallyEqual({ n: NaN }, { n: NaN })).toBe(true); + }); + + it('ignores KEY ORDER, which stringify treats as a difference', () => { + const a = { field: 'status', value: 'open' }; + const b: Record = {}; + b.value = 'open'; + b.field = 'status'; + expect(JSON.stringify(a)).not.toBe(JSON.stringify(b)); + expect(isStructurallyEqual(a, b)).toBe(true); + }); + + it('compares a `Date` by its instant, and reports a different instant as changed', () => { + expect(isStructurallyEqual(new Date('2026-01-01'), new Date('2026-01-01'))).toBe(true); + expect(isStructurallyEqual(new Date('2026-01-01'), new Date('2026-01-02'))).toBe(false); + // A Date is not interchangeable with the string it would serialize to. + expect(isStructurallyEqual(new Date('2026-01-01'), '2026-01-01T00:00:00.000Z')).toBe(false); + }); + + it('survives a cyclic value instead of throwing, and calls it changed', () => { + const a: Record = {}; a.self = a; + const b: Record = {}; b.self = b; + expect(() => JSON.stringify(a)).toThrow(); + // Bounded, and bounded in the re-fetch direction. + expect(isStructurallyEqual(a, b)).toBe(false); + }); +}); + +describe('isStructurallyEqual — the ordinary filter/sort shapes', () => { + it('reports a fresh-but-identical view filter as unchanged', () => { + expect(isStructurallyEqual([['status', '=', 'open']], [['status', '=', 'open']])).toBe(true); + }); + + it('reports a changed operand, operator and arity as changed', () => { + expect(isStructurallyEqual([['status', '=', 'open']], [['status', '=', 'closed']])).toBe(false); + expect(isStructurallyEqual([['status', '=', 'open']], [['status', '!=', 'open']])).toBe(false); + expect(isStructurallyEqual([['status', '=', 'open']], [['status', '=', 'open'], ['a', '=', 1]])).toBe(false); + }); + + it('is order-SENSITIVE for arrays — a sort’s order is semantic', () => { + const asc = [{ field: 'a', order: 'asc' }, { field: 'b', order: 'asc' }]; + const swapped = [{ field: 'b', order: 'asc' }, { field: 'a', order: 'asc' }]; + expect(isStructurallyEqual(asc, swapped)).toBe(false); + }); + + it('handles the `undefined` both-sides case a view without a filter produces', () => { + expect(isStructurallyEqual(undefined, undefined)).toBe(true); + expect(isStructurallyEqual(undefined, null)).toBe(false); + expect(isStructurallyEqual(undefined, [])).toBe(false); + }); +}); diff --git a/packages/plugin-view/src/stableIdentity.ts b/packages/plugin-view/src/stableIdentity.ts new file mode 100644 index 0000000000..36a8186657 --- /dev/null +++ b/packages/plugin-view/src/stableIdentity.ts @@ -0,0 +1,142 @@ +/** + * 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. + */ + +/** + * stableIdentity — hold a value's REFERENCE steady while its VALUE has not + * changed, so a structurally-unchanged object can be a `useEffect` dependency. + * + * ## Why this exists (objectui#6460) + * + * `ObjectView`'s non-grid fetch effect depended on `activeView`, an element of + * the caller's `views` prop array. A host that builds that array inline — + * `views={[{ id: 'cal', type: 'calendar', label: … }]}`, which is how every + * example in this repo's own docs writes it — produces a fresh element object + * on every one of its own renders, so the dependency changed identity every + * render and a new `find()` went out each time (measured: 4 queries where a + * hoisted array gives 1). "Ask hosts to hoist the array" was considered and + * REJECTED: this is a published component, so that is a contract change dressed + * as a bug fix, and it leaves the defect live for every host that does not + * comply. + * + * ## ⚠️ Why NOT a stringified key + * + * The obvious cheap mechanism — `JSON.stringify(filter)` as the dependency — is + * wrong in BOTH directions for the values that actually flow through here + * (a view's `filter` and `sort`, which are author-supplied metadata this + * package does not get to constrain): + * + * - It reports EQUAL for values that differ, which is a MISSED re-fetch — the + * dangerous direction, and a silent one. `JSON.stringify` drops keys whose + * value is `undefined` or a function and renders a `Map`/`Set`/class + * instance as `{}`, so `{ a: undefined }`, `{ a: () => 1 }`, `{}` and + * `{ a: new Map() }` all serialize to the same four characters. `NaN` and + * `Infinity` both become `null`. + * - It reports DIFFERENT for values that are the same, which is the very + * churn being fixed. Key order is insertion order, not semantics: + * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` are the same filter and two + * different strings. + * - A cyclic value makes it THROW, taking the render down with it. + * + * So this compares structurally and never serializes. Key order therefore + * cannot matter, a `Date` is compared by its instant rather than flattened to a + * string, and anything this function does not model (functions, `Map`, `Set`, + * `RegExp`, class instances) falls back to `Object.is` — i.e. to reference + * identity, which can never call two different values equal. + * + * ## The safety invariant + * + * **Every uncertainty resolves to "not equal".** An unmodelled type, a + * differing key count, and a structure deeper than `MAX_DEPTH` all return + * `false`, which yields a NEW reference and therefore a re-fetch. This function + * can only ever remove a REDUNDANT query; it can never withhold a needed one. + * That is what makes it safe to sit under a data dependency. + */ + +import { useRef } from 'react'; + +/** + * Depth past which comparison gives up and reports "not equal". + * + * Bounds the recursion so a cyclic value costs one extra query instead of a + * stack overflow. View filters and sorts are a handful of levels deep at most + * (`[{ field, operator, value }]`, or a nested `and`/`or` group), so nothing + * legitimate reaches this. + */ +const MAX_DEPTH = 12; + +/** A value whose own prototype is `Object.prototype` (or null) — not a class instance. */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Compare two values by structure, without serializing either. + * + * Modelled: primitives (via `Object.is`, so `NaN` equals `NaN`), `Date` (by + * instant), arrays (element-wise and order-sensitive — a filter's order is + * semantic), and plain objects (by key SET and value, so key order does not + * matter). Everything else — functions, `Map`, `Set`, `RegExp`, class + * instances — is equal only when it is the same reference. + * + * @returns `true` only when the two are known to be equivalent. Unknown cases + * return `false`, which is the re-fetch direction. + */ +export function isStructurallyEqual(a: unknown, b: unknown, depth = 0): boolean { + if (Object.is(a, b)) return true; + if (depth >= MAX_DEPTH) return false; + + // Dates before the plain-object test: they are objects, but their content is + // not enumerable, so a key-wise comparison would call any two Dates equal. + if (a instanceof Date || b instanceof Date) { + return a instanceof Date && b instanceof Date && a.getTime() === b.getTime(); + } + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!isStructurallyEqual(a[i], b[i], depth + 1)) return false; + } + return true; + } + + if (isPlainObject(a) && isPlainObject(b)) { + const aKeys = Object.keys(a); + // Key COUNT plus presence, so `{ a: undefined }` and `{}` stay distinct — + // exactly the pair `JSON.stringify` collapses. + if (aKeys.length !== Object.keys(b).length) return false; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!isStructurallyEqual(a[key], b[key], depth + 1)) return false; + } + return true; + } + + // Unmodelled: a function, Map, Set, RegExp, class instance. `Object.is` above + // already accepted the same-reference case; anything else is "changed". + return false; +} + +/** + * Return `value`'s reference, replaced only when the value has structurally + * changed — the identity-preserving half of {@link isStructurallyEqual}. + * + * Deriving the returned reference from `(previous, value)` alone makes this + * idempotent: re-running the render with the same input returns the same + * reference, so it is safe under StrictMode's double invocation. + * + * @param value The value to hold steady. Typically built inline by the caller. + * @returns The previous reference while structurally unchanged, else `value`. + */ +export function useStableIdentity(value: T): T { + const held = useRef(value); + const stable = isStructurallyEqual(held.current, value) ? held.current : value; + held.current = stable; + return stable; +}