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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/7508-non-grid-ceiling-to-core.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/utils/__tests__/non-grid-row-ceiling.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 4 additions & 2 deletions packages/core/src/utils/extract-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
161 changes: 161 additions & 0 deletions packages/core/src/utils/non-grid-row-ceiling.ts
Original file line number Diff line number Diff line change
@@ -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<T = any> {
/** 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<T = any>(result: unknown): NonGridCeilingResult<T> {
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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -244,15 +243,15 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling
<ObjectCalendar
schema={{
...base,
staticData: makeRows(NON_GRID_ROW_CEILING_TOP + 500),
staticData: makeRows(nonGridRowCeilingQuery().$top + 500),
}}
/>,
);
await waitFor(() => expect(drawn().count).toBe(String(NON_GRID_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(
<ObjectCalendar schema={{ ...base, staticData: makeRows(total) }} />,
);
Expand Down
Loading
Loading