|
| 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 | +} |
0 commit comments