Skip to content

Commit ae0a5b7

Browse files
committed
refactor(core,react): home the non-grid row ceiling in core; note takes the result (objectui#7508)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
1 parent a707197 commit ae0a5b7

25 files changed

Lines changed: 527 additions & 343 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/react': minor
4+
---
5+
6+
The non-grid row ceiling's mechanism moves to `@object-ui/core`, its probe row
7+
lives in one query helper, and its footnote takes the result and nothing else
8+
(objectui#7508, maintainer ruling A′ — follow-up to objectui#7210).
9+
10+
**`@object-ui/core` — new exports**, beside the `extractRecords` they wrap:
11+
12+
- `NON_GRID_ROW_CEILING` (moved from `@object-ui/react`, value unchanged);
13+
- `applyNonGridRowCeiling` and the type `NonGridCeilingResult` (moved from
14+
`@object-ui/react`, behaviour unchanged);
15+
- `nonGridRowCeilingQuery()` (new) — returns `{ $top }`, the ceiling plus ONE
16+
probe row. It is the only place that `+ 1` is written; spread it into the
17+
`find()` query.
18+
19+
**`@object-ui/react`**:
20+
21+
- still exports `NON_GRID_ROW_CEILING`, `applyNonGridRowCeiling` and the type
22+
`NonGridCeilingResult`, now re-exported from `@object-ui/core` — the same
23+
bindings, so no import of these three changes;
24+
- **removed**: `NON_GRID_ROW_CEILING_TOP`. Replace `$top: NON_GRID_ROW_CEILING_TOP`
25+
with `...nonGridRowCeilingQuery()` from `@object-ui/core`. This supersedes the
26+
export list in objectui#7210's entry, which names it;
27+
- **breaking prop change**: `NonGridRowCeilingNote` takes `result`
28+
(a `NonGridCeilingResult`) and no other prop. `drawn`, `total`, `truncated`
29+
and `className` are gone, with no deprecated path. Pass the object
30+
`applyNonGridRowCeiling` returned: the note prints `result.rows.length` and
31+
`result.total`, so a footnote whose numbers disagree with the rows drawn can
32+
no longer be written. It now carries `shrink-0` itself, which the gantt used
33+
to pass in through `className`.
34+
35+
`ObjectCalendar`, `ObjectGantt`, `ObjectMap` and `ObjectTree` take the helpers
36+
from `@object-ui/core` and hand the note their result; what they fetch, draw and
37+
print is unchanged.

‎packages/core/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export * from './utils/dom-props.js';
2323
export * from './utils/filter-converter.js';
2424
export * from './utils/managedBy.js';
2525
export * from './utils/extract-records.js';
26+
// The non-grid row ceiling (objectui#7210), homed beside the `extractRecords`
27+
// it wraps by objectui#7508's ruling A′. `@object-ui/react` re-exports three of
28+
// these names and keeps only the React footnote, `NonGridRowCeilingNote`.
29+
export * from './utils/non-grid-row-ceiling.js';
2630
// The emptiness FLOOR (objectui#8496, director seat, decision batch #86): the
2731
// weakest common claim about "is this value empty" — `null`, `undefined`, the
2832
// empty string, the empty array — below `plugin-detail`, `plugin-list`,

packages/react/src/utils/nonGridRowCeiling.contractEnvelope-6839.test.ts renamed to packages/core/src/utils/__tests__/non-grid-row-ceiling.contractEnvelope-6839.test.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
*
1313
* ## Why this module gets its own pin
1414
*
15-
* It is a PUBLISHED `@object-ui/react` export in its own right (README
16-
* "NON_GRID_ROW_CEILING"), so an app calls it directly, without any of the nine
17-
* renderers in the picture. It is also the seam through which FOUR of them
15+
* It is a PUBLISHED export in its own right — `@object-ui/core`'s since
16+
* objectui#7508 moved it beside `extractRecords`, and re-exported by
17+
* `@object-ui/react` (README "NON_GRID_ROW_CEILING") — so an app calls it
18+
* directly, without any of the nine renderers in the picture. It is also the seam through which FOUR of them
1819
* reach `extractRecords` — `ObjectCalendar`, `ObjectGantt`, `ObjectMap` and
1920
* `ObjectTree` all hand it their `find()` answer rather than unwrapping it
2021
* themselves. So it is one module by the pin's definition and four by the
@@ -36,11 +37,13 @@
3637
import { describe, it, expect } from 'vitest';
3738
import {
3839
NON_GRID_ROW_CEILING,
39-
NON_GRID_ROW_CEILING_TOP,
40+
nonGridRowCeilingQuery,
4041
applyNonGridRowCeiling,
41-
} from './nonGridRowCeiling';
42+
} from '../non-grid-row-ceiling';
4243

4344
const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ id: String(i + 1) }));
45+
/** What a caller's `find()` asks for: the ceiling plus the probe row. */
46+
const PROBE_TOP = nonGridRowCeilingQuery().$top;
4447

