diff --git a/.changeset/7210-non-grid-row-ceiling.md b/.changeset/7210-non-grid-row-ceiling.md new file mode 100644 index 0000000000..c17c4eae21 --- /dev/null +++ b/.changeset/7210-non-grid-row-ceiling.md @@ -0,0 +1,44 @@ +--- +'@object-ui/react': minor +'@object-ui/i18n': patch +'@object-ui/plugin-gantt': patch +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-map': patch +'@object-ui/plugin-tree': patch +--- + +A non-grid view's fetch now carries a platform row ceiling, and crossing it is +never silent (objectui#7210, maintainer ruling a′, 2026-09-02). + +Before this, `ObjectGantt`, `ObjectCalendar`, `ObjectMap` and `ObjectTree` each +issued a `find` with **no `$top` at all**, so the request returned the entire +filtered result set. At the 186 rows the card was filed from that is invisible; +on an object with 100k scheduled rows it is the whole table into the browser, +and nothing an author could write — `pagination.pageSize` included — could +bound a request that never carried a cap to begin with. + +**What changed.** Those four fetches now ask for `NON_GRID_ROW_CEILING_TOP` +rows, draw at most `NON_GRID_ROW_CEILING` of them, and when the result set was +larger they render a footnote naming both numbers: *"Showing the first 2,000 of +41,234 records. Narrow the filter."* Below the ceiling nothing changes: the full +set draws and no footnote appears. + +**The ceiling is a platform constant, not an authorable key** — `2000`, exported +from `@object-ui/react` as `NON_GRID_ROW_CEILING`. An authored `limit` or +`dataSource: { limit }` still does not reach these queries, by the same ruling; +three alternatives were rejected with it (a documentation note only — still the +whole table; truncating at `pageSize` — silent, and a complete schedule capped +at one page; an authorable `maxRows` — a new permanent key every author sets). + +**Why 2,000.** One constant for all four, so the binding view sets it. Measured +in this repo's jsdom lane: gantt, calendar and map hold their DOM flat as rows +grow (virtualised task list; four events per day cell; auto-clustering above +100 markers), while `ObjectTree` flattens every expanded node into the document +at a linear **5.2 DOM elements per record** with no virtualisation. 2,000 rows +is where the worst of the four lands at ~10,400 elements — an order of +magnitude above Lighthouse's "excessive DOM size" warning, and still ~10x the +real application result set this card came from. + +New exports on `@object-ui/react`: `NON_GRID_ROW_CEILING`, +`NON_GRID_ROW_CEILING_TOP`, `applyNonGridRowCeiling`, `NonGridRowCeilingNote`. +Two new `common.*` i18n keys carry the footnote copy in all ten packs. diff --git a/.changeset/7225-settled-schema-convergence.md b/.changeset/7225-settled-schema-convergence.md new file mode 100644 index 0000000000..c8b9c16de3 --- /dev/null +++ b/.changeset/7225-settled-schema-convergence.md @@ -0,0 +1,51 @@ +--- +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-view': patch +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-gantt': patch +--- + +The settled-schema convergence, and the gantt's duplicate query gated +(objectui#7225, maintainer ruling B, 2026-09-02). + +`useSettledSchema` was extracted and published in PR #6690 with exactly **one** +non-test adopter (`ObjectTree`, the component that had an actual defect — +objectui#6481's unkeyed latch). `ObjectKanban`, `plugin-view/ObjectView` and +`ObjectCalendar` kept their own hand copies of the same shape, so a published +export was owed compatibility forever **and** the duplication it was named for +stayed. All three now call the hook. + +The migration is a pure deduplication with no behaviour delta — the hook was +extracted *from* these three shapes, so each becomes a one-line call. +`ObjectCalendar`, which objectui#6482 named as the obstacle, fits via the +recipe the hook's own doc comment prescribes for it by name: pass the data +source as `undefined` for a render that must not read metadata +(`hasInlineData ? undefined : dataSource`), so "inline value data set" is +expressed as "there is no source to read from" rather than as a second enable +flag. GATE PLACEMENT stays local in all three, which is what #6482 ruled and +what made the calendar's obstacle a non-obstacle: it was about the gate half. + +**One observable change:** `ObjectKanban`'s rejected definition read now logs +on `console.error` with a `[useSettledSchema]` prefix instead of +`console.warn`. Its test spy moves with it, and now asserts on the channel +rather than merely silencing it. + +**The gantt's duplicate query is gated** (ask 2 of the card; #6482's +undischarged half). `ObjectGantt` listed `objectSchema` in `reload`'s +dependency list, so every load issued two unbounded queries — the first with no +`$expand` at all. Measured on this component across three latency profiles, the +cost is not the mild "round trip bought and thrown away": when the metadata +read is the slower of the two, which is the common case on a cold +`MetadataCache`, the user sees the full three-step paint — raw foreign-key ids, +back to the loading placeholder, then the expanded rows. It now issues one +query, already expanded. + +Gating the gantt required its schema resolution to settle on EVERY exit +(objectui#7232): the hand-rolled effect returned without settling on +`!effectiveDataSource`, on `!resource` and in its `catch` — harmless while +nothing waited on it, and a chart that never loads once something does. +`useSettledSchema` settles on all three by construction, which is what makes +the gate safe; both exits are pinned. + +⛔ Gating is not capping. The row ceiling on these fetches is objectui#7210's +separate ruling, in its own commit on the same branch. diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index 47c794dff7..17c06d0b60 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -252,12 +252,12 @@ ignores would be accepted and dropped, which is the defect this binding removes. | `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ | | `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ | | `record:related_list` | ✅ | columns / filter / sort / limit | ✅ | ✅ | ✅ | -| `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — no row cap | +| `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | | `object-kanban` | ✅ | filter / limit | ✅ | — no ordering | ✅ (`limit`) | | `object-chart` | ✅ | filter | ✅ | — engine orders | — no page | | `object-metric` | ✅ | filter | ✅ | — single value | — single value | -| `object-gantt` | ✅ | filter / sort | ✅ | ✅ | — no row cap | -| `object-map` | ✅ | filter / sort | ✅ | ✅ | — no row cap | +| `object-gantt` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | +| `object-map` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | | `object-pivot` | ✅ | filter | ✅ | — grouping orders | — totals need all rows | | `object-timeline` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ (`limit`) | | `object-form` | ✅ | error-checked only | — no collection query | — | — | @@ -322,6 +322,24 @@ cap above is `limit` on the **board**, while `limit` on a **column** is that lane's WIP limit (how many cards it may hold before it warns), which is display behaviour and never touches the query. +Reading `— platform ceiling` on `object-calendar` / `object-gantt` / `object-map` +(and on `object-tree`, which predates this table): those four are the **non-grid** +visualisations, and objectui#7210's maintainer ruling settled what their row +behaviour is. They fetch the whole **filtered** result set — a gantt cannot +compute a truthful `min(start) → max(end)` from one page, a map fits its camera to +every marker, and a tree assembled from a page loses every node whose parent fell +outside it — but the fetch is **bounded** by `NON_GRID_ROW_CEILING` +(`@object-ui/react`, currently 2,000). Past it the view draws the first N rows and +shows a footnote naming both N and the total. Silent truncation is the failure +that ruling exists to prevent: a cut-off schedule still looks like a schedule. + +The `limit` cell stays "not ✅" for all four because the ceiling is **not +authorable and must not become one** — a named constant in the renderer, by the +same ruling. An authored `limit`, a binding's `limit` and a named view's +`pagination.pageSize` all still fail to reach these queries, which is what the +cell has always meant. What changed is only that "no cap at all" is no longer +true. + Remaining gap, recorded rather than papered over: - `object-form` / `embeddable-form` / `object-master-detail-form` resolve `view` diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 0871ed928a..b73908439a 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -129,6 +129,13 @@ const ar = { record: "سجل", retry: "إعادة المحاولة", printDialogHint: "يفتح مربع حوار الطباعة في المتصفح (ليس تصديرًا إلى PDF)", + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: "يتم عرض أول {{shown}} من أصل {{total}} سجل. ضيّق عامل التصفية.", + rowCeilingNoteUnknownTotal: "يتم عرض أول {{shown}} سجل. ضيّق عامل التصفية.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a6bd2afe53..e93b239ff4 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -125,6 +125,13 @@ const de = { record: "Datensatz", retry: "Erneut versuchen", printDialogHint: "Öffnet den Druckdialog Ihres Browsers (kein PDF-Export)", + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: "Erste {{shown}} von {{total}} Datensätzen. Filter eingrenzen.", + rowCeilingNoteUnknownTotal: "Erste {{shown}} Datensätze. Filter eingrenzen.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 538b4cdd4f..9d8dea77f1 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -149,6 +149,13 @@ const en = { record: 'Record', retry: 'Retry', printDialogHint: 'Opens your browser’s print dialog (not a PDF export)', + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: 'Showing the first {{shown}} of {{total}} records. Narrow the filter.', + rowCeilingNoteUnknownTotal: 'Showing the first {{shown}} records. Narrow the filter.', }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index b4d27230dd..2375c995d3 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -124,6 +124,13 @@ const es = { record: "Registro", retry: "Reintentar", printDialogHint: "Abre el cuadro de diálogo de impresión de tu navegador (no es una exportación a PDF)", + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: "Mostrando los primeros {{shown}} de {{total}} registros. Acota el filtro.", + rowCeilingNoteUnknownTotal: "Mostrando los primeros {{shown}} registros. Acota el filtro.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 0fbe5a26ab..5e48730a1f 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -125,6 +125,13 @@ const fr = { record: "Enregistrement", retry: "Réessayer", printDialogHint: "Ouvre la boîte de dialogue d'impression de votre navigateur (ce n'est pas un export PDF)", + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: "Affichage des {{shown}} premiers enregistrements sur {{total}}. Affinez le filtre.", + rowCeilingNoteUnknownTotal: "Affichage des {{shown}} premiers enregistrements. Affinez le filtre.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index bd5018acd2..036874fb23 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -125,6 +125,13 @@ const ja = { record: "レコード", retry: "再試行", printDialogHint: "ブラウザーの印刷ダイアログを開きます(PDF エクスポートではありません)", + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: "{{total}} 件中、最初の {{shown}} 件を表示しています。フィルターを絞り込んでください。", + rowCeilingNoteUnknownTotal: "最初の {{shown}} 件を表示しています。フィルターを絞り込んでください。", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index f2ee1fcabb..0730fcdcc3 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -125,6 +125,13 @@ const ko = { record: "레코드", retry: "다시 시도", printDialogHint: "브라우저의 인쇄 대화 상자를 엽니다(PDF 내보내기가 아닙니다)", + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: "전체 {{total}}개 중 처음 {{shown}}개를 표시합니다. 필터를 좁히세요.", + rowCeilingNoteUnknownTotal: "처음 {{shown}}개를 표시합니다. 필터를 좁히세요.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 98e6bc697d..0691e0e051 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -124,6 +124,13 @@ const pt = { record: "Registro", retry: "Tentar novamente", printDialogHint: "Abre a caixa de diálogo de impressão do navegador (não é uma exportação para PDF)", + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: "Mostrando os primeiros {{shown}} de {{total}} registros. Restrinja o filtro.", + rowCeilingNoteUnknownTotal: "Mostrando os primeiros {{shown}} registros. Restrinja o filtro.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 17e51db6e0..ac48e1ef66 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -131,6 +131,13 @@ const ru = { record: "Запись", retry: "Повторить", printDialogHint: "Открывает диалог печати браузера (это не экспорт в PDF)", + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: "Показаны первые {{shown}} из {{total}} записей. Сузьте фильтр.", + rowCeilingNoteUnknownTotal: "Показаны первые {{shown}} записей. Сузьте фильтр.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 71b9e5fe7e..62dda095e7 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -132,6 +132,13 @@ const zh = { record: '记录', retry: '重试', printDialogHint: '打开浏览器打印对话框(不是导出 PDF)', + // The non-grid row ceiling's footnote (objectui#7210). Two keys because + // there are two conditions: a reported `total` states the fact with BOTH + // numbers, a missing one cannot name how many. Same split as + // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is + // eagerly loaded, and the per-chunk gzip budget has ~1 KB of headroom. + rowCeilingNote: '仅显示 {{total}} 条记录中的前 {{shown}} 条。请缩小筛选范围。', + rowCeilingNoteUnknownTotal: '仅显示前 {{shown}} 条记录。请缩小筛选范围。', }, actions: { decisionOutput: { diff --git a/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx new file mode 100644 index 0000000000..0ceb7a56aa --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx @@ -0,0 +1,117 @@ +/** + * 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, half 2 — maintainer ruling a′ (2026-09-02), on the calendar. + * + * The calendar is the view where a cut is HARDEST to notice from the picture, + * which is the ruling's whole concern. Its month grid draws at most four + * events per day cell and a "+N more" affordance, so a month rendered from the + * first N of a much larger set looks exactly like a full one — the missing + * records are not missing pixels, they are cells that were never asked about. + * There is no row count on screen to compare against, so the footnote is the + * only signal that exists. + * + * ⚠️ Environment: jsdom, via this repo's `dom` vitest project. No assertion + * here depends on container size or on a media query — the note is a sibling + * in the component's own tree and is present or absent regardless of layout, + * which is deliberate: `happy-dom` never fires container-size effects and + * `jsdom` applies media-query rules irrespective of `innerWidth`, so a pin + * that leaned on either would be unmeasurable rather than merely flaky. + * + * REVERSE VERIFICATION — direction predicted before running: removing + * `$top: NON_GRID_ROW_CEILING_TOP` from `ObjectCalendar`'s record fetch turns + * the truncation case red at the footnote assertion, while the below-ceiling + * case stays green. + */ + +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 { ObjectCalendar } from './ObjectCalendar'; + +vi.mock('@object-ui/plugin-detail', () => ({ + RecordDetailDrawer: () => null, + deriveRecordPageHref: () => null, +})); + +const TOTAL_ROWS = 9876; +const NOW = new Date(); + +/** Events inside the month the calendar opens on, so they are drawable at all. */ +function makeRows(n: number) { + return Array.from({ length: n }, (_, i) => { + const d = new Date(NOW.getFullYear(), NOW.getMonth(), (i % 28) + 1); + const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate(), + ).padStart(2, '0')}`; + return { id: String(i + 1), subject: `Event ${i + 1}`, start_at: iso, end_at: iso }; + }); +} + +function makeDataSource(storeSize: number, calls: Array>) { + const store = makeRows(storeSize); + return { + find: vi.fn(async (_resource: string, params: any) => { + calls.push({ ...(params ?? {}) }); + const top = typeof params?.$top === 'number' ? params.$top : store.length; + return { data: store.slice(0, top), total: store.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: 'event', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text' }, + start_at: { name: 'start_at', type: 'date' }, + end_at: { name: 'end_at', type: 'date' }, + }, + })), + } as any; +} + +const schema: any = { + type: 'calendar', + objectName: 'event', + calendar: { titleField: 'subject', startDateField: 'start_at', endDateField: 'end_at' }, + data: { provider: 'object', object: 'event' }, +}; + +describe('objectui#7210 ruling a′ — the calendar caps at the platform ceiling, loudly', () => { + it('above the ceiling: the query stops at the ceiling and BOTH numbers are named', async () => { + const calls: Array> = []; + const dataSource = makeDataSource(TOTAL_ROWS, calls); + + render(); + + await waitFor(() => expect(calls.length).toBeGreaterThan(0)); + for (const params of calls) { + expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + } + + const note = await screen.findByRole('note'); + expect(note.getAttribute('data-row-ceiling-note')).toBe('non-grid'); + expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); + expect(note.textContent).toContain(String(TOTAL_ROWS)); + }); + + it('below the ceiling: there is NO footnote', async () => { + const calls: Array> = []; + const dataSource = makeDataSource(12, calls); + + render(); + + await waitFor(() => expect(calls.length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.queryByText(/Loading/i)).toBeNull()); + expect(screen.queryByRole('note')).toBeNull(); + }); +}); diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 76da0f45fb..630957c4c9 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -33,6 +33,11 @@ import { extractWriteErrorMessage, isPermissionError, declaredUserMessage, + useSettledSchema, + NON_GRID_ROW_CEILING, + NON_GRID_ROW_CEILING_TOP, + applyNonGridRowCeiling, + NonGridRowCeilingNote, } from '@object-ui/react'; import { RecordDetailDrawer, deriveRecordPageHref } from '@object-ui/plugin-detail'; import { usePermissions } from '@object-ui/permissions'; @@ -52,7 +57,6 @@ import { toast, } from '@object-ui/components'; import { - extractRecords, buildExpandFields, convertSortToQueryParams, getRecordDisplayName, @@ -199,12 +203,15 @@ export const ObjectCalendar: React.FC = ({ const [data, setData] = useState(hasExternalData ? externalData! : []); const [loading, setLoading] = useState(hasExternalData ? (externalLoading ?? false) : true); const [error, setError] = useState(null); - // The object-schema read and the fact that it has SETTLED are ONE piece of - // state, keyed by the object it belongs to (objectui#6453). The derived - // `objectSchema` / `objectSchemaReady` pair lives further down, next to - // `dataConfig`, because the key is the object the RECORD QUERY will use. - const [schemaResolution, setSchemaResolution] = - useState<{ key: string; def: any } | null>(null); + /** + * Did the platform row ceiling bite, and how large was the whole filtered + * result set (objectui#7210)? Carried from the response that knew it — + * `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 [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. @@ -309,8 +316,21 @@ export const ObjectCalendar: React.FC = ({ * forever. "Settled with nothing" and "not yet settled" are different states * and only the second may hold the query. */ - const objectSchemaReady = schemaResolution !== null && schemaResolution.key === schemaKey; - const objectSchema = objectSchemaReady ? schemaResolution.def : null; + // + // Since objectui#7225 (maintainer ruling B, 2026-09-02) this is the SHARED + // `useSettledSchema`. `ObjectCalendar` was #6482's named obstacle to that + // convergence, and the obstacle turned out to be about the GATE half, which + // the hook deliberately leaves local (the record effect below still gates + // only its `object`-provider branch). The RESOLUTION half fits via the + // recipe the hook's own doc comment prescribes for this component by name: + // an inline `value` data set issues no metadata read, so it is expressed as + // "there is no source to read from" — `dataSource: undefined` — rather than + // as a second "should fetch" flag. The hook settles-with-`null` on that + // path, which is exactly what the hand copy's `hasInlineData` exit did. + const { ready: objectSchemaReady, def: objectSchema } = useSettledSchema( + schemaKey, + hasInlineData ? undefined : dataSource, + ); // Permissions context, read here rather than inside the fetch effect below: // an effect's DEPENDENCY ARRAY is evaluated during render, so `perms` has to @@ -363,6 +383,7 @@ export const ObjectCalendar: React.FC = ({ if (hasInlineData && dataProvider === 'value') { if (isMounted) { setData(dataItems as any[]); + setRowCeiling({ truncated: false }); setLoading(false); } return; @@ -425,13 +446,21 @@ export const ObjectCalendar: React.FC = ({ const result = await dataSource.find(objectName, { $filter: schema.filter, $orderby: convertSortToQueryParams(schema.sort), + // The platform ceiling (objectui#7210, ruling a′). A calendar + // still fetches the whole FILTERED set — it cannot lay out a month + // from a page whose rows all fall in one week — but the fetch now + // 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, ...(expand.length > 0 ? { $expand: expand } : {}), }); - const items: any[] = extractRecords(result); + const capped = applyNonGridRowCeiling(result); if (isMounted) { - setData(items); + setData(capped.rows); + setRowCeiling({ truncated: capped.truncated, total: capped.total }); } } else if (dataProvider === 'api') { console.warn('API provider not yet implemented for ObjectCalendar'); @@ -453,38 +482,6 @@ export const ObjectCalendar: React.FC = ({ }, [hasExternalData, dataProvider, schemaObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, refreshKey, objectSchemaReady, objectSchema, perms]); - // Fetch object schema for field metadata. - // - // Every exit settles the resolution — success, failure, and "there is nothing - // to read from" alike — because the record query above WAITS on this - // (objectui#6453). A path that returned without settling would not merely - // skip the expansion, it would hold that query open forever. - useEffect(() => { - let isMounted = true; - const key = schemaKey; - const fetchObjectSchema = async () => { - // No source for a schema — including an inline (`value`) data set, which - // issues no metadata read here and did not before. Settle with none, so - // anything gated on this still runs (unexpanded: with no schema there is - // no expand set to derive, which is the same query these cases produced - // before). - if (hasInlineData || !dataSource || !key || typeof dataSource.getObjectSchema !== 'function') { - if (isMounted) setSchemaResolution({ key, def: null }); - return; - } - try { - const schemaData = await dataSource.getObjectSchema(key); - if (isMounted) setSchemaResolution({ key, def: schemaData }); - } catch (err) { - console.error('Failed to fetch object schema:', err); - if (isMounted) setSchemaResolution({ key, def: null }); - } - }; - - fetchObjectSchema(); - return () => { isMounted = false; }; - }, [schemaKey, dataSource, hasInlineData]); - // Transform data to calendar events, and separate out the records that have // no date to be placed on at all (objectui#7071 — see the early return in the // loop below). ONE pass, so the two lists are always answers about the same @@ -849,6 +846,14 @@ export const ObjectCalendar: React.FC = ({ onTimeRangeSelect={handleTimeRangeSelectDefault} /> + {/* 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. */} + {/* 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 802aebabba..8364ac0b3f 100644 --- a/packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx @@ -15,14 +15,21 @@ * * `filter` and `sort` DO map here (`$filter` / `$orderby` on the reload), while a * column list and a row cap do not — a gantt projects the fields its `gantt` - * config names and its reload issues no `$top` at all. Those keys are left - * unmapped rather than parked on a key nothing reads. + * config names and takes its `$top` from the PLATFORM, never from the binding. + * Those keys are left unmapped rather than parked on a key nothing reads. + * + * ⚠️ The row-cap case below changed shape with objectui#7210's ruling a′ and + * kept its point. It used to assert `$top` was absent entirely; the reload now + * always carries the platform ceiling. What it pins is unchanged: an AUTHORED + * `limit: 3` still does not reach the wire — the ceiling is "a named constant + * in the renderer, not an authorable view key", so no binding can be the thing + * that decides how many rows a chart draws. */ import { describe, it, expect, vi } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; -import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { SchemaRenderer, SchemaRendererProvider, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); @@ -142,7 +149,7 @@ describe('object-gantt — dataSource: { object, view } (objectstack#7121)', () expect(adapter.find).not.toHaveBeenCalled(); }); - it('writes NO row cap: the gantt reload issues no $top for one to land on', async () => { + it('writes NO row cap: an authored `limit` never reaches the $top, which is the platform ceiling', async () => { const adapter = makeAdapter(); renderBlock( { type: 'object-gantt', gantt: GANTT, dataSource: { object: 'task', view: 'hot', limit: 3 } }, @@ -153,7 +160,8 @@ describe('object-gantt — dataSource: { object, view } (objectstack#7121)', () const [, params] = adapter.find.mock.calls[0] as [string, any]; // 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).toBeUndefined(); + expect(params.$top).not.toBe(3); + expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); expect(params.options).toBeUndefined(); }); diff --git a/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx b/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx index 14765d50b9..3b147548c6 100644 --- a/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.expandFls-7230.test.tsx @@ -125,12 +125,33 @@ function makeDataSource() { * `waitFor` targets a `find('task', …)` issued after `getObjectSchema` settled, * so "the gantt stopped fetching" times out rather than reading as an empty * expansion — the ghost-assertion guard this family of pins requires. + * + * ⭐ THE SECOND `waitFor` READS "AT LEAST ONE CALL", NOT "MORE THAN ONE", and + * the difference is the whole reason this helper needed re-expressing when + * objectui#7225 landed beside it. What this file pins is the CONTENT of + * `$expand`; the call COUNT was only ever the means of telling the + * schema-dependent query apart from the one the gantt used to issue BEFORE the + * schema arrived. objectui#7225 ask 2 deleted that first query — the object + * schema now GATES the fetch instead of refining it afterwards, so `reload` + * fires once, with the schema already in hand, and + * `ObjectGantt.fetchGate-7225.test.tsx` pins that count at exactly one. + * `toBeGreaterThan(1)` was therefore not a stronger assertion under the gate, + * it was an unsatisfiable one: it waited for a second query that no longer + * exists, and the six pins below timed out while the behaviour they grade — + * FLS on the expansion — was fully intact. + * + * Nothing is given up by the change. Every recorded `find` is now + * schema-dependent by construction, so "read the last call" reads the right + * one; and a gantt that stopped fetching altogether still TIMES OUT here rather + * than returning `[]`, which is the guard the paragraph above exists for. This + * is the shape the sibling pin in the same family already uses for a component + * whose fetch was gated first — `__tests__/ObjectCalendar.expandFls-7230.test.tsx`. */ async function expandFor(): Promise { const ds = makeDataSource(); render(); await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); - await waitFor(() => expect(ds.find.mock.calls.length).toBeGreaterThan(1)); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); return (ds.find.mock.calls.at(-1)?.[1]?.$expand ?? []) as string[]; } diff --git a/packages/plugin-gantt/src/ObjectGantt.fetchGate-7225.test.tsx b/packages/plugin-gantt/src/ObjectGantt.fetchGate-7225.test.tsx new file mode 100644 index 0000000000..16729a0c40 --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.fetchGate-7225.test.tsx @@ -0,0 +1,190 @@ +/** + * 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#7225 ask 2 — the gantt's DUPLICATE query is gated (maintainer + * ruling B, 2026-09-02; objectui#6482's undischarged gating half). + * + * Before this, `reload` listed `objectSchema` in its dependency list, so a + * mount issued TWO `find` calls: one before the object schema settled, with + * `buildExpandFields` seeing no fields and therefore NO `$expand` at all, and + * one after. Measured on this component with an instrumented adapter over + * three latency profiles — invariably 2 `find` and 1 `getObjectSchema` per + * load, expand sets `[null, ['owner']]`. + * + * ⭐ Why gating pays HERE, per #6482's own per-component standard (it measured + * three profiles and found the cost differs by component, so the table is not + * an argument on its own): the gantt is not the mild kanban-style "round trip + * bought and thrown away". Whenever the metadata read is the slower of the two + * — the common case on a cold `MetadataCache` — the user sees the full + * THREE-STEP PAINT: raw foreign-key ids, back to the loading placeholder, then + * the expanded rows. + * + * ⛔ GATING IS NOT CAPPING. objectui#7210's ruling a' put a row ceiling on + * these queries and that is a different change on the same lines; this file + * asserts only HOW MANY queries go out and whether the first one carries its + * expansion. The ceiling has its own pin + * (`ObjectGantt.rowCeiling-7210.test.tsx`). + * + * ⚠️ The gate is only safe because the schema resolution now SETTLES ON EVERY + * EXIT (objectui#7232): the component's own effect used to return without + * settling on `!effectiveDataSource`, on `!resource` and in its `catch`, which + * cost nothing while nothing waited on it — and would hold a gated query open + * FOREVER. `useSettledSchema` settles on all three, and the last two cases + * below are that trap, pinned: a chart that never loads is the failure this + * file exists to make impossible. + * + * REVERSE VERIFICATION — prediction MISSED, reported rather than adjusted. + * Predicted before running: deleting + * `if (recordQueryDerivesExpand && !objectSchemaReady) return;` from + * `ObjectGantt` turns the first two cases red and leaves the two + * settle-on-every-exit cases green — 2 failed / 3 passed. OBSERVED: + * **4 failed / 1 passed**. Direction as predicted, magnitude higher, and the + * reason is worth writing down: the prediction assumed the second query came + * from `objectSchema`'s identity changing, so a definition that settles as + * `null` (no `getObjectSchema`, or a read that threw) would still produce one + * query. It does not. The second query comes from `objectSchemaReady` being in + * the effect's DEPENDENCY LIST — it flips `false` to `true` on every path, + * including both settle-with-nothing paths, so with the early return deleted + * the effect re-runs and reloads a second time in all four adapter cases. The + * inline-`value` case is the one that stays green, since it never queries at + * all. The gate line and the dependency are two halves of one mechanism, and + * the ablation only removed one of them. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ObjectGantt } from './ObjectGantt'; + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); + +vi.mock('./GanttView', () => ({ + GanttView: ({ tasks }: any) => ( +
+ ), +})); + +vi.mock('@object-ui/plugin-detail', () => ({ + RecordDetailDrawer: () => null, + deriveRecordPageHref: () => null, +})); + +const ROWS = [ + { id: '1', subject: 'Renewal', owner: 'u1', visible_from: '2026-01-01', due_date: '2026-01-05' }, +]; + +/** A definition with a lookup, so a gated query has a real `$expand` to carry. */ +const OBJECT_DEF = { + name: 'task', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text' }, + owner: { name: 'owner', type: 'lookup', reference_to: 'user' }, + visible_from: { name: 'visible_from', type: 'date' }, + due_date: { name: 'due_date', type: 'date' }, + }, +}; + +function makeAdapter(getObjectSchema?: any) { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + ...(getObjectSchema === undefined ? {} : { getObjectSchema }), + } as any; +} + +const schema: any = { + type: 'object-gantt', + objectName: 'task', + gantt: { titleField: 'subject', startDateField: 'visible_from', endDateField: 'due_date' }, +}; + +/** The expand sets of every issued query, in order. */ +function expandSets(adapter: any): Array { + return adapter.find.mock.calls.map(([, params]: [string, any]) => params?.$expand ?? null); +} + +describe('objectui#7225 ask 2 — the gantt waits for the object schema instead of querying twice', () => { + it('issues ONE query per load, and it already carries the expansion', async () => { + const adapter = makeAdapter(vi.fn(async () => OBJECT_DEF)); + + render(); + + await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + + // The old regime's signature was `[null, ['owner']]`. One expanded call. + expect(expandSets(adapter)).toEqual([['owner']]); + expect(adapter.getObjectSchema).toHaveBeenCalledTimes(1); + }); + + it('never issues an UNEXPANDED query for an object that declares a lookup', async () => { + const adapter = makeAdapter(vi.fn(async () => OBJECT_DEF)); + + render(); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + // The discarded round trip — and the raw-id frame it painted — is the + // thing gating removes. Not "fewer" unexpanded calls: none. + for (const params of adapter.find.mock.calls.map(([, p]: [string, any]) => p)) { + expect(params.$expand).toEqual(['owner']); + } + }); + + it('still queries when the adapter exposes NO `getObjectSchema` — the gate is on SETTLED, not on truthy', async () => { + // objectui#7232's trap: an exit that returns without settling would hold + // this query open forever, and the chart would never load. + const adapter = makeAdapter(undefined); + + render(); + + await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); + expect(adapter.find).toHaveBeenCalledTimes(1); + // Nothing to derive an expand set from, so the query is unexpanded — which + // is the same query this case produced before the gate. + expect(expandSets(adapter)).toEqual([null]); + }); + + it('still queries when the definition read REJECTS', async () => { + const adapter = makeAdapter( + vi.fn(async () => { + throw new Error('metadata endpoint down'); + }), + ); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + render(); + + await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); + expect(adapter.find).toHaveBeenCalledTimes(1); + expect(expandSets(adapter)).toEqual([null]); + expect(String(error.mock.calls[0]?.[0] ?? '')).toContain('[useSettledSchema]'); + } finally { + error.mockRestore(); + } + }); + + it('an inline `value` data set paints without waiting for any metadata read', async () => { + const inline: any = { + ...schema, + data: { provider: 'value', items: ROWS }, + }; + const adapter = makeAdapter(vi.fn(async () => OBJECT_DEF)); + + render(); + + await waitFor(() => + expect(screen.getByTestId('gantt-view').getAttribute('data-task-count')).toBe('1'), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx b/packages/plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx index 022c18265a..86e76a4df7 100644 --- a/packages/plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx @@ -24,15 +24,24 @@ * Two facts are pinned, because the footer correction rests on both: * * 1. the rows drawn are the ADAPTER's, not the host `data` prop's; - * 2. the query carries no `$top` — so its row count is not the host's page - * size and cannot be made to be by authoring `pagination.pageSize`. + * 2. the query's `$top` is the PLATFORM CEILING — so its row count is not the + * host's page size and cannot be made to be by authoring + * `pagination.pageSize`. * * ⛔ Do not "fix" (1) by spreading `{...props}` in the renderer. That caps the * chart at the host's page — a complete schedule silently becomes a truncated - * one that still looks like a schedule. Whether a non-grid view may fetch - * unbounded at all is an open maintainer decision (objectui#7210, half 2) and - * is not settled here; this file only records what the code does today, which - * is what the footer has to stop contradicting either way. + * one that still looks like a schedule. + * + * ⚠️ Case (2) changed shape when objectui#7210's half 2 was RULED (a′, + * 2026-09-02) and kept its point. When this file was written the answer was + * open and the query carried no `$top` at all; the ruling settled it — a + * non-grid view may fetch the whole filtered set up to a PLATFORM CEILING + * expressed as a named constant in the renderer, and past it must say so + * loudly. So the assertion moves from "no cap" to "the cap is the platform's", + * which is what still separates it from the forbidden direction: `pageSize: 2` + * is on this schema and the query is not 2 rows wide. The pin that the host's + * page cannot bound the chart is unchanged; only the reason the number is not + * the host's has been written down. * * REVERSE VERIFICATION — direction and counts predicted before running: add * `{...props}` to the `ObjectGanttRenderer` children callback and BOTH @@ -43,7 +52,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react'; +import { SchemaRendererProvider, SchemaRenderer, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react'; import './index'; vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); @@ -133,7 +142,7 @@ describe('objectui#7210 — object-gantt ignores a host `data` prop', () => { expect(calls.length).toBeGreaterThan(0); }); - it('issues its query with no `$top` — the host page size cannot bound it', async () => { + it('issues its query at the PLATFORM ceiling — the host page size cannot bound it', async () => { const dataSource = makeDataSource(); render( @@ -145,7 +154,10 @@ describe('objectui#7210 — object-gantt ignores a host `data` prop', () => { await waitFor(() => expect(calls.length).toBeGreaterThan(0)); for (const params of calls) { - expect(params.$top).toBeUndefined(); + // 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.$skip).toBeUndefined(); } }); diff --git a/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx b/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx new file mode 100644 index 0000000000..05cc979090 --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx @@ -0,0 +1,165 @@ +/** + * 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, half 2 — maintainer ruling a′ (2026-09-02), on the gantt. + * + * The ruling, and the pin it names: a non-grid visualisation may fetch the + * whole FILTERED result set, because a truthful range needs all of it, but the + * fetch carries a platform-level hard ceiling expressed as a named constant in + * the renderer, not an authorable view key. Past the ceiling the visualisation + * draws the first N rows and shows a LOUD FOOTNOTE naming both N and M. + * Below it: no footnote, and the full set draws. + * + * ⛔ The direction the ruling calls dangerous is SILENT truncation — "a cut-off + * schedule still looks like a schedule". That is precisely what these cases + * exist to make impossible to reintroduce: the first one would still pass if + * the footnote were deleted and only the cap kept, so it asserts the note's + * text and BOTH numbers, not merely the row count. + * + * ⛔ This is the CAP, not the gate. objectui#7225's half gates the gantt's + * DUPLICATE schema-driven query; nothing here is about that, and the two are + * separate commits on this branch for that reason. + * + * REVERSE VERIFICATION — direction and counts predicted before running: + * removing `$top: NON_GRID_ROW_CEILING_TOP` from `ObjectGantt`'s reload turns + * the truncation case red at the FOOTNOTE assertion (no probe row ⇒ + * `truncated` false ⇒ no note) while the below-ceiling case stays green, + * i.e. 1 failed / 2 passed. + */ + +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 { ObjectGantt } from './ObjectGantt'; + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); + +// The bar canvas is irrelevant here — every assertion is about how many rows +// reached the chart and what the component says about them. Same stub the +// sibling ObjectGantt tests use, for the same reason. +vi.mock('./GanttView', () => ({ + GanttView: ({ tasks }: any) => ( +
+ ), +})); + +vi.mock('@object-ui/plugin-detail', () => ({ + RecordDetailDrawer: () => null, + deriveRecordPageHref: () => null, +})); + +/** The whole filtered result set the store holds. */ +const TOTAL_ROWS = 4321; + +function makeRows(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: String(i + 1), + subject: `Task ${i + 1}`, + visible_from: '2026-01-01', + due_date: '2026-12-31', + })); +} + +let calls: Array> = []; + +/** An adapter that HONOURS `$top`, the way a real one does. */ +function makeDataSource(storeSize: number) { + const store = makeRows(storeSize); + return { + find: vi.fn(async (_resource: string, params: any) => { + calls.push({ ...(params ?? {}) }); + const top = typeof params?.$top === 'number' ? params.$top : store.length; + return { data: store.slice(0, top), total: store.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: 'duly_task', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text' }, + visible_from: { name: 'visible_from', type: 'date' }, + due_date: { name: 'due_date', type: 'date' }, + }, + })), + } as any; +} + +const schema: any = { + type: 'object-gantt', + objectName: 'duly_task', + gantt: { + titleField: 'subject', + startDateField: 'visible_from', + endDateField: 'due_date', + }, +}; + +describe('objectui#7210 ruling a′ — the gantt draws at most the platform ceiling, loudly', () => { + beforeEach(() => { + calls = []; + }); + + it('above the ceiling: draws exactly N rows and names BOTH N and M', async () => { + const dataSource = makeDataSource(TOTAL_ROWS); + + render(); + + await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); + await waitFor(() => + expect(screen.getByTestId('gantt-view').getAttribute('data-task-count')).toBe( + String(NON_GRID_ROW_CEILING), + ), + ); + + // The fetch asked for one probe row past the ceiling — that is what makes + // 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); + } + + // ⭐ The half the ruling cares about: the truncation is NOT silent. + const note = await screen.findByRole('note'); + expect(note.getAttribute('data-row-ceiling-note')).toBe('non-grid'); + expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); + expect(note.textContent).toContain(String(TOTAL_ROWS)); + expect(note.textContent).toMatch(/narrow the filter/i); + }); + + it('below the ceiling: the full set draws and there is NO footnote', async () => { + const dataSource = makeDataSource(7); + + render(); + + await waitFor(() => + expect(screen.getByTestId('gantt-view').getAttribute('data-task-count')).toBe('7'), + ); + expect(screen.queryByRole('note')).toBeNull(); + }); + + it('an inline `value` data set is never capped by us, and never footnoted', async () => { + const inline: any = { + ...schema, + data: { provider: 'value', items: makeRows(NON_GRID_ROW_CEILING_TOP + 500) }, + }; + + render(); + + await waitFor(() => + expect(screen.getByTestId('gantt-view').getAttribute('data-task-count')).toBe( + String(NON_GRID_ROW_CEILING_TOP + 500), + ), + ); + expect(screen.queryByRole('note')).toBeNull(); + }); +}); diff --git a/packages/plugin-gantt/src/ObjectGantt.staleReloadFinally-7231.test.tsx b/packages/plugin-gantt/src/ObjectGantt.staleReloadFinally-7231.test.tsx index 9fc250af4e..4364c8565e 100644 --- a/packages/plugin-gantt/src/ObjectGantt.staleReloadFinally-7231.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.staleReloadFinally-7231.test.tsx @@ -36,6 +36,24 @@ * covers include the toolbar refresh and the write-readback paths, where two * reloads legitimately overlap and no schema gating is involved — see the * card for why this must not be folded into the gating work. + * + * ⚠️ HOW THE OVERLAP IS PRODUCED changed with objectui#7225's gating, and the + * property under test did not. This file used to generate its two in-flight + * reloads out of the gantt's DUPLICATE mount query: `getObjectSchema` resolved, + * re-keyed `reload`, and issued a second `find` while the first was still + * pending — so every case opened with + * `expect(find.mock.calls.length).toBe(2)`. That duplicate is exactly what + * objectui#7225 ask 2 removed: the record query is now GATED on the settled + * schema, so a mount issues ONE `find`, and a test that waits for two would + * wait forever. + * + * So the overlap is now generated from a pair that is real, is the reason the + * guard exists, and is untouched by gating: a SILENT toolbar refresh + * superseded by a non-silent filter-change reload. That is what case 3 always + * used; cases 1 and 2 now use it too. Not one assertion about the guard has + * been weakened — the orderings (stale-first, fresh-first), the flags and the + * outcomes are the same. The `finally` guard itself is not modified by that + * card or this one. */ import React from 'react'; @@ -105,9 +123,8 @@ function deferred(): Deferred { /** * A data source whose every `find()` hands back a promise the test resolves * by hand, so reload N and reload N+1 can be held in flight together and - * completed in either order. `getObjectSchema` resolves immediately — that is - * what issues the second reload (`objectSchema` is a `reload` dependency) - * while the first `find()` is still pending. + * completed in either order. `getObjectSchema` resolves immediately, which is + * what opens the gate and lets the mount's single `find()` go out. */ function makeDeferredDataSource() { const finds: Deferred[] = []; @@ -141,27 +158,57 @@ async function flush() { }); } +/** + * Bring the component to the state every case below needs: painted, then two + * reloads in flight at once — a SILENT toolbar refresh (`finds[1]`, owns + * `refreshing`) superseded by a non-silent filter-change reload (`finds[2]`, + * owns `loading`). + * + * The mount issues exactly ONE `find` since objectui#7225's gate; that single + * assertion is also this file's live control on the gate, because a + * regression back to the duplicate query makes `toBe(1)` fail here loudly + * instead of silently restoring the old overlap generator. + */ +async function paintThenOverlap( + dataSource: DataSource, + finds: Deferred[], + rerender: (ui: React.ReactElement) => void, +) { + await waitFor(() => expect((dataSource.find as any).mock.calls.length).toBe(1)); + await settle(finds[0], ROWS_A); + await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); + + fireEvent.click(screen.getByTestId('gv-refresh')); + await waitFor(() => expect((dataSource.find as any).mock.calls.length).toBe(2)); + await waitFor(() => + expect(screen.getByTestId('gantt-view').getAttribute('data-refreshing')).toBe('true'), + ); + + rerender(); + await waitFor(() => expect((dataSource.find as any).mock.calls.length).toBe(3)); +} + describe('ObjectGantt — a superseded reload must not clear the loading state (objectui#7231)', () => { it('keeps the placeholder up when the STALE reload finishes first and the fresh one is still in flight', async () => { const { dataSource, finds } = makeDeferredDataSource(); - render(); + const { rerender } = render(); + await paintThenOverlap(dataSource, finds, rerender); - // Reload #1 (mount) is in flight; the object schema resolves and re-keys - // `reload`, issuing reload #2 before #1 has answered. - await waitFor(() => expect((dataSource.find as any).mock.calls.length).toBe(2)); + // The non-silent reload owns `loading`, so the placeholder is up and the + // chart is unmounted while both are in flight. expect(screen.getByText(PLACEHOLDER)).toBeTruthy(); - // The superseded reload #1 answers first — the ordinary ordering. - await settle(finds[0], ROWS_A); + // The superseded (silent) reload answers first — the ordinary ordering. + await settle(finds[1], ROWS_A); // Its `finally` must NOT clear `loading`: the fresh query has not answered, // so releasing the placeholder here paints an empty chart. expect(screen.getByText(PLACEHOLDER)).toBeTruthy(); expect(screen.queryByTestId('gantt-view')).toBeNull(); - // The current reload #2 answers and owns the transition out of loading. - await settle(finds[1], ROWS_B); + // The current reload answers and owns the transition out of loading. + await settle(finds[2], ROWS_B); await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); expect(screen.getByText('From the fresh query')).toBeTruthy(); @@ -171,19 +218,18 @@ describe('ObjectGantt — a superseded reload must not clear the loading state ( it('control — the fresh reload finishing FIRST paints its rows, and the late stale answer changes nothing', async () => { const { dataSource, finds } = makeDeferredDataSource(); - render(); - - await waitFor(() => expect((dataSource.find as any).mock.calls.length).toBe(2)); + const { rerender } = render(); + await paintThenOverlap(dataSource, finds, rerender); - // Out-of-order: the current reload #2 answers before the superseded #1. - await settle(finds[1], ROWS_B); + // Out-of-order: the current reload answers before the superseded one. + await settle(finds[2], ROWS_B); await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); expect(screen.getByText('From the fresh query')).toBeTruthy(); // The late stale answer must neither clobber the data (the pre-existing // `setData` guard) nor put the placeholder back. - await settle(finds[0], ROWS_A); + await settle(finds[1], ROWS_A); await flush(); expect(screen.getByTestId('gantt-view')).toBeTruthy(); @@ -196,33 +242,18 @@ describe('ObjectGantt — a superseded reload must not clear the loading state ( const { dataSource, finds } = makeDeferredDataSource(); const { rerender } = render(); + await paintThenOverlap(dataSource, finds, rerender); - await waitFor(() => expect((dataSource.find as any).mock.calls.length).toBe(2)); - await settle(finds[0], ROWS_A); - await settle(finds[1], ROWS_A); - await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); - expect(screen.getByTestId('gantt-view').getAttribute('data-refreshing')).toBe('false'); - - // Toolbar refresh → reload #3, silent: it owns `refreshing`, not `loading`. - fireEvent.click(screen.getByTestId('gv-refresh')); - await waitFor(() => - expect(screen.getByTestId('gantt-view').getAttribute('data-refreshing')).toBe('true'), - ); - - // A filter change re-keys `reload` → reload #4, non-silent, superseding the - // silent one while it is still in flight. Different flag, same sequence. - rerender(); - await waitFor(() => expect((dataSource.find as any).mock.calls.length).toBe(4)); expect(screen.getByText(PLACEHOLDER)).toBeTruthy(); // The superseded silent reload answers: it must touch neither flag. - await settle(finds[2], ROWS_A); + await settle(finds[1], ROWS_A); expect(screen.getByText(PLACEHOLDER)).toBeTruthy(); // The current reload answers. Nothing is in flight any more, so BOTH flags // must be honest — a guard that only cleared `loading` here would leave the // refresh button spinning forever. - await settle(finds[3], ROWS_B); + await settle(finds[2], ROWS_B); await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy()); expect(screen.getByTestId('gantt-view').getAttribute('data-refreshing')).toBe('false'); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index dcf6df2c43..04952fbdf0 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -31,7 +31,15 @@ import { GanttConfigSchema } from '@objectstack/spec/ui'; // ref, `resolveKeyedI18nLabel` in `@object-ui/react`), and neither accepts the // other's shape. This one resolves the spec's INLINE locale MAP. import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; -import { useNavigationOverlay, SchemaRendererContext } from '@object-ui/react'; +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'; import { RecordDetailDrawer, deriveRecordPageHref } from '@object-ui/plugin-detail'; import { usePermissions } from '@object-ui/permissions'; @@ -574,7 +582,19 @@ export const ObjectGantt: React.FC = ({ const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [objectSchema, setObjectSchema] = useState(null); + /** + * Did the platform row ceiling bite on the rows currently drawn, and how + * large was the whole filtered result set (objectui#7210)? + * + * State rather than a value derived from `data.length`: once the rows are + * capped, `data.length === NON_GRID_ROW_CEILING` is exactly what a result + * set of exactly the ceiling ALSO looks like, so the fact has to be carried + * from the response that knew it. Every path that sets `data` sets this too + * — a host `data` prop and an inline `value` set are never truncated by us. + */ + const [rowCeiling, setRowCeiling] = useState<{ truncated: boolean; total?: number }>({ + truncated: false, + }); // 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 @@ -640,6 +660,31 @@ export const ObjectGantt: React.FC = ({ const resource = dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName ?? ''; + /** + * The object schema, and whether the read for THIS object has SETTLED — + * one piece of state, through the shared hook (objectui#7225, maintainer + * ruling B; the gantt gating is ask 2 of that card, handled here with + * objectui#7210 because it is the same component). + * + * This replaces a local `useState` plus an effect whose exits — `if + * (!effectiveDataSource) return;`, `if (!resource) return;`, and its + * `catch` — returned WITHOUT settling anything (objectui#7232). That was + * harmless only while the record query was ungated: nothing was listening. + * The gate below listens, and an exit that never settles would hold the + * chart's query open forever — a chart that never loads, on a code path + * that reads as correct. The hook settles on every exit by construction, + * which is why the gate can be added at all. + * + * An inline `value` data set reads no metadata and never did, so it is + * expressed as "there is no source to read from" (`dataSource: undefined`) + * rather than as a second enable flag — the recipe the hook's doc comment + * prescribes. + */ + const { ready: objectSchemaReady, def: objectSchema } = useSettledSchema( + resource, + hasInlineData ? undefined : effectiveDataSource, + ); + // Permissions context, read here rather than inside `reload` below: a // `useCallback`'s DEPENDENCY ARRAY is evaluated during render, so `perms` has // to be a binding that already exists by the time render reaches it @@ -663,12 +708,18 @@ export const ObjectGantt: React.FC = ({ else setLoading(true); // 1. Check for data prop (Unified ListView) if ((rest as any).data && Array.isArray((rest as any).data)) { - if (isCurrent()) setData((rest as any).data); + if (isCurrent()) { + setData((rest as any).data); + setRowCeiling({ truncated: false }); + } return; } if (hasInlineData && dataProvider === 'value') { - if (isCurrent()) setData(dataItems as any[]); + if (isCurrent()) { + setData(dataItems as any[]); + setRowCeiling({ truncated: false }); + } return; } @@ -710,9 +761,23 @@ export const ObjectGantt: React.FC = ({ const result = await effectiveDataSource.find(resource, { $filter: schema.filter, $orderby: convertSortToQueryParams(schema.sort), + // The platform ceiling (objectui#7210, ruling a′). The gantt still + // fetches the whole FILTERED result set — a truthful + // `min(start) → max(end)` range and the group rollups need all of it, + // which is why paging this at `pagination.pageSize` was rejected — but + // "the whole result set" now stops at a number instead of at whatever + // the table happens to hold. One probe row past the ceiling is what + // 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, ...(expand.length > 0 ? { $expand: expand } : {}), }); - if (isCurrent()) setData(extractRecords(result)); + const capped = applyNonGridRowCeiling(result); + if (isCurrent()) { + setData(capped.rows); + setRowCeiling({ truncated: capped.truncated, total: capped.total }); + } } catch (err) { if (silent) { // Background refresh failure keeps the last good data on screen. @@ -743,31 +808,39 @@ export const ObjectGantt: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps -- (rest as any).data intentionally untracked, matching the original effect }, [effectiveDataSource, resource, hasInlineData, dataProvider, dataItems, schema.filter, schema.sort, objectSchema, perms]); - useEffect(() => { - reload(); - }, [reload]); + /** + * Does the query this effect is about to issue DERIVE anything from the + * object schema? Only the adapter branch does. A host-supplied `data` array + * and an inline `value` set both paint with no metadata read at all, so + * gating them would hold a paint on a resolution that buys them nothing. + * Same scoping ObjectCalendar's gate uses, and for the same reason. + */ + const hasHostData = Array.isArray((rest as any).data); + const recordQueryDerivesExpand = !hasHostData && !hasInlineData; - // Fetch object schema for field metadata + // ⭐ objectui#7225 ask 2 (objectui#6482's undischarged gating half) — the + // object schema GATES this query; it does not refine it afterwards. + // + // Before this line the gantt issued TWO unbounded queries per load: `reload` + // lists `objectSchema` in its dependency list, so the effect ran once with + // the schema still unresolved (`buildExpandFields` saw no fields, so the + // query carried no `$expand` at all) and again once it landed. Measured on + // this component with an instrumented adapter, three latency profiles — 2 + // `find` calls and 1 `getObjectSchema` per load, expand sets `[null, + // ['owner']]` — the cost is NOT the mild "round trip bought and thrown + // away" the kanban showed: whenever the metadata read is the slower of the + // two, which is the common case on a cold `MetadataCache`, the user sees the + // full THREE-STEP PAINT — raw foreign-key ids, back to the loading + // placeholder, then the expanded rows. That is the profile #6482's + // per-component standard names as the one where gating pays. + // + // ⛔ Gating is not capping. The row ceiling is objectui#7210's ruling and + // lives on the query itself (`$top` above); this decides WHEN the query + // fires, not how many rows it may bring back. useEffect(() => { - const fetchObjectSchema = async () => { - try { - if (!effectiveDataSource) return; - if (!resource) return; - - const schemaData = await effectiveDataSource.getObjectSchema(resource); - setObjectSchema(schemaData); - } catch (err) { - console.error('Failed to fetch object schema:', err); - } - }; - - if (!hasInlineData && effectiveDataSource) { - fetchObjectSchema(); - } - // `dataConfig` was listed here but never read in this effect (`resource` - // already carries the one field — `object` — this effect needs from - // it); dropped rather than re-keyed (objectui#6592). - }, [resource, effectiveDataSource, hasInlineData]); + if (recordQueryDerivesExpand && !objectSchemaReady) return; + reload(); + }, [reload, recordQueryDerivesExpand, objectSchemaReady]); // Transform data to gantt tasks const tasks = useMemo(() => { @@ -1826,6 +1899,19 @@ export const ObjectGantt: React.FC = ({ /> )}
+ {/* objectui#7210 — the ceiling must never be crossed quietly. A gantt + 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). */} + {navigation.isOverlay && navigation.isOpen && navigation.selectedRecord && (() => { const rec = navigation.selectedRecord as Record; const detail = recordDetailHref(rec); diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 1609c92139..56a8acb649 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -16,6 +16,7 @@ import { extractWriteErrorMessage, isPermissionError, declaredUserMessage, + useSettledSchema, } from '@object-ui/react'; import { toast } from '@object-ui/components'; import { createSafeTranslation } from '@object-ui/i18n'; @@ -173,14 +174,14 @@ export const ObjectKanban: React.FC = ({ const [fetchedData, setFetchedData] = useState([]); // The object-definition read and the fact that it has SETTLED are one piece - // of state, keyed by the object it belongs to (objectui#6271). Two separate - // states could disagree for one commit — long enough for the record query to - // fire against the previous object's expand set — and a bare `objectDef` - // cannot express "settled with nothing", which is a legitimate outcome (an - // adapter with no `getObjectSchema`, or a read that threw). `key` is compared - // against the CURRENT object name during render, so switching objects closes - // the gate in the same commit that changes it, not one commit later. - const [schemaResolution, setSchemaResolution] = useState<{ key: string; def: any } | null>(null); + // of state, keyed by the object it belongs to (objectui#6271) — now the + // SHARED hook rather than this component's hand copy of it (objectui#7225, + // maintainer ruling B, 2026-09-02, which amends #6482's "migrate + // incidentally" to one convergence PR). `ready` is derived from a single + // `{ key, def }` state at render time, so "ready for the wrong object" stays + // unrepresentable; `useSettledSchema`'s own doc comment carries the full + // argument, including why a bare `objectDef` cannot express "settled with + // nothing". const schemaKey = schema.objectName ?? ''; /** * Has the object definition for THIS object finished resolving? Note what @@ -188,8 +189,7 @@ export const ObjectKanban: React.FC = ({ * `getObjectSchema`, or whose schema read failed, must still get its cards — * gating on a truthy definition would leave those boards empty forever. */ - const objectDefReady = schemaResolution !== null && schemaResolution.key === schemaKey; - const objectDef = objectDefReady ? schemaResolution.def : null; + const { ready: objectDefReady, def: objectDef } = useSettledSchema(schemaKey, dataSource); // loading state const [loading, setLoading] = useState(hasExternalData ? (externalLoading ?? false) : false); const [error, setError] = useState(null); @@ -218,35 +218,6 @@ export const ObjectKanban: React.FC = ({ } }, [externalLoading, hasExternalData]); - // Fetch object definition for metadata (labels, options). - // - // Every exit settles the resolution — success, failure, and "there is nothing - // to read from" alike — because the record query below WAITS on this - // (objectui#6271). A path that returned without settling would not merely - // skip the expansion, it would hold the query open forever. - useEffect(() => { - let isMounted = true; - const key = schema.objectName ?? ''; - const fetchMeta = async () => { - if (!dataSource || !schema.objectName || typeof dataSource.getObjectSchema !== 'function') { - // No source for a definition: settle with none, so the board still - // queries (unexpanded — with no schema there is no expand set to - // derive, which is the same query this case produced before). - if (isMounted) setSchemaResolution({ key, def: null }); - return; - } - try { - const def = await dataSource.getObjectSchema(schema.objectName); - if (isMounted) setSchemaResolution({ key, def }); - } catch (e) { - console.warn("Failed to fetch object def", e); - if (isMounted) setSchemaResolution({ key, def: null }); - } - }; - fetchMeta(); - return () => { isMounted = false; }; - }, [schema.objectName, dataSource]); - useEffect(() => { // Skip internal fetch when data is managed by a parent component if (hasExternalData) return; diff --git a/packages/plugin-kanban/src/__tests__/fetchGate.objectDef-6271.test.tsx b/packages/plugin-kanban/src/__tests__/fetchGate.objectDef-6271.test.tsx index d256ae87b1..c6c02656ef 100644 --- a/packages/plugin-kanban/src/__tests__/fetchGate.objectDef-6271.test.tsx +++ b/packages/plugin-kanban/src/__tests__/fetchGate.objectDef-6271.test.tsx @@ -191,15 +191,25 @@ describe('ObjectKanban gates its record query on the object definition (objectui await new Promise((r) => setTimeout(r, 10)); throw new Error('metadata endpoint down'); }); - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // ⚠️ The CHANNEL moved with objectui#7225's migration and this spy moved + // with it (maintainer ruling B, 2026-09-02, which names exactly this): + // the hand copy this component used to carry logged the rejected read on + // `console.warn`; `useSettledSchema` logs it on `console.error` with a + // bracketed prefix. Silencing the wrong channel here would have let the + // rejection print through the suite while this file still read as green, + // so the spy is ASSERTED on, not merely installed — a silenced channel + // nobody checks is how a moved log goes unnoticed. + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); try { const { container } = renderBoard(adapter); await waitFor(() => expect(container.textContent).toContain('Q3 renewal')); expect(adapter.find).toHaveBeenCalledTimes(1); expect(unexpandedCalls(adapter)).toHaveLength(1); expect(adapter.order).toEqual(['schema:issued', 'schema:settled', 'find']); + expect(error).toHaveBeenCalled(); + expect(String(error.mock.calls[0]?.[0] ?? '')).toContain('[useSettledSchema]'); } finally { - warn.mockRestore(); + error.mockRestore(); } }); diff --git a/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx b/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx index a079578c6f..f2181f30a9 100644 --- a/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx +++ b/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx @@ -14,12 +14,21 @@ * * `filter` and `sort` DO map here (`$filter` / `$orderby` on the fetch); a column * list and a row cap do not, a map projecting the fields its `map` config names - * and issuing no `$top`. + * and taking its `$top` from the PLATFORM, never from the binding. + * + * ⚠️ The row-cap case below changed shape with objectui#7210's ruling a′ and + * kept its point. It used to assert `$top` was absent entirely; the fetch now + * always carries the platform ceiling. What it pins is unchanged and is the + * half that matters here: an AUTHORED `limit: 3` still does not reach the + * wire — the ceiling is "a named constant in the renderer, not an authorable + * view key", so a binding cannot lower it, raise it, or otherwise be the thing + * that decides how many rows a map draws. */ 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 { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; // No WebGL in the test env — same stub the sibling ObjectMap tests use. Every @@ -129,7 +138,7 @@ describe('object-map — dataSource: { object, view } (objectstack#7121)', () => expect(adapter.find).not.toHaveBeenCalled(); }); - it('writes NO row cap: the map fetch issues no $top for one to land on', async () => { + it('writes NO row cap: an authored `limit` never reaches the $top, which is the platform ceiling', async () => { const adapter = makeAdapter(); renderBlock( { type: 'object-map', map: MAP, dataSource: { object: 'store', view: 'hot', limit: 3 } }, @@ -138,7 +147,10 @@ describe('object-map — dataSource: { object, view } (objectstack#7121)', () => await waitFor(() => expect(adapter.find).toHaveBeenCalled()); const [, params] = adapter.find.mock.calls[0] as [string, any]; - expect(params.$top).toBeUndefined(); + // 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); }); it('leaves a map with NO dataSource exactly as it was', async () => { diff --git a/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx b/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx new file mode 100644 index 0000000000..37b763f503 --- /dev/null +++ b/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx @@ -0,0 +1,113 @@ +/** + * 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, half 2 — maintainer ruling a′ (2026-09-02), on the map. + * + * The map's own defence — it auto-clusters above 100 markers — is exactly what + * makes a silent cut invisible here: cluster bubbles redraw at whatever counts + * they are given, so a map of the first N of a much larger set is a plausible + * map with plausible bubbles, and its CAMERA is fitted to a bounding box that + * is not the data's. The footnote is the only thing that says so. + * + * ⚠️ Environment: jsdom, with `react-map-gl/maplibre` stubbed the way the + * sibling ObjectMap tests stub it (no WebGL in this lane). Nothing asserted + * here depends on container size or on the real map's viewport — the note is a + * sibling in the component's own tree, present or absent regardless of layout. + * + * REVERSE VERIFICATION — direction predicted before running: removing + * `$top: NON_GRID_ROW_CEILING_TOP` from `ObjectMap`'s fetch turns the + * truncation case red at the footnote assertion, while the below-ceiling case + * stays green. + */ + +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 { ObjectMap } from './ObjectMap'; + +vi.mock('react-map-gl/maplibre', () => ({ + default: ({ children }: any) =>
{children}
, + Map: ({ children }: any) =>
{children}
, + NavigationControl: () =>
, + Marker: ({ children }: any) =>
{children}
, + Popup: ({ children }: any) =>
{children}
, +})); + +const TOTAL_ROWS = 6543; + +function makeRows(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: String(i + 1), + name: `Place ${i + 1}`, + latitude: -80 + ((i * 37) % 160), + longitude: -179 + ((i * 53) % 358), + })); +} + +function makeDataSource(storeSize: number, calls: Array>) { + const store = makeRows(storeSize); + return { + find: vi.fn(async (_resource: string, params: any) => { + calls.push({ ...(params ?? {}) }); + const top = typeof params?.$top === 'number' ? params.$top : store.length; + return { data: store.slice(0, top), total: store.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: 'store', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text' }, + latitude: { name: 'latitude', type: 'number' }, + longitude: { name: 'longitude', type: 'number' }, + }, + })), + } as any; +} + +const schema: any = { + type: 'map', + objectName: 'store', + map: { latitudeField: 'latitude', longitudeField: 'longitude', titleField: 'name' }, + data: { provider: 'object', object: 'store' }, +}; + +describe('objectui#7210 ruling a′ — the map caps at the platform ceiling, loudly', () => { + it('above the ceiling: the query stops at the ceiling and BOTH numbers are named', async () => { + const calls: Array> = []; + const dataSource = makeDataSource(TOTAL_ROWS, calls); + + render(); + + await waitFor(() => expect(calls.length).toBeGreaterThan(0)); + for (const params of calls) { + expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + } + + const note = await screen.findByRole('note'); + expect(note.getAttribute('data-row-ceiling-note')).toBe('non-grid'); + expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); + expect(note.textContent).toContain(String(TOTAL_ROWS)); + }); + + it('below the ceiling: there is NO footnote', async () => { + const calls: Array> = []; + const dataSource = makeDataSource(20, calls); + + render(); + + await waitFor(() => expect(calls.length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.queryByText(/Loading map/i)).toBeNull()); + expect(screen.queryByRole('note')).toBeNull(); + }); +}); diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 338e6f7829..3fb9653f85 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -23,10 +23,15 @@ import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import type { ObjectMapSchema, ObjectMapConfig, DataSource, ViewData } from '@object-ui/types'; import { ObjectMapConfigSchema } from '@object-ui/types/zod'; -import { useNavigationOverlay } from '@object-ui/react'; +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'; import { - extractRecords, buildExpandFields, convertSortToQueryParams, getRecordDisplayName, @@ -543,6 +548,16 @@ export const ObjectMap: React.FC = ({ const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + /** + * Did the platform row ceiling bite, and how large was the whole filtered + * result set (objectui#7210)? Carried from the response that knew it — + * `data.length === NON_GRID_ROW_CEILING` cannot tell a capped result set + * apart from one that is exactly that size. A host `data` prop and an inline + * `value` set are never truncated by us, so both reset it. + */ + const [rowCeiling, setRowCeiling] = useState<{ truncated: boolean; total?: number }>({ + truncated: false, + }); const [objectSchema, setObjectSchema] = useState(null); const [selectedMarkerId, setSelectedMarkerId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); @@ -683,12 +698,14 @@ export const ObjectMap: React.FC = ({ // refetch-every-render trap (objectui#5003). if (Array.isArray(dataProp)) { setData(dataProp); + setRowCeiling({ truncated: false }); setLoading(false); return; } if (hasInlineData && dataProvider === 'value') { setData(dataItems as any[]); + setRowCeiling({ truncated: false }); setLoading(false); return; } @@ -708,11 +725,20 @@ export const ObjectMap: React.FC = ({ const result = await dataSource.find(objectName, { $filter: schema.filter, $orderby: convertSortToQueryParams(schema.sort), + // The platform ceiling (objectui#7210, ruling a′). A map still + // fetches the whole FILTERED set — the camera fit is computed from + // every marker, so a page would frame the wrong box — but it now + // stops at a number rather than at whatever the table holds. One + // 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, ...(expand.length > 0 ? { $expand: expand } : {}), }); - const items: any[] = extractRecords(result); - setData(items); + const capped = applyNonGridRowCeiling(result); + setData(capped.rows); + setRowCeiling({ truncated: capped.truncated, total: capped.total }); } else if (dataProvider === 'api') { console.warn('API provider not yet implemented for ObjectMap'); setData([]); @@ -1135,6 +1161,15 @@ export const ObjectMap: React.FC = ({
)}
+ {/* objectui#7210 — a map drawn from the first N of a larger result set + 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. */} + {navigation.isOverlay && ( {(record) => ( diff --git a/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx b/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx new file mode 100644 index 0000000000..7e46ce678a --- /dev/null +++ b/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx @@ -0,0 +1,116 @@ +/** + * 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, half 2 — maintainer ruling a′ (2026-09-02), on the tree. + * + * ⭐ This is the view the ceiling's VALUE was measured on, and the only one of + * the four whose DOM grows with the result set: gantt virtualises, the + * calendar month grid caps events per day cell, the map auto-clusters above + * 100 markers — the tree flattens every expanded node into the document, at a + * measured 5.2 elements per record. So the ruling's "the DOM row count equals + * the ceiling" is literally checkable here, against real rendered ``s, + * and this file checks it that way rather than through a stub. + * + * The truncation is also the most consequential here: a hierarchy assembled + * from the first N rows is not a subtree of the real one — every node whose + * parent fell past the cut is silently reparented to a root. Nothing in the + * rendering says so, which is what the footnote is for. + * + * REVERSE VERIFICATION — direction predicted before running: removing + * `$top: NON_GRID_ROW_CEILING_TOP` from `ObjectTree`'s record fetch turns the + * truncation case red at BOTH the row count and the footnote (the whole store + * arrives, nothing is capped, `truncated` is false), while the below-ceiling + * case stays green. + */ + +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 { ObjectTree } from './ObjectTree'; + +vi.mock('@object-ui/plugin-detail', () => ({ + RecordDetailDrawer: () => null, + deriveRecordPageHref: () => null, +})); + +const TOTAL_ROWS = 5000; + +/** A flat forest: every 10th record is a root, the rest hang off it. */ +function makeRows(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: String(i + 1), + name: `Node ${i + 1}`, + parent_id: i % 10 === 0 ? null : String(i - (i % 10) + 1), + })); +} + +function makeDataSource(storeSize: number, calls: Array>) { + const store = makeRows(storeSize); + return { + find: vi.fn(async (_resource: string, params: any) => { + calls.push({ ...(params ?? {}) }); + const top = typeof params?.$top === 'number' ? params.$top : store.length; + return { data: store.slice(0, top), total: store.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: 'node', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text' }, + parent_id: { name: 'parent_id', type: 'text' }, + }, + })), + } as any; +} + +const schema: any = { + type: 'object-tree', + objectName: 'node', + tree: { parentField: 'parent_id', labelField: 'name' }, + data: { provider: 'object', object: 'node' }, +}; + +describe('objectui#7210 ruling a′ — the tree draws at most the platform ceiling, loudly', () => { + it('above the ceiling: the rendered row count EQUALS the ceiling, and both numbers are named', async () => { + const calls: Array> = []; + const dataSource = makeDataSource(TOTAL_ROWS, calls); + + const { container } = render(); + + await waitFor(() => expect(container.querySelector('[data-testid="object-tree"]')).not.toBeNull()); + await waitFor(() => + expect(container.querySelectorAll('tbody tr').length).toBe(NON_GRID_ROW_CEILING), + ); + + expect(calls.length).toBeGreaterThan(0); + for (const params of calls) { + expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); + } + + const note = screen.getByRole('note'); + expect(note.getAttribute('data-row-ceiling-note')).toBe('non-grid'); + expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); + expect(note.textContent).toContain(String(TOTAL_ROWS)); + }); + + it('below the ceiling: every row draws and there is NO footnote', async () => { + const calls: Array> = []; + const dataSource = makeDataSource(30, calls); + + const { container } = render(); + + await waitFor(() => expect(container.querySelectorAll('tbody tr').length).toBe(30)); + expect(screen.queryByRole('note')).toBeNull(); + }); +}); diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index 568c4d355d..82f90870d3 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -21,11 +21,18 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { DataSource, ViewData } from '@object-ui/types'; -import { useNavigationOverlay, useSafeFieldLabel, useSettledSchema } from '@object-ui/react'; +import { + useNavigationOverlay, + useSafeFieldLabel, + useSettledSchema, + NON_GRID_ROW_CEILING, + NON_GRID_ROW_CEILING_TOP, + applyNonGridRowCeiling, + NonGridRowCeilingNote, +} from '@object-ui/react'; import { NavigationOverlay, cn } from '@object-ui/components'; import { createSafeTranslation } from '@object-ui/i18n'; import { - extractRecords, buildExpandFields, columnIdentity, isExpandableFieldType, @@ -343,6 +350,15 @@ export const ObjectTree: React.FC = ({ ...rest }) => { const [records, setRecords] = useState([]); + /** + * Did the platform row ceiling bite, and how large was the whole filtered + * result set (objectui#7210)? Carried from the response that knew it — + * `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 [loading, setLoading] = useState(true); const [error, setError] = useState(null); const dataConfig = useMemo(() => getDataConfig(schema), [schema]); @@ -457,10 +473,21 @@ export const ObjectTree: React.FC = ({ // `dataConfig.object` read carried. const result = await dataSource.find(dataObjectName as string, { $filter: schema.filter, + // The platform ceiling (objectui#7210, ruling a′). A tree still + // fetches the whole FILTERED set — a hierarchy assembled from a + // page loses every child whose parent fell outside it, which is + // why paging this was rejected — but the fetch now stops at a + // number. This is also the view the ceiling's VALUE was measured + // 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, ...(expand.length > 0 ? { $expand: expand } : {}), }); + const capped = applyNonGridRowCeiling(result); if (!cancelled) { - setRecords(extractRecords(result)); + setRecords(capped.rows); + setRowCeiling({ truncated: capped.truncated, total: capped.total }); setLoading(false); } return; @@ -471,6 +498,7 @@ export const ObjectTree: React.FC = ({ if (Array.isArray(passed)) { if (!cancelled) { setRecords(passed); + setRowCeiling({ truncated: false }); setLoading(false); } return; @@ -479,6 +507,7 @@ export const ObjectTree: React.FC = ({ if (dataProvider === 'value') { if (!cancelled) { setRecords((dataItems as any[]) ?? []); + setRowCeiling({ truncated: false }); setLoading(false); } return; @@ -657,6 +686,16 @@ export const ObjectTree: React.FC = ({ })} + {/* objectui#7210 — a hierarchy drawn from the first N rows of a larger + result set is not a subtree of the real one: every node whose parent + 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. */} + {navigation.isOverlay && ( /* Keyed, not a bare literal (objectui#3459). This value is handed to diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 6dc421d05f..7e3df268ab 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -64,7 +64,7 @@ import { columnIdentity, convertSortToQueryParams, } from '@object-ui/core'; -import { SchemaRenderer as ImportedSchemaRenderer } from '@object-ui/react'; +import { SchemaRenderer as ImportedSchemaRenderer, useSettledSchema } from '@object-ui/react'; import { ViewSwitcher } from './ViewSwitcher'; import { deriveRecordSurface } from './recordSurface'; import { useStableIdentity } from './stableIdentity'; @@ -636,8 +636,12 @@ export const ObjectView: React.FC = ({ // `getObjectSchema`, or a read that threw). `key` is compared against the // CURRENT object name during render, so switching objects closes the gate in // the same commit that changes it, not one commit later. - const [schemaResolution, setSchemaResolution] = - useState<{ key: string; def: Record | null } | null>(null); + // + // Since objectui#7225 (maintainer ruling B, 2026-09-02) this is the SHARED + // `useSettledSchema` rather than this component's hand copy of it — the + // convergence #6482 asked for, amended from "migrate incidentally" to one + // PR once the migration's cost was measured at zero behaviour delta. The + // shape is unchanged because the hook was EXTRACTED from this shape. const schemaKey = schema.objectName ?? ''; /** * Has the object schema for THIS object finished resolving? Note what this is @@ -645,8 +649,8 @@ export const ObjectView: React.FC = ({ * `getObjectSchema`, or whose schema read failed, must still fetch its rows — * gating on a truthy schema would leave those views empty forever. */ - const objectSchemaReady = schemaResolution !== null && schemaResolution.key === schemaKey; - const objectSchema = objectSchemaReady ? schemaResolution.def : null; + const { ready: objectSchemaReady, def: objectSchema } = + useSettledSchema>(schemaKey, dataSource); const [isFormOpen, setIsFormOpen] = useState(false); const [formMode, setFormMode] = useState('create'); const [selectedRecord, setSelectedRecord] = useState | null>(null); @@ -768,35 +772,6 @@ export const ObjectView: React.FC = ({ // Navigation config const navigationConfig: ViewNavigationConfig | undefined = schema.navigation; - // Fetch object schema from ObjectQL/ObjectStack. - // - // Every exit settles the resolution — success, failure, and "there is nothing - // to read from" alike — because the non-grid record query below WAITS on this - // (objectui#6419). A path that returned without settling would not merely - // skip the expansion, it would hold that query open forever. - useEffect(() => { - let isMounted = true; - const key = schema.objectName ?? ''; - const fetchObjectSchema = async () => { - if (!schema.objectName || !dataSource || typeof dataSource.getObjectSchema !== 'function') { - // No source for a schema: settle with none, so the view still queries - // (unexpanded — with no schema there is no expand set to derive, which - // is the same query this case produced before). - if (isMounted) setSchemaResolution({ key, def: null }); - return; - } - try { - const schemaData = await dataSource.getObjectSchema(schema.objectName); - if (isMounted) setSchemaResolution({ key, def: schemaData }); - } catch (err) { - console.error('Failed to fetch object schema:', err); - if (isMounted) setSchemaResolution({ key, def: null }); - } - }; - fetchObjectSchema(); - return () => { isMounted = false; }; - }, [schema.objectName, dataSource]); - // Fetch data for non-grid view types (grid handles its own data via ObjectGrid) useEffect(() => { let isMounted = true; diff --git a/packages/react/README.md b/packages/react/README.md index 090e14b0cf..570ea0a6de 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -194,10 +194,11 @@ again importing `elementDataSourceBlock` from `@object-ui/core`. ### useSettledSchema -The settled-schema RESOLUTION half shared by `ObjectKanban` / `ObjectView` / -`ObjectCalendar`'s fetch-gate hand copies (objectui#6482). Tracks whether an -object's definition has finished resolving FOR THE KEY THE CURRENT RENDER IS -ASKING ABOUT — `ready` and `def` are two views of one piece of state, so a +The settled-schema RESOLUTION half, shared by every view that gates a record +query on its object definition — `ObjectKanban`, `ObjectView`, +`ObjectCalendar`, `ObjectTree` and `ObjectGantt` (objectui#6482, converged in +objectui#7225). Tracks whether an object's definition has finished resolving +FOR THE KEY THE CURRENT RENDER IS ASKING ABOUT — `ready` and `def` are two views of one piece of state, so a stale key can never read as ready. GATE PLACEMENT — which effect actually waits on `ready` — stays a per-component decision; this hook only owns the resolution. @@ -220,6 +221,40 @@ Pass `dataSource: undefined` for a render that should settle immediately with no definition (e.g. a provider that issues no metadata read at all) instead of adding a separate enable flag. +### NON_GRID_ROW_CEILING + +The platform's hard row ceiling for a NON-GRID visualisation — gantt, calendar, +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. + +```tsx +import { + NON_GRID_ROW_CEILING, + NON_GRID_ROW_CEILING_TOP, + applyNonGridRowCeiling, + NonGridRowCeilingNote, +} from '@object-ui/react' + +const result = await dataSource.find(objectName, { + $filter: schema.filter, + $top: NON_GRID_ROW_CEILING_TOP, // the ceiling plus ONE probe row +}) +const { rows, total, truncated } = applyNonGridRowCeiling(result) +// …draw `rows`, then: + +``` + +`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. + +⛔ 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 +schedule — so a view that caps rows without rendering the note is a defect, not +an optimisation. + ### ComponentRegistry There is no registry hook: the registry is a process-level singleton exported diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7cbf9ffe3d..087f6f28fc 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -98,3 +98,16 @@ export { type NumberFormatOptions, } from '@object-ui/i18n'; + +// The platform row ceiling for NON-GRID visualisations — gantt, calendar, map +// and tree (objectui#7210, maintainer ruling a′). Exported at the package +// entry because the ruling asks for ONE constant across the four, and this is +// the only package all four already depend on; a per-plugin copy would be four +// constants wearing one name, which is the thing the ruling ruled out. +export { + NON_GRID_ROW_CEILING, + NON_GRID_ROW_CEILING_TOP, + applyNonGridRowCeiling, + NonGridRowCeilingNote, +} from './utils/nonGridRowCeiling.js'; +export type { NonGridCeilingResult } from './utils/nonGridRowCeiling.js'; diff --git a/packages/react/src/utils/nonGridRowCeiling.test.tsx b/packages/react/src/utils/nonGridRowCeiling.test.tsx new file mode 100644 index 0000000000..02da4a2c78 --- /dev/null +++ b/packages/react/src/utils/nonGridRowCeiling.test.tsx @@ -0,0 +1,94 @@ +/** + * 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. + * + * 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: + * + * 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. + * + * 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. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { + NON_GRID_ROW_CEILING, + NON_GRID_ROW_CEILING_TOP, + applyNonGridRowCeiling, + NonGridRowCeilingNote, +} from './nonGridRowCeiling.js'; + +const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ id: String(i + 1) })); + +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); + }); + + 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(); + 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(); + 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 + // sentence says "the FIRST N" rather than hedging. It must not invent an M. + expect(note.textContent).toMatch(/first/i); + expect(note.textContent).not.toMatch(/undefined|NaN/); + }); +}); diff --git a/packages/react/src/utils/nonGridRowCeiling.tsx b/packages/react/src/utils/nonGridRowCeiling.tsx new file mode 100644 index 0000000000..58fc8d9993 --- /dev/null +++ b/packages/react/src/utils/nonGridRowCeiling.tsx @@ -0,0 +1,212 @@ +/** + * 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 React from 'react'; +import { extractRecords } 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 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). + * + * Placement and tone follow objectui#7148's chart footnote — a `role="note"` + * line in muted small type directly under the visualisation, naming BOTH + * numbers, because the count is the half a reader cannot recover from the + * picture. A truncated schedule renders as a healthy, confident schedule of a + * fraction of itself; "some rows are missing" leaves it indistinguishable from + * a complete one, and `2,000 of 40,000` is the bit that was missing. + * + * Two sentences because there are two conditions, the same split + * `grid.grouping.partialNotice` carries: a known total states the fact with + * both numbers; an adapter that reported no `total` still gets a DEFINITE + * 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. + */ +export function NonGridRowCeilingNote({ + drawn, + total, + truncated, + className, +}: { + /** 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; +}) { + // ⚠️ A HOOK AT RENDER, never a module-scope `createSafeTranslation(...)` + // factory, and that is a correctness constraint rather than taste. This + // module is re-exported from `@object-ui/react`'s entry, so anything at its + // module scope runs on IMPORT for every consumer that touches the barrel — + // and a factory call there throws inside any test that partially mocks + // `@object-ui/i18n` with an object literal instead of `importOriginal`, + // before a single assertion runs. Two such files went red in CI on exactly + // that, and the population is repo-wide rather than knowable from here, so + // the call moved to where it cannot fire at import time. `en` is likewise + // dereferenced HERE, at render, not in a module-scope constant. + // + // `useObjectTranslation` is the one that interpolates on the provider-less + // path too (objectui#6219), so `{{shown}}` / `{{total}}` are filled whether + // or not the host mounted an `I18nProvider`. The `defaultValue` is read from + // the `en` pack rather than retyped: both sit in the same eagerly-loaded + // chunk, so a hand copy would ship identical bytes twice, and reading the + // pack makes an inline default that disagrees with `en` unrepresentable + // instead of merely policed. + const { t } = useObjectTranslation(); + if (!truncated) return null; + const text = + typeof total === 'number' + ? t('common.rowCeilingNote', { + shown: drawn, + total, + defaultValue: en.common.rowCeilingNote, + }) + : t('common.rowCeilingNoteUnknownTotal', { + shown: drawn, + defaultValue: en.common.rowCeilingNoteUnknownTotal, + }); + return ( +

+ {text} +

+ ); +}