From ae0a5b7ff456fa3fde2a40d7d496afbc0697c408 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:46:45 +0000 Subject: [PATCH] refactor(core,react): home the non-grid row ceiling in core; note takes the result (objectui#7508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling A′: NON_GRID_ROW_CEILING, applyNonGridRowCeiling and NonGridCeilingResult move to @object-ui/core beside extractRecords, and @object-ui/react re-exports them. NON_GRID_ROW_CEILING_TOP is deleted; the probe row's +1 lives only in core's new nonGridRowCeilingQuery(). The four non-grid renderers spread that query into their find() and hand NonGridRowCeilingNote the result, which is now its only prop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/7508-non-grid-ceiling-to-core.md | 37 ++++ packages/core/src/index.ts | 4 + ...row-ceiling.contractEnvelope-6839.test.ts} | 17 +- .../__tests__/non-grid-row-ceiling.test.ts | 83 ++++++++ packages/core/src/utils/extract-records.ts | 6 +- .../core/src/utils/non-grid-row-ceiling.ts | 161 ++++++++++++++++ ...jectCalendar.inlineQueryKeys-9061.test.tsx | 7 +- .../ObjectCalendar.rowCeiling-7210.test.tsx | 6 +- .../plugin-calendar/src/ObjectCalendar.tsx | 26 +-- .../ObjectGantt.elementDataSource.test.tsx | 5 +- .../ObjectGantt.hostDataProp-7210.test.tsx | 5 +- .../ObjectGantt.inlineQueryKeys-8769.test.tsx | 7 +- .../src/ObjectGantt.rowCeiling-7210.test.tsx | 10 +- packages/plugin-gantt/src/ObjectGantt.tsx | 31 ++- .../src/ObjectMap.elementDataSource.test.tsx | 4 +- .../ObjectMap.inlineQueryKeys-9061.test.tsx | 7 +- .../src/ObjectMap.rowCeiling-7210.test.tsx | 6 +- packages/plugin-map/src/ObjectMap.tsx | 26 +-- .../ObjectTree.inlineQueryKeys-9136.test.tsx | 7 +- .../src/ObjectTree.rowCeiling-7210.test.tsx | 6 +- packages/plugin-tree/src/ObjectTree.tsx | 26 +-- packages/react/README.md | 35 ++-- packages/react/src/index.ts | 33 +--- .../src/utils/nonGridRowCeiling.test.tsx | 134 ++++++++----- .../react/src/utils/nonGridRowCeiling.tsx | 181 ++++-------------- 25 files changed, 527 insertions(+), 343 deletions(-) create mode 100644 .changeset/7508-non-grid-ceiling-to-core.md rename packages/{react/src/utils/nonGridRowCeiling.contractEnvelope-6839.test.ts => core/src/utils/__tests__/non-grid-row-ceiling.contractEnvelope-6839.test.ts} (87%) create mode 100644 packages/core/src/utils/__tests__/non-grid-row-ceiling.test.ts create mode 100644 packages/core/src/utils/non-grid-row-ceiling.ts diff --git a/.changeset/7508-non-grid-ceiling-to-core.md b/.changeset/7508-non-grid-ceiling-to-core.md new file mode 100644 index 0000000000..939884c91c --- /dev/null +++ b/.changeset/7508-non-grid-ceiling-to-core.md @@ -0,0 +1,37 @@ +--- +'@object-ui/core': minor +'@object-ui/react': minor +--- + +The non-grid row ceiling's mechanism moves to `@object-ui/core`, its probe row +lives in one query helper, and its footnote takes the result and nothing else +(objectui#7508, maintainer ruling A′ — follow-up to objectui#7210). + +**`@object-ui/core` — new exports**, beside the `extractRecords` they wrap: + +- `NON_GRID_ROW_CEILING` (moved from `@object-ui/react`, value unchanged); +- `applyNonGridRowCeiling` and the type `NonGridCeilingResult` (moved from + `@object-ui/react`, behaviour unchanged); +- `nonGridRowCeilingQuery()` (new) — returns `{ $top }`, the ceiling plus ONE + probe row. It is the only place that `+ 1` is written; spread it into the + `find()` query. + +**`@object-ui/react`**: + +- still exports `NON_GRID_ROW_CEILING`, `applyNonGridRowCeiling` and the type + `NonGridCeilingResult`, now re-exported from `@object-ui/core` — the same + bindings, so no import of these three changes; +- **removed**: `NON_GRID_ROW_CEILING_TOP`. Replace `$top: NON_GRID_ROW_CEILING_TOP` + with `...nonGridRowCeilingQuery()` from `@object-ui/core`. This supersedes the + export list in objectui#7210's entry, which names it; +- **breaking prop change**: `NonGridRowCeilingNote` takes `result` + (a `NonGridCeilingResult`) and no other prop. `drawn`, `total`, `truncated` + and `className` are gone, with no deprecated path. Pass the object + `applyNonGridRowCeiling` returned: the note prints `result.rows.length` and + `result.total`, so a footnote whose numbers disagree with the rows drawn can + no longer be written. It now carries `shrink-0` itself, which the gantt used + to pass in through `className`. + +`ObjectCalendar`, `ObjectGantt`, `ObjectMap` and `ObjectTree` take the helpers +from `@object-ui/core` and hand the note their result; what they fetch, draw and +print is unchanged. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2f44898a98..8485ebff59 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,10 @@ export * from './utils/dom-props.js'; export * from './utils/filter-converter.js'; export * from './utils/managedBy.js'; export * from './utils/extract-records.js'; +// The non-grid row ceiling (objectui#7210), homed beside the `extractRecords` +// it wraps by objectui#7508's ruling A′. `@object-ui/react` re-exports three of +// these names and keeps only the React footnote, `NonGridRowCeilingNote`. +export * from './utils/non-grid-row-ceiling.js'; // The emptiness FLOOR (objectui#8496, director seat, decision batch #86): the // weakest common claim about "is this value empty" — `null`, `undefined`, the // empty string, the empty array — below `plugin-detail`, `plugin-list`, diff --git a/packages/react/src/utils/nonGridRowCeiling.contractEnvelope-6839.test.ts b/packages/core/src/utils/__tests__/non-grid-row-ceiling.contractEnvelope-6839.test.ts similarity index 87% rename from packages/react/src/utils/nonGridRowCeiling.contractEnvelope-6839.test.ts rename to packages/core/src/utils/__tests__/non-grid-row-ceiling.contractEnvelope-6839.test.ts index 57d324cf00..ecba39e033 100644 --- a/packages/react/src/utils/nonGridRowCeiling.contractEnvelope-6839.test.ts +++ b/packages/core/src/utils/__tests__/non-grid-row-ceiling.contractEnvelope-6839.test.ts @@ -12,9 +12,10 @@ * * ## Why this module gets its own pin * - * It is a PUBLISHED `@object-ui/react` export in its own right (README - * "NON_GRID_ROW_CEILING"), so an app calls it directly, without any of the nine - * renderers in the picture. It is also the seam through which FOUR of them + * It is a PUBLISHED export in its own right — `@object-ui/core`'s since + * objectui#7508 moved it beside `extractRecords`, and re-exported by + * `@object-ui/react` (README "NON_GRID_ROW_CEILING") — so an app calls it + * directly, without any of the nine renderers in the picture. It is also the seam through which FOUR of them * reach `extractRecords` — `ObjectCalendar`, `ObjectGantt`, `ObjectMap` and * `ObjectTree` all hand it their `find()` answer rather than unwrapping it * themselves. So it is one module by the pin's definition and four by the @@ -36,11 +37,13 @@ import { describe, it, expect } from 'vitest'; import { NON_GRID_ROW_CEILING, - NON_GRID_ROW_CEILING_TOP, + nonGridRowCeilingQuery, applyNonGridRowCeiling, -} from './nonGridRowCeiling'; +} from '../non-grid-row-ceiling'; const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ id: String(i + 1) })); +/** What a caller's `find()` asks for: the ceiling plus the probe row. */ +const PROBE_TOP = nonGridRowCeilingQuery().$top; describe('applyNonGridRowCeiling — the find() envelope it reads (objectui#6839)', () => { describe('the shapes it still reads — the live arms', () => { @@ -76,7 +79,7 @@ describe('applyNonGridRowCeiling — the find() envelope it reads (objectui#6839 describe('the truncation verdict, which is this sink’s own behaviour', () => { it('a `data` envelope over the ceiling still reports truncated, with the ceiling drawn', () => { const capped = applyNonGridRowCeiling({ - data: rows(NON_GRID_ROW_CEILING_TOP), + data: rows(PROBE_TOP), total: 41234, }); expect(capped.truncated).toBe(true); @@ -90,7 +93,7 @@ describe('applyNonGridRowCeiling — the find() envelope it reads (objectui#6839 // nothing was cut, over a result set of 2001 rows. That is why this sink // needs its own pin rather than inheriting the helper's. const capped = applyNonGridRowCeiling({ - records: rows(NON_GRID_ROW_CEILING_TOP), + records: rows(PROBE_TOP), total: 41234, }); expect(capped.rows).toHaveLength(0); diff --git a/packages/core/src/utils/__tests__/non-grid-row-ceiling.test.ts b/packages/core/src/utils/__tests__/non-grid-row-ceiling.test.ts new file mode 100644 index 0000000000..5e8ae0f022 --- /dev/null +++ b/packages/core/src/utils/__tests__/non-grid-row-ceiling.test.ts @@ -0,0 +1,83 @@ +/** + * 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#7210 (ruling a′) — the platform row ceiling for non-grid views, at + * the one place all four of them share it; homed in `@object-ui/core` by + * objectui#7508 (ruling A′). + * + * The four view-level pins (gantt, calendar, map, tree) each assert the ruling + * END TO END on their own surface, and each compares the `$top` it saw against + * `nonGridRowCeilingQuery()` — so all four agree on one `+ 1`. This file pins + * the things those cannot see, because they are properties of the mechanism + * rather than of any one view: + * + * 1. `nonGridRowCeilingQuery()` asks for the ceiling PLUS ONE. The probe row + * is what makes truncation detectable at all, and it is detectable from + * the rows alone — a result set of exactly the ceiling and one of 200,000 + * are otherwise the same 2,000 rows with an optional `total` that many + * adapters do not send. + * 2. The query carries `$top` and nothing else, and is a fresh object per + * call — it is spread into four different `find()` queries. + * 3. `applyNonGridRowCeiling` slices the probe row back off and decides + * `truncated` from the rows, never from `total`. + * + * REVERSE VERIFICATION — direction predicted before running: drop the `+ 1` + * from `nonGridRowCeilingQuery` and the "one past the ceiling IS truncated" + * cases built on `PROBE_TOP` collapse to "exactly at the ceiling", so they + * turn red — as does the `+ 1` pin itself. + */ + +import { describe, it, expect } from 'vitest'; +import { + NON_GRID_ROW_CEILING, + nonGridRowCeilingQuery, + applyNonGridRowCeiling, +} from '../non-grid-row-ceiling'; + +const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ id: String(i + 1) })); +/** What a caller's `find()` asks for: the ceiling plus the probe row. */ +const PROBE_TOP = nonGridRowCeilingQuery().$top; + +describe('objectui#7210 / #7508 — the non-grid row ceiling', () => { + it('asks for exactly one row more than it will draw', () => { + expect(nonGridRowCeilingQuery()).toEqual({ $top: NON_GRID_ROW_CEILING + 1 }); + }); + + it('carries `$top` and nothing else — it is spread into every view query', () => { + expect(Object.keys(nonGridRowCeilingQuery())).toEqual(['$top']); + }); + + it('is a fresh object per call, so one caller cannot move what the next sends', () => { + const first = nonGridRowCeilingQuery(); + first.$top = 1; + expect(nonGridRowCeilingQuery().$top).toBe(NON_GRID_ROW_CEILING + 1); + }); + + it('a result set exactly AT the ceiling is not truncated and keeps every row', () => { + const capped = applyNonGridRowCeiling({ data: rows(NON_GRID_ROW_CEILING) }); + expect(capped.truncated).toBe(false); + expect(capped.rows).toHaveLength(NON_GRID_ROW_CEILING); + }); + + it('one row PAST the ceiling is truncated, and the probe row is sliced back off', () => { + const capped = applyNonGridRowCeiling({ data: rows(PROBE_TOP), total: 41234 }); + expect(capped.truncated).toBe(true); + expect(capped.rows).toHaveLength(NON_GRID_ROW_CEILING); + expect(capped.total).toBe(41234); + }); + + it('detects truncation from a BARE ARRAY response, which carries no total at all', () => { + // The adapters least likely to page correctly are exactly the ones that + // report no `total`; a `total`-based test would go quiet on them. + const capped = applyNonGridRowCeiling(rows(PROBE_TOP)); + expect(capped.truncated).toBe(true); + expect(capped.total).toBeUndefined(); + expect(capped.rows).toHaveLength(NON_GRID_ROW_CEILING); + }); +}); diff --git a/packages/core/src/utils/extract-records.ts b/packages/core/src/utils/extract-records.ts index d36ba54fe0..6d7adaaa68 100644 --- a/packages/core/src/utils/extract-records.ts +++ b/packages/core/src/utils/extract-records.ts @@ -42,8 +42,10 @@ * packages/plugin-gantt/src/ObjectGantt.tsx (ref option labels) * packages/plugin-kanban/src/ObjectKanban.tsx * packages/plugin-timeline/src/ObjectTimeline.tsx - * packages/react/src/utils/nonGridRowCeiling.tsx (applyNonGridRowCeiling, - * itself the seam for ObjectCalendar, ObjectGantt, ObjectMap, ObjectTree) + * packages/core/src/utils/non-grid-row-ceiling.ts (applyNonGridRowCeiling, + * itself the seam for ObjectCalendar, ObjectGantt, ObjectMap, ObjectTree; + * measured when it lived in `@object-ui/react`, moved here unchanged by + * objectui#7508) * * ZERO of them can be reached by a `records` envelope: no `find()` in any of * those packages, nor in the apps and examples that mount them, emits one. diff --git a/packages/core/src/utils/non-grid-row-ceiling.ts b/packages/core/src/utils/non-grid-row-ceiling.ts new file mode 100644 index 0000000000..f4e65016c4 --- /dev/null +++ b/packages/core/src/utils/non-grid-row-ceiling.ts @@ -0,0 +1,161 @@ +/** + * 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. + */ + +import { extractRecords } from './extract-records.js'; + +/** + * The platform's hard row ceiling for a NON-GRID visualisation — gantt, + * calendar, map and tree (objectui#7210, maintainer ruling a′, 2026-09-02). + * + * ## Why this lives in `@object-ui/core` (objectui#7508, ruling A′) + * + * The four views live in four packages, so whatever they share is public by + * construction: every package here has a single entry and no subpath export. + * The non-React half of the mechanism — this constant, the query it sends and + * {@link applyNonGridRowCeiling} — wraps this package's own + * `extractRecords`, and every one of the four already imports core helpers, + * so this is its home. `@object-ui/react` re-exports the three names it + * carried before, and keeps only the React half (`NonGridRowCeilingNote`). + * + * ## What was ruled, and what was rejected + * + * A non-grid visualisation may fetch the whole FILTERED result set, because a + * truthful range or layout needs all of it: a gantt cannot compute + * `min(start) → max(end)` from one page, and a map cannot fit a camera to + * markers it never received. What it may not do is fetch it UNBOUNDED — at + * 100k scheduled rows that is the whole table into the browser, with no knob + * reachable from view metadata, since these four requests never carried a + * `$top` for `pagination.pageSize` to set. + * + * So the fetch carries a ceiling, and the ceiling is a NAMED CONSTANT IN THE + * RENDERER — not an authorable view key. Three alternatives were considered + * and are not what landed: + * + * - a documentation note only — still the whole table into the browser; + * - truncate at `pagination.pageSize` — SILENT truncation, and it caps a + * complete schedule at one page; + * - an authorable `maxRows` key — a new permanent key every author must set. + * + * ⛔ The direction the ruling names as dangerous is SILENT truncation: a + * cut-off schedule still looks like a schedule, a cut-off map still looks like + * a map. Nothing on screen distinguishes "these are all the records" from + * "these are the first 2,000 of 40,000" unless the view says so. That is why + * `NonGridRowCeilingNote` (`@object-ui/react`) is not decoration — crossing + * this ceiling quietly is the defect, and the note is the fix. + * + * ## Why 2,000, measured + * + * The ruling sets ONE constant across the four views, chosen after measuring + * them, so the binding view decides it. Measured in this repo's jsdom lane + * (`@testing-library/react`, real child views, inline `value` provider, mount + * to settled paint), DOM elements materialised and mount duration: + * + * | rows | gantt | calendar | map | tree | + * |------:|-----------:|----------:|---------------:|----------------:| + * | 250 | 726 · 314ms | 415 · 231ms | 478 · 152ms | 1,306 · 326ms | + * | 1,000 | 726 · 226ms | 415 · 235ms | 1,512 · 299ms | 5,206 · 1,025ms | + * | 2,000 | 726 · 484ms | 415 · 237ms | 2,770 · 1,250ms | 10,406 · 2,720ms | + * | 4,000 | 726 · 420ms | 415 · 211ms | 3,020 · 648ms | 20,806 · 3,105ms | + * | 8,000 | — | — | — | 41,606 · 7,597ms | + * + * Three of the four hold their DOM flat as rows grow, and for structural + * reasons that will not change: the gantt VIRTUALISES its task list and + * timeline window, the calendar month grid draws at most four events per day + * cell, and the map auto-clusters above 100 markers. Their cost in rows is the + * O(n) transform, not the DOM. + * + * `ObjectTree` is the outlier and therefore the constraint: it flattens every + * expanded node into the document, measured at a strictly linear **5.2 DOM + * elements per record**, with no virtualisation anywhere on that path. + * + * The budget applied to that: keep the WORST of the four inside ~10,000 DOM + * elements — an order of magnitude above Lighthouse's ~1,400-element + * "excessive DOM size" warning, and the last point at which the tree's mount + * stays under ~3s in an environment that does no layout and no paint at all. + * 2,000 rows is where that lands, measured rather than interpolated: 10,406 + * elements. It is also ~10x the real application result set this card was + * filed from (186 rows of `duly_task`), so a genuine working view is nowhere + * near it, which is the property that keeps the note meaningful when it does + * appear. + * + * ⚠️ These are SHARED-BOX jsdom seconds, not browser wall clock, and jsdom + * does no layout or paint. The DOM-element counts are the environment- + * independent half and the reason the ratio between the four views is the + * load-bearing part of the reading, not the milliseconds. This table is a + * reading taken once, when objectui#7210 landed; nothing re-derives it. + */ +export const NON_GRID_ROW_CEILING = 2000; + +/** + * The query keys a non-grid view sends to honour {@link NON_GRID_ROW_CEILING}: + * `$top` is the ceiling plus ONE probe row. + * + * This is the ONE place the `+ 1` is written (objectui#7508, ruling A′). It + * used to be a published constant beside the ceiling, which left the + * mechanism split in two — `$top` at each call site, the slice in + * {@link applyNonGridRowCeiling} — and gave every caller a number to get + * wrong. A caller now spreads this into its `find()` query and never spells + * the probe row itself. + * + * The `+ 1` is what makes truncation DETECTABLE, and a site that lost it would + * look correct and report nothing: with exactly `$top: NON_GRID_ROW_CEILING`, + * a result set of exactly 2,000 and one of 200,000 both come back as 2,000 + * rows, and the only thing separating them is a `total` the adapter is not + * obliged to send (`QueryResult.total` is optional, and an adapter answering + * with a bare array carries none at all). One extra row makes the distinction + * a fact about the rows in hand. + * + * A fresh object per call, so a caller that mutates the query it built cannot + * change what the next caller sends. + */ +export function nonGridRowCeilingQuery(): { $top: number } { + return { $top: NON_GRID_ROW_CEILING + 1 }; +} + +/** What {@link applyNonGridRowCeiling} tells a caller about its result set. */ +export interface NonGridCeilingResult { + /** The rows to draw — never more than {@link NON_GRID_ROW_CEILING}. */ + rows: T[]; + /** + * The size of the whole filtered result set when the adapter reported one + * (`QueryResult.total`), otherwise `undefined`. `undefined` is NOT "not + * truncated" — see {@link NonGridCeilingResult.truncated}, which is decided + * by the rows in hand and never by this. + */ + total?: number; + /** `true` when the source had more rows than the ceiling allows drawn. */ + truncated: boolean; +} + +/** + * Cap a non-grid view's result set at {@link NON_GRID_ROW_CEILING} and report + * whether it had to. + * + * Truncation is decided by the PROBE ROW (`rows.length > NON_GRID_ROW_CEILING` + * against a query built by {@link nonGridRowCeilingQuery}), never by comparing + * against `total`: `total` is optional on `QueryResult` and absent entirely + * from a bare-array response, so a `total`-based test would silently stop + * reporting on exactly the adapters least likely to be paging correctly. + * + * The rows are read by `extractRecords`, so this sink accepts exactly the + * envelopes that helper does (objectui#6839) — pinned in + * `non-grid-row-ceiling.contractEnvelope-6839.test.ts`. + */ +export function applyNonGridRowCeiling(result: unknown): NonGridCeilingResult { + const all = extractRecords(result) as T[]; + const rawTotal = + result && typeof result === 'object' && typeof (result as any).total === 'number' + ? ((result as any).total as number) + : undefined; + const truncated = all.length > NON_GRID_ROW_CEILING; + return { + rows: truncated ? all.slice(0, NON_GRID_ROW_CEILING) : all, + total: rawTotal, + truncated, + }; +} diff --git a/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx index a70182a958..9b93356f29 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx @@ -91,8 +91,7 @@ import React from 'react'; import { render, screen, waitFor, cleanup } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; -import { ValueDataSource } from '@object-ui/core'; +import { NON_GRID_ROW_CEILING, nonGridRowCeilingQuery, ValueDataSource } from '@object-ui/core'; import { ObjectCalendar } from './ObjectCalendar'; vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({ @@ -244,7 +243,7 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling , ); @@ -252,7 +251,7 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling }); it('ceilingNote: the cut is LOUD — the footnote names both numbers', async () => { - const total = NON_GRID_ROW_CEILING_TOP + 500; + const total = nonGridRowCeilingQuery().$top + 500; render( , ); diff --git a/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx index 9b563fce2c..8fb706bb09 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx @@ -27,7 +27,7 @@ * REVERSE VERIFICATION — MEASURED on two separate ablations (objectui#7507), * because this file grades two different things and one of them was missing: * - * 1. Remove `$top: NON_GRID_ROW_CEILING_TOP` from `ObjectCalendar`'s record + * 1. Remove `...nonGridRowCeilingQuery()` from `ObjectCalendar`'s record * fetch ⇒ red at the **`$top` assertion**, and there only; 1 failed / * 1 passed. NOT at the footnote, which is what this docblock used to * predict. An adapter with no `$top` answers with the whole filtered set, @@ -46,7 +46,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; -import { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; +import { NON_GRID_ROW_CEILING, nonGridRowCeilingQuery } from '@object-ui/core'; import { ObjectCalendar } from './ObjectCalendar'; vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({ @@ -126,7 +126,7 @@ describe('objectui#7210 ruling a′ — the calendar caps at the platform ceilin await waitFor(() => expect(calls.length).toBeGreaterThan(0)); for (const params of calls) { - expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + expect(params.$top).toBe(nonGridRowCeilingQuery().$top); } // ⭐ The ruling's own words — "the DOM row count equals the ceiling". The diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 262727eb4d..83f8524455 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -34,9 +34,6 @@ import { isPermissionError, declaredUserMessage, useSettledSchema, - NON_GRID_ROW_CEILING, - NON_GRID_ROW_CEILING_TOP, - applyNonGridRowCeiling, NonGridRowCeilingNote, } from '@object-ui/react'; import { @@ -72,6 +69,9 @@ import { resolveRecordSourceConfig, resolveRecordSourceObjectName, ValueDataSource, + applyNonGridRowCeiling, + nonGridRowCeilingQuery, + type NonGridCeilingResult, } from '@object-ui/core'; /** @@ -357,9 +357,7 @@ export const ObjectCalendar: React.FC = ({ * `data.length === NON_GRID_ROW_CEILING` cannot tell a capped result set * apart from one that is exactly that size. */ - const [rowCeiling, setRowCeiling] = useState<{ truncated: boolean; total?: number }>({ - truncated: false, - }); + const [rowCeiling, setRowCeiling] = useState(null); const [currentDate, setCurrentDate] = useState(new Date()); // Disclosure state of the "unscheduled" area (objectui#7071). Collapsed by // default, as ruled: the count is always on screen, the list is opt-in. @@ -568,7 +566,7 @@ export const ObjectCalendar: React.FC = ({ // fetch whose rows are no longer on screen is a footnote about a result // set that is not being drawn. Every other `setData` path here already // resets it — this was the one that did not. - setRowCeiling({ truncated: false }); + setRowCeiling(null); } }, [externalData, hasExternalData]); @@ -653,7 +651,7 @@ export const ObjectCalendar: React.FC = ({ // an inline event costs the browser exactly what a fetched one // costs and the ruling text carves out no provider. // ⛔ Still not authorable: no view key reaches this `$top`. - $top: NON_GRID_ROW_CEILING_TOP, + ...nonGridRowCeilingQuery(), }); // Filter first, ceiling second — `ValueDataSource` applies `$filter` // before `$top`, which is what the fetching path gets for free from @@ -663,7 +661,7 @@ export const ObjectCalendar: React.FC = ({ const capped = applyNonGridRowCeiling(result); if (isMounted) { setData(capped.rows); - setRowCeiling({ truncated: capped.truncated, total: capped.total }); + setRowCeiling(capped); setLoading(false); } return; @@ -732,7 +730,7 @@ export const ObjectCalendar: React.FC = ({ // stops at a number. The one probe row past the ceiling is what // makes the cut detectable; `applyNonGridRowCeiling` slices it off. // ⛔ Not authorable: no view key reaches this `$top`. - $top: NON_GRID_ROW_CEILING_TOP, + ...nonGridRowCeilingQuery(), ...(expand.length > 0 ? { $expand: expand } : {}), }); @@ -740,7 +738,7 @@ export const ObjectCalendar: React.FC = ({ if (isMounted) { setData(capped.rows); - setRowCeiling({ truncated: capped.truncated, total: capped.total }); + setRowCeiling(capped); } } else if (dataProvider === 'api') { console.warn('API provider not yet implemented for ObjectCalendar'); @@ -1354,11 +1352,7 @@ export const ObjectCalendar: React.FC = ({ {/* objectui#7210 — a month drawn from the first N rows of a larger set still reads as a complete month; the note is the only thing that says otherwise. Placement follows objectui#7148's chart footnote. */} - + {rowCeiling && } {/* The "unscheduled" containment area (objectui#7071, ruled 2026-09-01 and re-confirmed 2026-09-02). Records with no value in the declared start diff --git a/packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx b/packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx index d05994ea8b..0051a79a7a 100644 --- a/packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx @@ -29,7 +29,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; -import { SchemaRenderer, SchemaRendererProvider, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { nonGridRowCeilingQuery } from '@object-ui/core'; vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); @@ -162,7 +163,7 @@ describe('object-gantt — dataSource: { object, view } (objectstack#7121)', () // Not an oversight — `limit` is unmapped because there is no read site. The // assertion is here so a future mapping addition has to be deliberate. expect(params.$top).not.toBe(3); - expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + expect(params.$top).toBe(nonGridRowCeilingQuery().$top); expect(params.options).toBeUndefined(); }); diff --git a/packages/plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx b/packages/plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx index d80dc9255a..c01b35621b 100644 --- a/packages/plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx @@ -52,7 +52,8 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SchemaRendererProvider, SchemaRenderer, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; +import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react'; +import { nonGridRowCeilingQuery } from '@object-ui/core'; import './index'; vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); @@ -158,7 +159,7 @@ describe('objectui#7210 — object-gantt ignores a host `data` prop', () => { // Not `pagination.pageSize` (2), and not absent either: the platform // ceiling, one probe row wide so the cut is detectable. expect(params.$top).not.toBe(2); - expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + expect(params.$top).toBe(nonGridRowCeilingQuery().$top); expect(params.$skip).toBeUndefined(); } }); diff --git a/packages/plugin-gantt/src/ObjectGantt.inlineQueryKeys-8769.test.tsx b/packages/plugin-gantt/src/ObjectGantt.inlineQueryKeys-8769.test.tsx index e4d6c490c3..9e8d99e935 100644 --- a/packages/plugin-gantt/src/ObjectGantt.inlineQueryKeys-8769.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.inlineQueryKeys-8769.test.tsx @@ -73,8 +73,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; -import { ValueDataSource } from '@object-ui/core'; +import { NON_GRID_ROW_CEILING, nonGridRowCeilingQuery, ValueDataSource } from '@object-ui/core'; import { ObjectGantt } from './ObjectGantt'; vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); @@ -207,7 +206,7 @@ describe('objectui#8769 — the inline `value` provider honours filter / sort / , ); @@ -215,7 +214,7 @@ describe('objectui#8769 — the inline `value` provider honours filter / sort / }); it('ceilingNote: the cut is LOUD — the footnote names both numbers', async () => { - const total = NON_GRID_ROW_CEILING_TOP + 500; + const total = nonGridRowCeilingQuery().$top + 500; render( , ); diff --git a/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx b/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx index 16eb9a7dde..455ccd42b8 100644 --- a/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx @@ -27,7 +27,7 @@ * separate commits on this branch for that reason. * * REVERSE VERIFICATION — MEASURED, and corrected from what this docblock used - * to predict (objectui#7507). Removing `$top: NON_GRID_ROW_CEILING_TOP` from + * to predict (objectui#7507). Removing `...nonGridRowCeilingQuery()` from * `ObjectGantt`'s reload turns the truncation case red at the **`$top` * assertion**; the below-ceiling case stays green. ⚠️ The pass/fail TALLY that * used to be quoted here is deliberately gone: objectui#8769 changed how many @@ -49,7 +49,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; +import { NON_GRID_ROW_CEILING, nonGridRowCeilingQuery } from '@object-ui/core'; import { ObjectGantt } from './ObjectGantt'; vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); @@ -139,7 +139,7 @@ describe('objectui#7210 ruling a′ — the gantt draws at most the platform cei // the cut a fact about the rows in hand rather than a guess. expect(calls.length).toBeGreaterThan(0); for (const params of calls) { - expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + expect(params.$top).toBe(nonGridRowCeilingQuery().$top); } // ⭐ The half the ruling cares about: the truncation is NOT silent. @@ -176,7 +176,7 @@ describe('objectui#7210 ruling a′ — the gantt draws at most the platform cei * ceiling's own file states the exemption boundary in one place. */ it('an inline `value` data set IS capped, and says so', async () => { - const total = NON_GRID_ROW_CEILING_TOP + 500; + const total = nonGridRowCeilingQuery().$top + 500; const inline: any = { ...schema, data: { provider: 'value', items: makeRows(total) }, @@ -201,7 +201,7 @@ describe('objectui#7210 ruling a′ — the gantt draws at most the platform cei * have no `total` to name in a footnote. */ it('a host `data` prop is still never capped, and never footnoted', async () => { - const rows = makeRows(NON_GRID_ROW_CEILING_TOP + 500); + const rows = makeRows(nonGridRowCeilingQuery().$top + 500); render(); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 007c49a308..416c8f84ed 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -35,9 +35,6 @@ import { useNavigationOverlay, useSettledSchema, SchemaRendererContext, - NON_GRID_ROW_CEILING, - NON_GRID_ROW_CEILING_TOP, - applyNonGridRowCeiling, NonGridRowCeilingNote, } from '@object-ui/react'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; @@ -71,6 +68,9 @@ import { createFieldColorResolver, resolveRecordSourceConfig, resolveRecordSourceObjectName, + applyNonGridRowCeiling, + nonGridRowCeilingQuery, + type NonGridCeilingResult, } from '@object-ui/core'; import { getSemanticColorName, @@ -579,9 +579,7 @@ export const ObjectGantt: React.FC = ({ * its own table was measured over the inline provider, so an inline row * costs what a fetched row costs and the ruling text carves out no provider. */ - const [rowCeiling, setRowCeiling] = useState<{ truncated: boolean; total?: number }>({ - truncated: false, - }); + const [rowCeiling, setRowCeiling] = useState(null); // Tenant default currency (ADR-0053) for currency tooltips lacking a code. const { currency: tenantCurrency } = useLocalization(); // The one date/number locale resolver: tenant regional default → active UI @@ -798,7 +796,7 @@ export const ObjectGantt: React.FC = ({ if ((rest as any).data && Array.isArray((rest as any).data)) { if (isCurrent()) { setData((rest as any).data); - setRowCeiling({ truncated: false }); + setRowCeiling(null); } return; } @@ -873,7 +871,7 @@ export const ObjectGantt: React.FC = ({ // makes the cut DETECTABLE; `applyNonGridRowCeiling` slices it back off. // ⛔ Not authorable: an authored `limit` / `pagination.pageSize` still // cannot reach this query, by the same ruling. - $top: NON_GRID_ROW_CEILING_TOP, + ...nonGridRowCeilingQuery(), ...(expand.length > 0 ? { $expand: expand } : {}), // objectui#10250 — the term and its field narrowing, sent together // or not at all. See `searchTerm` above. @@ -887,7 +885,7 @@ export const ObjectGantt: React.FC = ({ const capped = applyNonGridRowCeiling(result); if (isCurrent()) { setData(capped.rows); - setRowCeiling({ truncated: capped.truncated, total: capped.total }); + setRowCeiling(capped); } } catch (err) { if (silent) { @@ -2231,15 +2229,12 @@ export const ObjectGantt: React.FC = ({ drawn from the first N rows of a larger result set is still a confident-looking schedule with a plausible range; the note is the only thing on screen that distinguishes it from a complete one. - `shrink-0` beneath the `flex-1` chart pane, so it cannot be clipped - out of a fixed-height host the way a plain sibling would be - (the construction objectui#7148's `ChartFootnote` measured). */} - + The note carries `shrink-0` itself, beneath the `flex-1` chart + pane, so it cannot be clipped out of a fixed-height host the way a + plain sibling would be (the construction objectui#7148's + `ChartFootnote` measured); it takes the result and nothing else + (objectui#7508). */} + {rowCeiling && } {/* Delete confirmation */} { if (!open && !deleting) setPendingDelete(null); }}> diff --git a/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx b/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx index f2181f30a9..9dc1bb50ce 100644 --- a/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx +++ b/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx @@ -28,7 +28,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; -import { NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; +import { nonGridRowCeilingQuery } from '@object-ui/core'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; // No WebGL in the test env — same stub the sibling ObjectMap tests use. Every @@ -150,7 +150,7 @@ describe('object-map — dataSource: { object, view } (objectstack#7121)', () => // Not an oversight — `limit` is unmapped because there is no read site. // The assertion is here so a future mapping addition has to be deliberate. expect(params.$top).not.toBe(3); - expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + expect(params.$top).toBe(nonGridRowCeilingQuery().$top); }); it('leaves a map with NO dataSource exactly as it was', async () => { diff --git a/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx b/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx index adb185c90d..1db5759564 100644 --- a/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx +++ b/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx @@ -86,8 +86,7 @@ import React from 'react'; import { render, screen, waitFor, cleanup } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; -import { ValueDataSource } from '@object-ui/core'; +import { NON_GRID_ROW_CEILING, nonGridRowCeilingQuery, ValueDataSource } from '@object-ui/core'; import { ObjectMap } from './ObjectMap'; // Same stub the sibling ObjectMap pins use (no WebGL in this lane), widened on @@ -244,7 +243,7 @@ describe('objectui#9061 — the map honours filter / sort / the row ceiling on i , @@ -253,7 +252,7 @@ describe('objectui#9061 — the map honours filter / sort / the row ceiling on i }); it('ceilingNote: the cut is LOUD — the footnote names both numbers', async () => { - const total = NON_GRID_ROW_CEILING_TOP + 500; + const total = nonGridRowCeilingQuery().$top + 500; render( ({ @@ -104,7 +104,7 @@ describe('objectui#7210 ruling a′ — the map caps at the platform ceiling, lo await waitFor(() => expect(calls.length).toBeGreaterThan(0)); for (const params of calls) { - expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + expect(params.$top).toBe(nonGridRowCeilingQuery().$top); } const note = await screen.findByRole('note'); diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 7a3a463767..ed7c0d4786 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -25,9 +25,6 @@ import type { ObjectMapSchema, ObjectMapConfig, DataSource, ViewData } from '@ob import { ObjectMapConfigSchema } from '@object-ui/types/zod'; import { useNavigationOverlay, - NON_GRID_ROW_CEILING, - NON_GRID_ROW_CEILING_TOP, - applyNonGridRowCeiling, NonGridRowCeilingNote, } from '@object-ui/react'; import { NavigationOverlay, cn, useIsMobile } from '@object-ui/components'; @@ -39,6 +36,9 @@ import { resolveRecordSourceConfig, resolveRecordSourceObjectName, ValueDataSource, + applyNonGridRowCeiling, + nonGridRowCeilingQuery, + type NonGridCeilingResult, } from '@object-ui/core'; import MapGL, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; @@ -624,9 +624,7 @@ export const ObjectMap: React.FC = ({ * query as every other provider, so the ceiling arrives with the same `$top` * and the same footnote. This docblock used to say both paths were exempt. */ - const [rowCeiling, setRowCeiling] = useState<{ truncated: boolean; total?: number }>({ - truncated: false, - }); + const [rowCeiling, setRowCeiling] = useState(null); const [objectSchema, setObjectSchema] = useState(null); const [selectedMarkerId, setSelectedMarkerId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); @@ -788,7 +786,7 @@ export const ObjectMap: React.FC = ({ // refetch-every-render trap (objectui#5003). if (Array.isArray(dataProp)) { setData(dataProp); - setRowCeiling({ truncated: false }); + setRowCeiling(null); setLoading(false); return; } @@ -839,7 +837,7 @@ export const ObjectMap: React.FC = ({ // an inline marker costs the browser exactly what a fetched one // costs and the ruling text carves out no provider. // ⛔ Still not authorable: no view key reaches this `$top`. - $top: NON_GRID_ROW_CEILING_TOP, + ...nonGridRowCeilingQuery(), }); // Filter first, ceiling second — `ValueDataSource` applies `$filter` // before `$top`, which is what the fetching path gets for free from @@ -848,7 +846,7 @@ export const ObjectMap: React.FC = ({ // quiet. const capped = applyNonGridRowCeiling(result); setData(capped.rows); - setRowCeiling({ truncated: capped.truncated, total: capped.total }); + setRowCeiling(capped); setLoading(false); return; } @@ -920,13 +918,13 @@ export const ObjectMap: React.FC = ({ // probe row past the ceiling makes the cut detectable; // `applyNonGridRowCeiling` slices it off. // ⛔ Not authorable: no view key reaches this `$top`. - $top: NON_GRID_ROW_CEILING_TOP, + ...nonGridRowCeilingQuery(), ...(expand.length > 0 ? { $expand: expand } : {}), }); const capped = applyNonGridRowCeiling(result); setData(capped.rows); - setRowCeiling({ truncated: capped.truncated, total: capped.total }); + setRowCeiling(capped); } else if (dataProvider === 'api') { console.warn('API provider not yet implemented for ObjectMap'); setData([]); @@ -1390,11 +1388,7 @@ export const ObjectMap: React.FC = ({ still looks like a complete map, and its camera is fitted to a box that is not the data's. Placement follows objectui#7148's chart footnote: a muted note directly under the surface it describes. */} - + {rowCeiling && } {navigation.isOverlay && ( {(record) => ( diff --git a/packages/plugin-tree/src/ObjectTree.inlineQueryKeys-9136.test.tsx b/packages/plugin-tree/src/ObjectTree.inlineQueryKeys-9136.test.tsx index 4c77c76b40..951bc05811 100644 --- a/packages/plugin-tree/src/ObjectTree.inlineQueryKeys-9136.test.tsx +++ b/packages/plugin-tree/src/ObjectTree.inlineQueryKeys-9136.test.tsx @@ -80,8 +80,7 @@ import React from 'react'; import { render, screen, waitFor, cleanup } from '@testing-library/react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; -import { ValueDataSource } from '@object-ui/core'; +import { NON_GRID_ROW_CEILING, nonGridRowCeilingQuery, ValueDataSource } from '@object-ui/core'; import { ObjectTree } from './ObjectTree'; // Same factory `ObjectTree.rowCeiling-7210.test.tsx` uses, and through `` @@ -164,7 +163,7 @@ describe('objectui#9136 — the tree honours filter and the row ceiling on inlin }); it('ceilingCap: an inline set past the ceiling draws exactly the ceiling, and the footnote names both numbers', async () => { - const total = NON_GRID_ROW_CEILING_TOP + 500; + const total = nonGridRowCeilingQuery().$top + 500; render(); // Real rendered ``s, as `ObjectTree.rowCeiling-7210.test.tsx` reads them @@ -238,7 +237,7 @@ describe('objectui#9136 — the tree honours filter and the row ceiling on inlin for (const [resource, params] of find.mock.calls as Array<[string, any]>) { expect(resource).toBe('org_unit'); expect(params.$filter).toBe(ACTIVE_FILTER); - expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + expect(params.$top).toBe(nonGridRowCeilingQuery().$top); } }); }); diff --git a/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx b/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx index 9c768873b0..f09997e2da 100644 --- a/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx +++ b/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx @@ -23,7 +23,7 @@ * rendering says so, which is what the footnote is for. * * REVERSE VERIFICATION — MEASURED, and corrected from what this docblock used - * to predict (objectui#7507). Removing `$top: NON_GRID_ROW_CEILING_TOP` from + * to predict (objectui#7507). Removing `...nonGridRowCeilingQuery()` from * `ObjectTree`'s record fetch turns the truncation case red at the **`$top` * assertion** and there only, 1 failed / 1 passed; the below-ceiling case * stays green. @@ -41,7 +41,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; -import { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; +import { NON_GRID_ROW_CEILING, nonGridRowCeilingQuery } from '@object-ui/core'; import { ObjectTree } from './ObjectTree'; // objectui#6892 slice 9 — inherit the real surface, but through `` rather @@ -115,7 +115,7 @@ describe('objectui#7210 ruling a′ — the tree draws at most the platform ceil expect(calls.length).toBeGreaterThan(0); for (const params of calls) { - expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + expect(params.$top).toBe(nonGridRowCeilingQuery().$top); } const note = screen.getByRole('note'); diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 26e42a6151..9bd4058991 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -46,9 +46,6 @@ import { useNavigationOverlay, useSafeFieldLabel, useSettledSchema, - NON_GRID_ROW_CEILING, - NON_GRID_ROW_CEILING_TOP, - applyNonGridRowCeiling, NonGridRowCeilingNote, } from '@object-ui/react'; import { @@ -73,6 +70,9 @@ import { resolveRecordSourceConfig, resolveRecordSourceObjectName, ValueDataSource, + applyNonGridRowCeiling, + nonGridRowCeilingQuery, + type NonGridCeilingResult, } from '@object-ui/core'; import { ChevronRight, ChevronDown } from 'lucide-react'; @@ -574,9 +574,7 @@ export const ObjectTree: React.FC = ({ * `records.length === NON_GRID_ROW_CEILING` cannot tell a capped result set * apart from one that is exactly that size. */ - const [rowCeiling, setRowCeiling] = useState<{ truncated: boolean; total?: number }>({ - truncated: false, - }); + const [rowCeiling, setRowCeiling] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // `'undeclared'` — and that is a finding, not a shrug (objectui#8348). @@ -759,13 +757,13 @@ export const ObjectTree: React.FC = ({ // on: it materialises ~5.2 DOM elements per record with no // virtualisation, so it is the binding one of the four. // ⛔ Not authorable: no view key reaches this `$top`. - $top: NON_GRID_ROW_CEILING_TOP, + ...nonGridRowCeilingQuery(), ...(expand.length > 0 ? { $expand: expand } : {}), }); const capped = applyNonGridRowCeiling(result); if (!cancelled) { setRecords(capped.rows); - setRowCeiling({ truncated: capped.truncated, total: capped.total }); + setRowCeiling(capped); setLoading(false); } return; @@ -787,7 +785,7 @@ export const ObjectTree: React.FC = ({ if (Array.isArray(passed)) { if (!cancelled) { setRecords(passed); - setRowCeiling({ truncated: false }); + setRowCeiling(null); setLoading(false); } return; @@ -841,7 +839,7 @@ export const ObjectTree: React.FC = ({ // record with no virtualisation — and an inline node costs the // browser exactly what a fetched one costs. // ⛔ Still not authorable: no view key reaches this `$top`. - $top: NON_GRID_ROW_CEILING_TOP, + ...nonGridRowCeilingQuery(), }); // Filter first, ceiling second — `ValueDataSource` applies `$filter` // before `$top`, which is what the fetching path gets for free from @@ -851,7 +849,7 @@ export const ObjectTree: React.FC = ({ const capped = applyNonGridRowCeiling(result); if (!cancelled) { setRecords(capped.rows); - setRowCeiling({ truncated: capped.truncated, total: capped.total }); + setRowCeiling(capped); setLoading(false); } return; @@ -1189,11 +1187,7 @@ export const ObjectTree: React.FC = ({ fell past the cut is reparented to a root. Nothing in the rendering says so, which is why the note does. Placement follows objectui#7148's chart footnote. */} - + {rowCeiling && } ); diff --git a/packages/react/README.md b/packages/react/README.md index 56b5f28e59..0caab1dbef 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -285,13 +285,15 @@ map and tree (objectui#7210). Those four fetch the whole FILTERED result set, because a truthful range or layout needs all of it, but the fetch is bounded: past the ceiling they draw the first N rows and say so. +The mechanism lives in `@object-ui/core` (objectui#7508): `NON_GRID_ROW_CEILING`, +`nonGridRowCeilingQuery` and `applyNonGridRowCeiling`, with the result type +`NonGridCeilingResult`. This package re-exports `NON_GRID_ROW_CEILING`, +`applyNonGridRowCeiling` and `NonGridCeilingResult`, and owns the React half — +the footnote, `NonGridRowCeilingNote`. + ```tsx -import { - NON_GRID_ROW_CEILING, - NON_GRID_ROW_CEILING_TOP, - applyNonGridRowCeiling, - NonGridRowCeilingNote, -} from '@object-ui/react' +import { NonGridRowCeilingNote, applyNonGridRowCeiling } from '@object-ui/react' +import { nonGridRowCeilingQuery } from '@object-ui/core' import type { DataSource, QueryParams } from '@object-ui/types' declare const dataSource: DataSource @@ -300,19 +302,24 @@ declare const schema: { filter?: QueryParams['$filter'] } const result = await dataSource.find(objectName, { $filter: schema.filter, - $top: NON_GRID_ROW_CEILING_TOP, // the ceiling plus ONE probe row + ...nonGridRowCeilingQuery(), // `$top`: the ceiling plus ONE probe row }) // The semicolon is load-bearing: the next statement opens with `<`, so without // it the call above is parsed as the left side of a relational expression. -const { rows, total, truncated } = applyNonGridRowCeiling(result); -// …draw `rows`, then: - +const capped = applyNonGridRowCeiling(result); +// …draw `capped.rows`, then hand the note the whole result: + ``` -`NON_GRID_ROW_CEILING_TOP` is the ceiling plus one deliberately: the probe row -is what makes truncation a fact about the rows in hand, since `QueryResult.total` -is optional and a bare-array response carries none. The note renders `null` when -nothing was truncated, so it can be mounted unconditionally. +`nonGridRowCeilingQuery()` is the one place the probe row is written: `$top` is +the ceiling plus one, because the extra row is what makes truncation a fact about +the rows in hand — `QueryResult.total` is optional and a bare-array response +carries none. Spread it into the query; never spell the `+ 1` at a call site. + +`NonGridRowCeilingNote` takes the `NonGridCeilingResult` and nothing else. The +count it prints is `result.rows.length` and the total is `result.total`, so the +footnote cannot name numbers that disagree with the rows drawn. It renders `null` +when nothing was truncated, so it can be mounted whenever a result is in hand. ⛔ The ceiling is not authorable and must not become so. Silent truncation is the failure it exists to prevent — a cut-off schedule still looks like a diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 86c0a3d5b9..e35f803b7c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -104,26 +104,13 @@ export { // four, because a per-plugin copy would be four constants wearing one name, // which is the thing the ruling ruled out. // -// ⚠️ WHY IT LIVES HERE is a scheduling fact, not an architectural one, and the -// comment that used to stand here said otherwise: "the only package all four -// already depend on". That is measured FALSE — -// `packages/plugin-{gantt,calendar,map,tree}/package.json` each also list -// `@object-ui/core`, `@object-ui/components` and `@object-ui/types`. The real -// reason is that #7210 was dispatched with a same-round barrel fence over -// exactly those: `core` was held by #7349, `types` and `components` by #7182, -// and `@object-ui/react` has no subpath export, so "one constant, not in those -// three" left the package's sole entry as the only reachable home. -// -// ⛔ Do not read this placement as a decision that it belongs here. The -// non-React half (`applyNonGridRowCeiling`, `NonGridCeilingResult`) wraps -// `core`'s own `extractRecords` and would sit beside it; whether these five -// symbols move, and whether `NON_GRID_ROW_CEILING_TOP` should be published at -// all, is an OPEN maintainer decision on objectui#7508. Anything that grows a -// dependency on this exact shape makes that decision more expensive. -export { - NON_GRID_ROW_CEILING, - NON_GRID_ROW_CEILING_TOP, - applyNonGridRowCeiling, - NonGridRowCeilingNote, -} from './utils/nonGridRowCeiling.js'; -export type { NonGridCeilingResult } from './utils/nonGridRowCeiling.js'; +// HOME: `@object-ui/core`, beside the `extractRecords` it wraps (objectui#7508, +// maintainer ruling A′). This entry re-exports the three names it published +// first, the same way it re-exports `@object-ui/i18n` above, and keeps only +// the React half — `NonGridRowCeilingNote`, which takes the +// `NonGridCeilingResult` and nothing else. The probe row's `+ 1` lives in +// core's `nonGridRowCeilingQuery()` and nowhere else; the constant that used +// to carry it here is deleted, not re-exported. +export { NON_GRID_ROW_CEILING, applyNonGridRowCeiling } from '@object-ui/core'; +export type { NonGridCeilingResult } from '@object-ui/core'; +export { NonGridRowCeilingNote } from './utils/nonGridRowCeiling.js'; diff --git a/packages/react/src/utils/nonGridRowCeiling.test.tsx b/packages/react/src/utils/nonGridRowCeiling.test.tsx index 02da4a2c78..a80e7b171d 100644 --- a/packages/react/src/utils/nonGridRowCeiling.test.tsx +++ b/packages/react/src/utils/nonGridRowCeiling.test.tsx @@ -7,83 +7,63 @@ */ /** - * objectui#7210 (ruling a′) — the platform row ceiling for non-grid views, at - * the one place all four of them share it. + * objectui#7210 (ruling a′) — the footnote a non-grid view shows when the + * platform row ceiling bit; reshaped by objectui#7508 (ruling A′) to take the + * `NonGridCeilingResult` and nothing else. * - * The four view-level pins (gantt, calendar, map, tree) each assert the ruling - * END TO END on their own surface. This file pins the two things those cannot - * see, because they are properties of the mechanism rather than of any one - * view: + * The mechanism itself — the ceiling, the probe-row query and + * `applyNonGridRowCeiling` — lives in `@object-ui/core` and is pinned there + * (`packages/core/src/utils/__tests__/non-grid-row-ceiling.test.ts`). This file + * pins the two things that are this package's: * - * 1. `NON_GRID_ROW_CEILING_TOP` is the ceiling PLUS ONE. The probe row is - * what makes truncation detectable at all, and it is detectable from the - * rows alone — a result set of exactly the ceiling and one of 200,000 - * are otherwise the same 2,000 rows with an optional `total` that many - * adapters do not send. - * 2. The note names BOTH numbers when the adapter reported a total, and - * still says something DEFINITE when it did not. + * 1. The note names BOTH numbers when the adapter reported a total, still + * says something DEFINITE when it did not, and every number it prints is + * read off the result it was handed — so a drawn/total pair that did not + * come from a result is not something a caller can make it print. + * 2. This entry re-exports core's names (the same bindings, not copies) and + * no longer publishes the retired `NON_GRID_ROW_CEILING_TOP`. * - * REVERSE VERIFICATION — direction predicted before running: change - * `NON_GRID_ROW_CEILING_TOP` to `NON_GRID_ROW_CEILING` and the "exactly at the - * ceiling is NOT truncated" / "one past it IS" pair collapses — the second case - * turns red because the probe row it depends on is gone. + * REVERSE VERIFICATION — direction predicted before running: make the note + * print the constant instead of `result.rows.length` and the "drawn count is + * the result's" case turns red, because its result is truncated at a row count + * that is not the ceiling. */ import React from 'react'; import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; +import * as core from '@object-ui/core'; import { NON_GRID_ROW_CEILING, - NON_GRID_ROW_CEILING_TOP, applyNonGridRowCeiling, - NonGridRowCeilingNote, -} from './nonGridRowCeiling.js'; + nonGridRowCeilingQuery, + type NonGridCeilingResult, +} from '@object-ui/core'; +import * as entry from '../index'; +import { NonGridRowCeilingNote } from './nonGridRowCeiling.js'; const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ id: String(i + 1) })); +const PROBE_TOP = nonGridRowCeilingQuery().$top; -describe('objectui#7210 — the non-grid row ceiling', () => { - it('asks for exactly one row more than it will draw', () => { - expect(NON_GRID_ROW_CEILING_TOP).toBe(NON_GRID_ROW_CEILING + 1); - }); - - it('a result set exactly AT the ceiling is not truncated and keeps every row', () => { - const capped = applyNonGridRowCeiling({ data: rows(NON_GRID_ROW_CEILING) }); - expect(capped.truncated).toBe(false); - expect(capped.rows).toHaveLength(NON_GRID_ROW_CEILING); - }); - - it('one row PAST the ceiling is truncated, and the probe row is sliced back off', () => { - const capped = applyNonGridRowCeiling({ data: rows(NON_GRID_ROW_CEILING_TOP), total: 41234 }); - expect(capped.truncated).toBe(true); - expect(capped.rows).toHaveLength(NON_GRID_ROW_CEILING); - expect(capped.total).toBe(41234); - }); - - it('detects truncation from a BARE ARRAY response, which carries no total at all', () => { - // The adapters least likely to page correctly are exactly the ones that - // report no `total`; a `total`-based test would go quiet on them. - const capped = applyNonGridRowCeiling(rows(NON_GRID_ROW_CEILING_TOP)); - expect(capped.truncated).toBe(true); - expect(capped.total).toBeUndefined(); - expect(capped.rows).toHaveLength(NON_GRID_ROW_CEILING); - }); - +describe('objectui#7210 / #7508 — the non-grid row ceiling note', () => { it('renders NOTHING when nothing was truncated', () => { const { container } = render( - , + , ); expect(container.querySelector('[data-row-ceiling-note]')).toBeNull(); }); it('names BOTH numbers when the adapter reported a total', () => { - render(); + render( + , + ); const note = screen.getByRole('note'); expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); expect(note.textContent).toContain('41234'); }); it('still says something DEFINITE when the adapter reported no total', () => { - render(); + render(); const note = screen.getByRole('note'); expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); // Definite, not a "may": the probe row proved more rows exist, and the @@ -91,4 +71,58 @@ describe('objectui#7210 — the non-grid row ceiling', () => { expect(note.textContent).toMatch(/first/i); expect(note.textContent).not.toMatch(/undefined|NaN/); }); + + it('the drawn count is the RESULT’s row count, never a number supplied beside it', () => { + // A result whose rows are NOT the ceiling's length, so a note that printed + // the constant (the old `drawn={NON_GRID_ROW_CEILING}` call-site shape) + // cannot pass. + const result: NonGridCeilingResult = { rows: rows(7), total: 90, truncated: true }; + render(); + const note = screen.getByRole('note'); + expect(note.textContent).toContain('7'); + expect(note.textContent).toContain('90'); + expect(note.textContent).not.toContain(String(NON_GRID_ROW_CEILING)); + }); + + it('takes the result and nothing else — the three loose props are gone', () => { + const result = applyNonGridRowCeiling(rows(PROBE_TOP)); + // Compile-time half, enforced by this package's `tsc -p tsconfig.test.json` + // (the `type-check` script): each loose prop is refused by name. + // @ts-expect-error — `drawn` is not a prop; the count comes from `result.rows`. + void (); + // @ts-expect-error — `total` is not a prop; it comes from `result.total`. + void (); + // @ts-expect-error — `truncated` is not a prop; it comes from `result.truncated`. + void (); + // @ts-expect-error — a note without a result is not expressible. + void (); + + // Runtime half: loose numbers forced past the compiler are not read. The + // note prints the result's 3 rows and its own total, never the 5 / 999 + // supplied beside it. + const forced = { + result: { rows: rows(3), total: 40, truncated: true }, + drawn: 5, + total: 999, + } as unknown as React.ComponentProps; + render(); + const text = screen.getByRole('note').textContent ?? ''; + expect(text).toContain('3'); + expect(text).toContain('40'); + expect(text).not.toContain('5'); + expect(text).not.toContain('999'); + }); +}); + +describe('objectui#7508 — `@object-ui/react` re-exports core’s names', () => { + it('re-exports the SAME bindings core owns, not copies', () => { + expect(entry.NON_GRID_ROW_CEILING).toBe(core.NON_GRID_ROW_CEILING); + expect(entry.applyNonGridRowCeiling).toBe(core.applyNonGridRowCeiling); + expect(entry.NonGridRowCeilingNote).toBe(NonGridRowCeilingNote); + }); + + it('no longer publishes `NON_GRID_ROW_CEILING_TOP` — the `+ 1` lives only in core’s query', () => { + expect('NON_GRID_ROW_CEILING_TOP' in entry).toBe(false); + expect('NON_GRID_ROW_CEILING_TOP' in core).toBe(false); + }); }); diff --git a/packages/react/src/utils/nonGridRowCeiling.tsx b/packages/react/src/utils/nonGridRowCeiling.tsx index 58fc8d9993..65c249b59d 100644 --- a/packages/react/src/utils/nonGridRowCeiling.tsx +++ b/packages/react/src/utils/nonGridRowCeiling.tsx @@ -7,137 +7,18 @@ */ import React from 'react'; -import { extractRecords } from '@object-ui/core'; +import type { NonGridCeilingResult } from '@object-ui/core'; import { useObjectTranslation, en } from '@object-ui/i18n'; -/** - * The platform's hard row ceiling for a NON-GRID visualisation — gantt, - * calendar, map and tree (objectui#7210, maintainer ruling a′, 2026-09-02). - * - * ## What was ruled, and what was rejected - * - * A non-grid visualisation may fetch the whole FILTERED result set, because a - * truthful range or layout needs all of it: a gantt cannot compute - * `min(start) → max(end)` from one page, and a map cannot fit a camera to - * markers it never received. What it may not do is fetch it UNBOUNDED — at - * 100k scheduled rows that is the whole table into the browser, with no knob - * reachable from view metadata, since these four requests never carried a - * `$top` for `pagination.pageSize` to set. - * - * So the fetch carries a ceiling, and the ceiling is a NAMED CONSTANT IN THE - * RENDERER — not an authorable view key. Three alternatives were considered - * and are not what landed: - * - * - a documentation note only — still the whole table into the browser; - * - truncate at `pagination.pageSize` — SILENT truncation, and it caps a - * complete schedule at one page; - * - an authorable `maxRows` key — a new permanent key every author must set. - * - * ⛔ The direction the ruling names as dangerous is SILENT truncation: a - * cut-off schedule still looks like a schedule, a cut-off map still looks like - * a map. Nothing on screen distinguishes "these are all the records" from - * "these are the first 2,000 of 40,000" unless the view says so. That is why - * {@link NonGridRowCeilingNote} is not decoration — crossing this ceiling - * quietly is the defect, and the note is the fix. - * - * ## Why 2,000, measured - * - * The ruling sets ONE constant across the four views, chosen after measuring - * them, so the binding view decides it. Measured in this repo's jsdom lane - * (`@testing-library/react`, real child views, inline `value` provider, mount - * to settled paint), DOM elements materialised and mount duration: - * - * | rows | gantt | calendar | map | tree | - * |------:|-----------:|----------:|---------------:|----------------:| - * | 250 | 726 · 314ms | 415 · 231ms | 478 · 152ms | 1,306 · 326ms | - * | 1,000 | 726 · 226ms | 415 · 235ms | 1,512 · 299ms | 5,206 · 1,025ms | - * | 2,000 | 726 · 484ms | 415 · 237ms | 2,770 · 1,250ms | 10,406 · 2,720ms | - * | 4,000 | 726 · 420ms | 415 · 211ms | 3,020 · 648ms | 20,806 · 3,105ms | - * | 8,000 | — | — | — | 41,606 · 7,597ms | - * - * Three of the four hold their DOM flat as rows grow, and for structural - * reasons that will not change: the gantt VIRTUALISES its task list and - * timeline window, the calendar month grid draws at most four events per day - * cell, and the map auto-clusters above 100 markers. Their cost in rows is the - * O(n) transform, not the DOM. - * - * `ObjectTree` is the outlier and therefore the constraint: it flattens every - * expanded node into the document, measured at a strictly linear **5.2 DOM - * elements per record**, with no virtualisation anywhere on that path. - * - * The budget applied to that: keep the WORST of the four inside ~10,000 DOM - * elements — an order of magnitude above Lighthouse's ~1,400-element - * "excessive DOM size" warning, and the last point at which the tree's mount - * stays under ~3s in an environment that does no layout and no paint at all. - * 2,000 rows is where that lands, measured rather than interpolated: 10,406 - * elements. It is also ~10x the real application result set this card was - * filed from (186 rows of `duly_task`), so a genuine working view is nowhere - * near it, which is the property that keeps the note meaningful when it does - * appear. - * - * ⚠️ These are SHARED-BOX jsdom seconds, not browser wall clock, and jsdom - * does no layout or paint. The DOM-element counts are the environment- - * independent half and the reason the ratio between the four views is the - * load-bearing part of the reading, not the milliseconds. - */ -export const NON_GRID_ROW_CEILING = 2000; - -/** - * The `$top` a non-grid view actually sends: the ceiling plus ONE probe row. - * - * Exported as its own constant rather than left as `NON_GRID_ROW_CEILING + 1` - * at four call sites, because the `+ 1` is what makes truncation DETECTABLE - * and a site that lost it would look correct and report nothing: with exactly - * `$top: NON_GRID_ROW_CEILING`, a result set of exactly 2,000 and one of - * 200,000 both come back as 2,000 rows, and the only thing separating them is - * a `total` the adapter is not obliged to send (`QueryResult.total` is - * optional, and an adapter answering with a bare array carries none at all). - * One extra row makes the distinction a fact about the rows in hand. - */ -export const NON_GRID_ROW_CEILING_TOP = NON_GRID_ROW_CEILING + 1; - -/** What {@link applyNonGridRowCeiling} tells a caller about its result set. */ -export interface NonGridCeilingResult { - /** The rows to draw — never more than {@link NON_GRID_ROW_CEILING}. */ - rows: T[]; - /** - * The size of the whole filtered result set when the adapter reported one - * (`QueryResult.total`), otherwise `undefined`. `undefined` is NOT "not - * truncated" — see {@link NonGridCeilingResult.truncated}, which is decided - * by the rows in hand and never by this. - */ - total?: number; - /** `true` when the source had more rows than the ceiling allows drawn. */ - truncated: boolean; -} - -/** - * Cap a non-grid view's result set at {@link NON_GRID_ROW_CEILING} and report - * whether it had to. - * - * Truncation is decided by the PROBE ROW (`rows.length > NON_GRID_ROW_CEILING` - * against a query that asked for {@link NON_GRID_ROW_CEILING_TOP}), never by - * comparing against `total`: `total` is optional on `QueryResult` and absent - * entirely from a bare-array response, so a `total`-based test would silently - * stop reporting on exactly the adapters least likely to be paging correctly. - */ -export function applyNonGridRowCeiling(result: unknown): NonGridCeilingResult { - const all = extractRecords(result) as T[]; - const rawTotal = - result && typeof result === 'object' && typeof (result as any).total === 'number' - ? ((result as any).total as number) - : undefined; - const truncated = all.length > NON_GRID_ROW_CEILING; - return { - rows: truncated ? all.slice(0, NON_GRID_ROW_CEILING) : all, - total: rawTotal, - truncated, - }; -} +// The non-React half of the non-grid row ceiling — `NON_GRID_ROW_CEILING`, +// `nonGridRowCeilingQuery`, `applyNonGridRowCeiling` and `NonGridCeilingResult` +// — lives in `@object-ui/core` beside the `extractRecords` it wraps +// (objectui#7508, ruling A′). Only the footnote is React, so only the footnote +// is here. /** * The loud footnote a non-grid view shows when it drew only the first - * {@link NON_GRID_ROW_CEILING} rows of a larger result set (objectui#7210). + * `NON_GRID_ROW_CEILING` rows of a larger result set (objectui#7210). * * Placement and tone follow objectui#7148's chart footnote — a `role="note"` * line in muted small type directly under the visualisation, naming BOTH @@ -152,22 +33,26 @@ export function applyNonGridRowCeiling(result: unknown): NonGridCeiling * sentence (the probe row proves more rows exist), it simply cannot name how * many. * - * Renders `null` when nothing was truncated, so a caller can mount it - * unconditionally and gains no wrapper element on the healthy path. + * ## It takes the RESULT, and nothing else (objectui#7508, ruling A′) + * + * The only prop is the `NonGridCeilingResult` that `applyNonGridRowCeiling` + * returned. Every number the note prints is read off that one object — the + * drawn count is `result.rows.length`, the total is `result.total`, the + * verdict is `result.truncated` — so a footnote whose numbers disagree with + * the rows actually drawn is not something a caller can write. The earlier + * shape took `drawn` / `total` / `truncated` as three loose props, and every + * call site hand-wrote `drawn={NON_GRID_ROW_CEILING}` next to a `total` from + * somewhere else: an incoherent pair was expressible and nothing refused it. + * ⛔ No deprecated path keeps those props; there were no released consumers. + * + * Renders `null` when nothing was truncated, so a caller holding a result can + * mount it unconditionally and gains no wrapper element on the healthy path. */ export function NonGridRowCeilingNote({ - drawn, - total, - truncated, - className, + result, }: { - /** Rows actually drawn — the ceiling, on every path that renders this. */ - drawn: number; - /** The whole result set's size, when the adapter reported one. */ - total?: number; - /** Pass {@link NonGridCeilingResult.truncated} straight through. */ - truncated: boolean; - className?: string; + /** What `applyNonGridRowCeiling` returned for the rows being drawn. */ + result: NonGridCeilingResult; }) { // ⚠️ A HOOK AT RENDER, never a module-scope `createSafeTranslation(...)` // factory, and that is a correctness constraint rather than taste. This @@ -188,23 +73,29 @@ export function NonGridRowCeilingNote({ // pack makes an inline default that disagrees with `en` unrepresentable // instead of merely policed. const { t } = useObjectTranslation(); - if (!truncated) return null; + if (!result.truncated) return null; + const shown = result.rows.length; const text = - typeof total === 'number' + typeof result.total === 'number' ? t('common.rowCeilingNote', { - shown: drawn, - total, + shown, + total: result.total, defaultValue: en.common.rowCeilingNote, }) : t('common.rowCeilingNoteUnknownTotal', { - shown: drawn, + shown, defaultValue: en.common.rowCeilingNoteUnknownTotal, }); + // `shrink-0` is the note's own, not a caller's: under a `flex-1` view pane in + // a fixed-height host a plain sibling can be clipped out of view — the + // construction objectui#7148's `ChartFootnote` measured, and the reason the + // gantt used to pass this exact class in. Outside a flex container it does + // nothing, so all four views take it and no caller needs a styling prop. return (

{text}