4548
describe('applyNonGridRowCeiling — the find() envelope it reads (objectui#6839)', () => {
4649
describe('the shapes it still reads — the live arms', () => {
@@ -76,7 +79,7 @@ describe('applyNonGridRowCeiling — the find() envelope it reads (objectui#6839
7679
describe('the truncation verdict, which is this sink’s own behaviour', () => {
7780
it('a `data` envelope over the ceiling still reports truncated, with the ceiling drawn', () => {
7881
const capped = applyNonGridRowCeiling({
79-
data: rows(NON_GRID_ROW_CEILING_TOP),
82+
data: rows(PROBE_TOP),
8083
total: 41234,
8184
});
8285
expect(capped.truncated).toBe(true);
@@ -90,7 +93,7 @@ describe('applyNonGridRowCeiling — the find() envelope it reads (objectui#6839
9093
// nothing was cut, over a result set of 2001 rows. That is why this sink
9194
// needs its own pin rather than inheriting the helper's.
9295
const capped = applyNonGridRowCeiling({
93-
records: rows(NON_GRID_ROW_CEILING_TOP),
96+
records: rows(PROBE_TOP),
9497
total: 41234,
9598
});
9699
expect(capped.rows).toHaveLength(0);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#7210 (ruling a′) — the platform row ceiling for non-grid views, at
11+
* the one place all four of them share it; homed in `@object-ui/core` by
12+
* objectui#7508 (ruling A′).
13+
*
14+
* The four view-level pins (gantt, calendar, map, tree) each assert the ruling
15+
* END TO END on their own surface, and each compares the `$top` it saw against
16+
* `nonGridRowCeilingQuery()` — so all four agree on one `+ 1`. This file pins
17+
* the things those cannot see, because they are properties of the mechanism
18+
* rather than of any one view:
19+
*
20+
* 1. `nonGridRowCeilingQuery()` asks for the ceiling PLUS ONE. The probe row
21+
* is what makes truncation detectable at all, and it is detectable from
22+
* the rows alone — a result set of exactly the ceiling and one of 200,000
23+
* are otherwise the same 2,000 rows with an optional `total` that many
24+
* adapters do not send.
25+
* 2. The query carries `$top` and nothing else, and is a fresh object per
26+
* call — it is spread into four different `find()` queries.
27+
* 3. `applyNonGridRowCeiling` slices the probe row back off and decides
28+
* `truncated` from the rows, never from `total`.
29+
*
30+
* REVERSE VERIFICATION — direction predicted before running: drop the `+ 1`
31+
* from `nonGridRowCeilingQuery` and the "one past the ceiling IS truncated"
32+
* cases built on `PROBE_TOP` collapse to "exactly at the ceiling", so they
33+
* turn red — as does the `+ 1` pin itself.
34+
*/
35+
36+
import { describe, it, expect } from 'vitest';
37+
import {
38+
NON_GRID_ROW_CEILING,
39+
nonGridRowCeilingQuery,
40+
applyNonGridRowCeiling,
41+
} from '../non-grid-row-ceiling';
42+
43+
const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ id: String(i + 1) }));
44+
/** What a caller's `find()` asks for: the ceiling plus the probe row. */
45+
const PROBE_TOP = nonGridRowCeilingQuery().$top;
46+
47+
describe('objectui#7210 / #7508 — the non-grid row ceiling', () => {
48+
it('asks for exactly one row more than it will draw', () => {
49+
expect(nonGridRowCeilingQuery()).toEqual({ $top: NON_GRID_ROW_CEILING + 1 });
50+
});
51+
52+
it('carries `$top` and nothing else — it is spread into every view query', () => {
53+
expect(Object.keys(nonGridRowCeilingQuery())).toEqual(['$top']);
54+
});
55+
56+
it('is a fresh object per call, so one caller cannot move what the next sends', () => {
57+
const first = nonGridRowCeilingQuery();
58+
first.$top = 1;
59+
expect(nonGridRowCeilingQuery().$top).toBe(NON_GRID_ROW_CEILING + 1);
60+
});
61+
62+
it('a result set exactly AT the ceiling is not truncated and keeps every row', () => {
63+
const capped = applyNonGridRowCeiling({ data: rows(NON_GRID_ROW_CEILING) });
64+
expect(capped.truncated).toBe(false);
65+
expect(capped.rows).toHaveLength(NON_GRID_ROW_CEILING);
66+
});
67+
68+
it('one row PAST the ceiling is truncated, and the probe row is sliced back off', () => {
69+
const capped = applyNonGridRowCeiling({ data: rows(PROBE_TOP), total: 41234 });
70+
expect(capped.truncated).toBe(true);
71+
expect(capped.rows).toHaveLength(NON_GRID_ROW_CEILING);
72+
expect(capped.total).toBe(41234);
73+
});
74+
75+
it('detects truncation from a BARE ARRAY response, which carries no total at all', () => {
76+
// The adapters least likely to page correctly are exactly the ones that
77+
// report no `total`; a `total`-based test would go quiet on them.
78+
const capped = applyNonGridRowCeiling(rows(PROBE_TOP));
79+
expect(capped.truncated).toBe(true);
80+
expect(capped.total).toBeUndefined();
81+
expect(capped.rows).toHaveLength(NON_GRID_ROW_CEILING);
82+
});
83+
});

‎packages/core/src/utils/extract-records.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@
4242
* packages/plugin-gantt/src/ObjectGantt.tsx (ref option labels)
4343
* packages/plugin-kanban/src/ObjectKanban.tsx
4444
* packages/plugin-timeline/src/ObjectTimeline.tsx
45-
* packages/react/src/utils/nonGridRowCeiling.tsx (applyNonGridRowCeiling,
46-
* itself the seam for ObjectCalendar, ObjectGantt, ObjectMap, ObjectTree)
45+
* packages/core/src/utils/non-grid-row-ceiling.ts (applyNonGridRowCeiling,
46+
* itself the seam for ObjectCalendar, ObjectGantt, ObjectMap, ObjectTree;
47+
* measured when it lived in `@object-ui/react`, moved here unchanged by
48+
* objectui#7508)
4749
*
4850
* ZERO of them can be reached by a `records` envelope: no `find()` in any of
4951
* those packages, nor in the apps and examples that mount them, emits one.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { extractRecords } from './extract-records.js';
10+
11+
/**
12+
* The platform's hard row ceiling for a NON-GRID visualisation — gantt,
13+
* calendar, map and tree (objectui#7210, maintainer ruling a′, 2026-09-02).
14+
*
15+
* ## Why this lives in `@object-ui/core` (objectui#7508, ruling A′)
16+
*
17+
* The four views live in four packages, so whatever they share is public by
18+
* construction: every package here has a single entry and no subpath export.
19+
* The non-React half of the mechanism — this constant, the query it sends and
20+
* {@link applyNonGridRowCeiling} — wraps this package's own
21+
* `extractRecords`, and every one of the four already imports core helpers,
22+
* so this is its home. `@object-ui/react` re-exports the three names it
23+
* carried before, and keeps only the React half (`NonGridRowCeilingNote`).
24+
*
25+
* ## What was ruled, and what was rejected
26+
*
27+
* A non-grid visualisation may fetch the whole FILTERED result set, because a
28+
* truthful range or layout needs all of it: a gantt cannot compute
29+
* `min(start) → max(end)` from one page, and a map cannot fit a camera to
30+
* markers it never received. What it may not do is fetch it UNBOUNDED — at
31+
* 100k scheduled rows that is the whole table into the browser, with no knob
32+
* reachable from view metadata, since these four requests never carried a
33+
* `$top` for `pagination.pageSize` to set.
34+
*
35+
* So the fetch carries a ceiling, and the ceiling is a NAMED CONSTANT IN THE
36+
* RENDERER — not an authorable view key. Three alternatives were considered
37+
* and are not what landed:
38+
*
39+
* - a documentation note only — still the whole table into the browser;
40+
* - truncate at `pagination.pageSize` — SILENT truncation, and it caps a
41+
* complete schedule at one page;
42+
* - an authorable `maxRows` key — a new permanent key every author must set.
43+
*
44+
* ⛔ The direction the ruling names as dangerous is SILENT truncation: a
45+
* cut-off schedule still looks like a schedule, a cut-off map still looks like
46+
* a map. Nothing on screen distinguishes "these are all the records" from
47+
* "these are the first 2,000 of 40,000" unless the view says so. That is why
48+
* `NonGridRowCeilingNote` (`@object-ui/react`) is not decoration — crossing
49+
* this ceiling quietly is the defect, and the note is the fix.
50+
*
51+
* ## Why 2,000, measured
52+
*
53+
* The ruling sets ONE constant across the four views, chosen after measuring
54+
* them, so the binding view decides it. Measured in this repo's jsdom lane
55+
* (`@testing-library/react`, real child views, inline `value` provider, mount
56+
* to settled paint), DOM elements materialised and mount duration:
57+
*
58+
* | rows | gantt | calendar | map | tree |
59+
* |------:|-----------:|----------:|---------------:|----------------:|
60+
* | 250 | 726 · 314ms | 415 · 231ms | 478 · 152ms | 1,306 · 326ms |
61+
* | 1,000 | 726 · 226ms | 415 · 235ms | 1,512 · 299ms | 5,206 · 1,025ms |
62+
* | 2,000 | 726 · 484ms | 415 · 237ms | 2,770 · 1,250ms | 10,406 · 2,720ms |
63+
* | 4,000 | 726 · 420ms | 415 · 211ms | 3,020 · 648ms | 20,806 · 3,105ms |
64+
* | 8,000 | — | — | — | 41,606 · 7,597ms |
65+
*
66+
* Three of the four hold their DOM flat as rows grow, and for structural
67+
* reasons that will not change: the gantt VIRTUALISES its task list and
68+
* timeline window, the calendar month grid draws at most four events per day
69+
* cell, and the map auto-clusters above 100 markers. Their cost in rows is the
70+
* O(n) transform, not the DOM.
71+
*
72+
* `ObjectTree` is the outlier and therefore the constraint: it flattens every
73+
* expanded node into the document, measured at a strictly linear **5.2 DOM
74+
* elements per record**, with no virtualisation anywhere on that path.
75+
*
76+
* The budget applied to that: keep the WORST of the four inside ~10,000 DOM
77+
* elements — an order of magnitude above Lighthouse's ~1,400-element
78+
* "excessive DOM size" warning, and the last point at which the tree's mount
79+
* stays under ~3s in an environment that does no layout and no paint at all.
80+
* 2,000 rows is where that lands, measured rather than interpolated: 10,406
81+
* elements. It is also ~10x the real application result set this card was
82+
* filed from (186 rows of `duly_task`), so a genuine working view is nowhere
83+
* near it, which is the property that keeps the note meaningful when it does
84+
* appear.
85+
*
86+
* ⚠️ These are SHARED-BOX jsdom seconds, not browser wall clock, and jsdom
87+
* does no layout or paint. The DOM-element counts are the environment-
88+
* independent half and the reason the ratio between the four views is the
89+
* load-bearing part of the reading, not the milliseconds. This table is a
90+
* reading taken once, when objectui#7210 landed; nothing re-derives it.
91+
*/
92+
export const NON_GRID_ROW_CEILING = 2000;
93+
94+
/**
95+
* The query keys a non-grid view sends to honour {@link NON_GRID_ROW_CEILING}:
96+
* `$top` is the ceiling plus ONE probe row.
97+
*
98+
* This is the ONE place the `+ 1` is written (objectui#7508, ruling A′). It
99+
* used to be a published constant beside the ceiling, which left the
100+
* mechanism split in two — `$top` at each call site, the slice in
101+
* {@link applyNonGridRowCeiling} — and gave every caller a number to get
102+
* wrong. A caller now spreads this into its `find()` query and never spells
103+
* the probe row itself.
104+
*
105+
* The `+ 1` is what makes truncation DETECTABLE, and a site that lost it would
106+
* look correct and report nothing: with exactly `$top: NON_GRID_ROW_CEILING`,
107+
* a result set of exactly 2,000 and one of 200,000 both come back as 2,000
108+
* rows, and the only thing separating them is a `total` the adapter is not
109+
* obliged to send (`QueryResult.total` is optional, and an adapter answering
110+
* with a bare array carries none at all). One extra row makes the distinction
111+
* a fact about the rows in hand.
112+
*
113+
* A fresh object per call, so a caller that mutates the query it built cannot
114+
* change what the next caller sends.
115+
*/
116+
export function nonGridRowCeilingQuery(): { $top: number } {
117+
return { $top: NON_GRID_ROW_CEILING + 1 };
118+
}
119+
120+
/** What {@link applyNonGridRowCeiling} tells a caller about its result set. */
121+
export interface NonGridCeilingResult<T = any> {
122+
/** The rows to draw — never more than {@link NON_GRID_ROW_CEILING}. */
123+
rows: T[];
124+
/**
125+
* The size of the whole filtered result set when the adapter reported one
126+
* (`QueryResult.total`), otherwise `undefined`. `undefined` is NOT "not
127+
* truncated" — see {@link NonGridCeilingResult.truncated}, which is decided
128+
* by the rows in hand and never by this.
129+
*/
130+
total?: number;
131+
/** `true` when the source had more rows than the ceiling allows drawn. */
132+
truncated: boolean;
133+
}
134+
135+
/**
136+
* Cap a non-grid view's result set at {@link NON_GRID_ROW_CEILING} and report
137+
* whether it had to.
138+
*
139+
* Truncation is decided by the PROBE ROW (`rows.length > NON_GRID_ROW_CEILING`
140+
* against a query built by {@link nonGridRowCeilingQuery}), never by comparing
141+
* against `total`: `total` is optional on `QueryResult` and absent entirely
142+
* from a bare-array response, so a `total`-based test would silently stop
143+
* reporting on exactly the adapters least likely to be paging correctly.
144+
*
145+
* The rows are read by `extractRecords`, so this sink accepts exactly the
146+
* envelopes that helper does (objectui#6839) — pinned in
147+
* `non-grid-row-ceiling.contractEnvelope-6839.test.ts`.
148+
*/
149+
export function applyNonGridRowCeiling<T = any>(result: unknown): NonGridCeilingResult<T> {
150+
const all = extractRecords(result) as T[];
151+
const rawTotal =
152+
result && typeof result === 'object' && typeof (result as any).total === 'number'
153+
? ((result as any).total as number)
154+
: undefined;
155+
const truncated = all.length > NON_GRID_ROW_CEILING;
156+
return {
157+
rows: truncated ? all.slice(0, NON_GRID_ROW_CEILING) : all,
158+
total: rawTotal,
159+
truncated,
160+
};
161+
}

‎packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,7 @@
9191
import React from 'react';
9292
import { render, screen, waitFor, cleanup } from '@testing-library/react';
9393
import { describe, it, expect, vi, afterEach } from 'vitest';
94-
import { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react';
95-
import { ValueDataSource } from '@object-ui/core';
94+
import { NON_GRID_ROW_CEILING, nonGridRowCeilingQuery, ValueDataSource } from '@object-ui/core';
9695
import { ObjectCalendar } from './ObjectCalendar';
9796

9897
vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({
@@ -244,15 +243,15 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling
244243
<ObjectCalendar
245244
schema={{
246245
...base,
247-
staticData: makeRows(NON_GRID_ROW_CEILING_TOP + 500),
246+
staticData: makeRows(nonGridRowCeilingQuery().$top + 500),
248247
}}
249248
/>,
250249
);
251250
await waitFor(() => expect(drawn().count).toBe(String(NON_GRID_ROW_CEILING)));
252251
});
253252

254253
it('ceilingNote: the cut is LOUD — the footnote names both numbers', async () => {
255-
const total = NON_GRID_ROW_CEILING_TOP + 500;
254+
const total = nonGridRowCeilingQuery().$top + 500;
256255
render(
257256
<ObjectCalendar schema={{ ...base, staticData: makeRows(total) }} />,
258257
);

0 commit comments

Comments
 (0)