From 36fabaaf13872cbe9d56dd1ca35c0499df338b85 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:09:21 +0000 Subject: [PATCH 1/5] feat(non-grid): cap the gantt/calendar/map/tree fetch at a platform row ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#7210 half 2, maintainer ruling a' (2026-09-02, director seat). The four non-grid views each issued a `find` with no `$top` at all, so the request returned the entire filtered result set — invisible at 186 rows, the whole table into the browser at 100k, and unbounded by anything an author could write, since `pagination.pageSize` cannot cap a query that never carried a cap. They now ask for one probe row past a platform ceiling, draw at most the ceiling, and say so LOUDLY when the cut bites: a `role="note"` footnote naming both N and M, following objectui#7148's chart footnote for placement and tone. Silent truncation is the direction the ruling names as dangerous — a cut-off schedule still looks like a schedule. The ceiling is 2000, one constant for all four, chosen after measuring them: gantt, calendar and map hold their DOM flat as rows grow (virtualisation, four events per day cell, auto-clustering above 100 markers); ObjectTree flattens every expanded node at a measured 5.2 DOM elements per record and is therefore the binding view. 2000 rows puts it at ~10,400 elements. Not authorable, by the ruling: the two `$top` pins that used to assert the absence of a cap now assert that the cap is the PLATFORM's and that an authored `limit` still cannot reach it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .changeset/7210-non-grid-row-ceiling.md | 44 ++++ packages/i18n/src/locales/ar.ts | 7 + packages/i18n/src/locales/de.ts | 7 + packages/i18n/src/locales/en.ts | 7 + packages/i18n/src/locales/es.ts | 7 + packages/i18n/src/locales/fr.ts | 7 + packages/i18n/src/locales/ja.ts | 7 + packages/i18n/src/locales/ko.ts | 7 + packages/i18n/src/locales/pt.ts | 7 + packages/i18n/src/locales/ru.ts | 7 + packages/i18n/src/locales/zh.ts | 7 + .../ObjectCalendar.rowCeiling-7210.test.tsx | 119 +++++++++++ .../plugin-calendar/src/ObjectCalendar.tsx | 35 +++- .../ObjectGantt.elementDataSource.test.tsx | 18 +- .../ObjectGantt.hostDataProp-7210.test.tsx | 30 ++- .../src/ObjectGantt.rowCeiling-7210.test.tsx | 165 +++++++++++++++ packages/plugin-gantt/src/ObjectGantt.tsx | 61 +++++- .../src/ObjectMap.elementDataSource.test.tsx | 18 +- .../src/ObjectMap.rowCeiling-7210.test.tsx | 115 ++++++++++ packages/plugin-map/src/ObjectMap.tsx | 43 +++- .../src/ObjectTree.rowCeiling-7210.test.tsx | 116 ++++++++++ packages/plugin-tree/src/ObjectTree.tsx | 45 +++- packages/react/src/index.ts | 13 ++ .../src/utils/nonGridRowCeiling.test.tsx | 95 +++++++++ .../react/src/utils/nonGridRowCeiling.tsx | 198 ++++++++++++++++++ 25 files changed, 1154 insertions(+), 31 deletions(-) create mode 100644 .changeset/7210-non-grid-row-ceiling.md create mode 100644 packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx create mode 100644 packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx create mode 100644 packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx create mode 100644 packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx create mode 100644 packages/react/src/utils/nonGridRowCeiling.test.tsx create mode 100644 packages/react/src/utils/nonGridRowCeiling.tsx diff --git a/.changeset/7210-non-grid-row-ceiling.md b/.changeset/7210-non-grid-row-ceiling.md new file mode 100644 index 0000000000..e3adf73812 --- /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 to see the rest."* 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/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 59396a2a38..5778b626ff 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + rowCeilingNote: "يتم عرض أول {{shown}} من أصل {{total}} سجل — قم بتضييق عامل التصفية لعرض الباقي.", + rowCeilingNoteUnknownTotal: "يتم عرض أول {{shown}} سجل — هناك المزيد من السجلات المطابقة لهذا العرض. قم بتضييق عامل التصفية لعرض الباقي.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index bdb05ae7e3..2d99ccb1dc 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + rowCeilingNote: "Es werden die ersten {{shown}} von {{total}} Datensätzen angezeigt — grenzen Sie den Filter ein, um die übrigen zu sehen.", + rowCeilingNoteUnknownTotal: "Es werden die ersten {{shown}} Datensätze angezeigt — weitere Datensätze entsprechen dieser Ansicht. Grenzen Sie den Filter ein, um die übrigen zu sehen.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 9c71376678..a1ca6463fc 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + rowCeilingNote: 'Showing the first {{shown}} of {{total}} records — narrow the filter to see the rest.', + rowCeilingNoteUnknownTotal: 'Showing the first {{shown}} records — more records match this view. Narrow the filter to see the rest.', }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 2970bf80d3..1933593aea 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + rowCeilingNote: "Mostrando los primeros {{shown}} de {{total}} registros — acota el filtro para ver el resto.", + rowCeilingNoteUnknownTotal: "Mostrando los primeros {{shown}} registros — hay más registros que coinciden con esta vista. Acota el filtro para ver el resto.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 5078a8b133..02df187c0d 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + rowCeilingNote: "Affichage des {{shown}} premiers enregistrements sur {{total}} — affinez le filtre pour voir les autres.", + rowCeilingNoteUnknownTotal: "Affichage des {{shown}} premiers enregistrements — d’autres enregistrements correspondent à cette vue. Affinez le filtre pour voir les autres.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index a99d6be8a7..018d93991d 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + rowCeilingNote: "{{total}} 件中、最初の {{shown}} 件を表示しています — 残りを表示するにはフィルターを絞り込んでください。", + rowCeilingNoteUnknownTotal: "最初の {{shown}} 件を表示しています — このビューにはさらに多くのレコードがあります。残りを表示するにはフィルターを絞り込んでください。", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 66073cf4b0..b944cabe1d 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + rowCeilingNote: "전체 {{total}}개 레코드 중 처음 {{shown}}개를 표시하고 있습니다 — 나머지를 보려면 필터를 좁히세요.", + rowCeilingNoteUnknownTotal: "처음 {{shown}}개 레코드를 표시하고 있습니다 — 이 뷰에 해당하는 레코드가 더 있습니다. 나머지를 보려면 필터를 좁히세요.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 5ead6c97b1..226403c361 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + rowCeilingNote: "Mostrando os primeiros {{shown}} de {{total}} registros — restrinja o filtro para ver os demais.", + rowCeilingNoteUnknownTotal: "Mostrando os primeiros {{shown}} registros — há mais registros que correspondem a esta visualização. Restrinja o filtro para ver os demais.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 8f74f0c407..8e29609a11 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + rowCeilingNote: "Показаны первые {{shown}} из {{total}} записей — сузьте фильтр, чтобы увидеть остальные.", + rowCeilingNoteUnknownTotal: "Показаны первые {{shown}} записей — этому представлению соответствует больше записей. Сузьте фильтр, чтобы увидеть остальные.", }, actions: { decisionOutput: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 4e74c36dad..51c2d6779d 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, not one, + // because there are two conditions: an adapter that reported a `total` + // states the fact with BOTH numbers; one that reported none still gets a + // definite sentence — the probe row proves more rows exist — it just + // cannot name how many. Same split as `grid.grouping.partialNotice`. + 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..1ed7af7094 --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx @@ -0,0 +1,119 @@ +/** + * 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.getAttribute('data-ceiling-drawn')).toBe(String(NON_GRID_ROW_CEILING)); + expect(note.getAttribute('data-ceiling-total')).toBe(String(TOTAL_ROWS)); + 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 459c85d2e7..352ae05e50 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -32,6 +32,10 @@ import { extractWriteErrorMessage, isPermissionError, declaredUserMessage, + NON_GRID_ROW_CEILING, + NON_GRID_ROW_CEILING_TOP, + applyNonGridRowCeiling, + NonGridRowCeilingNote, } from '@object-ui/react'; import { RecordDetailDrawer, deriveRecordPageHref } from '@object-ui/plugin-detail'; import { @@ -48,7 +52,6 @@ import { toast, } from '@object-ui/components'; import { - extractRecords, buildExpandFields, convertSortToQueryParams, getRecordDisplayName, @@ -184,6 +187,15 @@ export const ObjectCalendar: React.FC = ({ // `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()); const isMobile = useIsMobile(); const schemaDefaultView = (schema as any).defaultView as 'month' | 'week' | 'day' | undefined; @@ -329,6 +341,7 @@ export const ObjectCalendar: React.FC = ({ if (hasInlineData && dataProvider === 'value') { if (isMounted) { setData(dataItems as any[]); + setRowCeiling({ truncated: false }); setLoading(false); } return; @@ -352,13 +365,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'); @@ -736,6 +757,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. */} + {/* Quick-create dialog: opens when the user clicks an empty day cell. Pre-fills start_date (and end_date) with the clicked day; only the 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.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.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 15d6753abb..30b4ccfe2c 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -31,7 +31,14 @@ 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, + 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 { @@ -574,6 +581,19 @@ export const ObjectGantt: React.FC = ({ 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 @@ -656,12 +676,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; } @@ -676,9 +702,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. @@ -1792,6 +1832,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-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..1a70049894 --- /dev/null +++ b/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx @@ -0,0 +1,115 @@ +/** + * 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.getAttribute('data-ceiling-drawn')).toBe(String(NON_GRID_ROW_CEILING)); + expect(note.getAttribute('data-ceiling-total')).toBe(String(TOTAL_ROWS)); + 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/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..e5082fcac5 --- /dev/null +++ b/packages/react/src/utils/nonGridRowCeiling.test.tsx @@ -0,0 +1,95 @@ +/** + * 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'); + expect(note.getAttribute('data-ceiling-drawn')).toBe(String(NON_GRID_ROW_CEILING)); + expect(note.getAttribute('data-ceiling-total')).toBe('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. + expect(note.textContent).toMatch(/more records match this view/i); + expect(note.getAttribute('data-ceiling-total')).toBe(''); + }); +}); diff --git a/packages/react/src/utils/nonGridRowCeiling.tsx b/packages/react/src/utils/nonGridRowCeiling.tsx new file mode 100644 index 0000000000..00c06d7903 --- /dev/null +++ b/packages/react/src/utils/nonGridRowCeiling.tsx @@ -0,0 +1,198 @@ +/** + * 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 { createSafeTranslation } 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, + }; +} + +const NOTE_DEFAULTS = { + 'common.rowCeilingNote': + 'Showing the first {{shown}} of {{total}} records — narrow the filter to see the rest.', + 'common.rowCeilingNoteUnknownTotal': + 'Showing the first {{shown}} records — more records match this view. Narrow the filter to see the rest.', +}; + +const useCeilingNoteTranslation = createSafeTranslation(NOTE_DEFAULTS, 'common.rowCeilingNote'); + +/** + * 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; +}) { + const { t } = useCeilingNoteTranslation(); + if (!truncated) return null; + const text = + typeof total === 'number' + ? t('common.rowCeilingNote', { shown: drawn, total }) + : t('common.rowCeilingNoteUnknownTotal', { shown: drawn }); + return ( +

+ {text} +

+ ); +} From c76dd0cf868cd1d6518d8eee3efb36bb35d9a7a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:25:34 +0000 Subject: [PATCH 2/5] refactor(views): converge on useSettledSchema and gate the gantt's duplicate query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#7225, maintainer ruling B (2026-09-02, director seat), which AMENDS the 2026-08-27 #6482 ruling that had barred a one-shot multi-package refactor — amended because the cost was measured at zero behaviour delta. `useSettledSchema` shipped with exactly one non-test adopter, so a published export was owed compatibility forever while the duplication it was named for stayed. ObjectKanban, plugin-view/ObjectView and ObjectCalendar now call it. The hook was extracted FROM these three shapes, so each becomes a one-line call; gate placement stays local, which is what #6482 ruled and what made ObjectCalendar's named obstacle a non-obstacle — it was about the gate half. ObjectCalendar uses the hook's own documented recipe for it: pass the data source as `undefined` for a render that must not read metadata. The kanban's rejected read moves from console.warn to console.error with a `[useSettledSchema]` prefix, and its test spy moves with it — asserting on the channel now, not merely silencing one. Ask 2: the gantt's DUPLICATE query is gated. `reload` listed `objectSchema` in its dependency list, so a load issued two unbounded queries, the first with no `$expand` at all. Per #6482's per-component measurement standard this is the profile where gating pays: with the metadata read the slower of the two — the common case on a cold MetadataCache — the user saw raw foreign-key ids, then the loading placeholder again, then the expanded rows. Gating required the 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; a chart that never loads once something does. The hook settles on all three, and both exits are pinned. The #7231 stale-reload pin keeps every assertion and changes only how it GENERATES two overlapping reloads: it used to use the gantt's duplicate mount query, which no longer exists, and now uses a silent toolbar refresh superseded by a filter-change reload — a pair that is real and untouched by gating. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .changeset/7225-settled-schema-convergence.md | 51 +++++ .../plugin-calendar/src/ObjectCalendar.tsx | 56 ++---- .../src/ObjectGantt.fetchGate-7225.test.tsx | 190 ++++++++++++++++++ ...jectGantt.staleReloadFinally-7231.test.tsx | 101 ++++++---- packages/plugin-gantt/src/ObjectGantt.tsx | 81 +++++--- packages/plugin-kanban/src/ObjectKanban.tsx | 49 +---- .../fetchGate.objectDef-6271.test.tsx | 14 +- packages/plugin-view/src/ObjectView.tsx | 43 +--- 8 files changed, 411 insertions(+), 174 deletions(-) create mode 100644 .changeset/7225-settled-schema-convergence.md create mode 100644 packages/plugin-gantt/src/ObjectGantt.fetchGate-7225.test.tsx 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/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 352ae05e50..6d6ee6ce0e 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -32,6 +32,7 @@ import { extractWriteErrorMessage, isPermissionError, declaredUserMessage, + useSettledSchema, NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP, applyNonGridRowCeiling, @@ -181,12 +182,6 @@ 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 — @@ -294,8 +289,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, + ); // Sync external data/loading changes from parent (e.g. ObjectView re-fetches after filter change) useEffect(() => { @@ -401,38 +409,6 @@ export const ObjectCalendar: React.FC = ({ }, [hasExternalData, dataProvider, schemaObjectName, dataItems, dataSource, hasInlineData, schema.filter, schema.sort, refreshKey, objectSchemaReady, objectSchema]); - // 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 const events = useMemo(() => { if (!calendarConfig || !data.length) { 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.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 30b4ccfe2c..7eb4a509ae 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -33,6 +33,7 @@ import { GanttConfigSchema } from '@objectstack/spec/ui'; import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { useNavigationOverlay, + useSettledSchema, SchemaRendererContext, NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP, @@ -580,7 +581,6 @@ 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)? @@ -659,6 +659,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, + ); + // Load (and re-load) data through the resolved adapter. `silent: true` // re-reads the source WITHOUT flipping `loading`, so GanttView stays mounted // and keeps its scroll/collapse state — used by the write-readback below and @@ -749,31 +774,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]); - 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(() => { 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-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index eea2e7fa36..a0d8700128 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; From abe381efec2fea26d73ec513f81f43ff604578fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 15:51:36 +0000 Subject: [PATCH 3/5] docs: the non-grid row ceiling and the settled-schema convergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md #2 — docs reflect the code. Kept as its own commit so the two card halves stay independently checkable as code changes. - `packages/react/README.md`: a `NON_GRID_ROW_CEILING` section (objectui#7210) with the three-export usage shape, why the `$top` is the ceiling PLUS ONE, and the standing "not authorable, and a cap without the note is a defect" fence. `useSettledSchema`'s section stops describing ObjectKanban / ObjectView / ObjectCalendar as hand copies — they call it now (objectui#7225) — and names all five adopters. - `content/docs/guide/data-source.md`: the per-block binding table said `— no row cap` for `object-calendar` / `object-gantt` / `object-map`. That is no longer true and the sentence it implied was the dangerous one. The cells now read `— platform ceiling`, with a paragraph saying what the cell still means: an authored `limit` / `pagination.pageSize` STILL cannot reach these queries, because the ceiling is a renderer constant by ruling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- content/docs/guide/data-source.md | 24 ++++++++++++++--- packages/react/README.md | 43 ++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) 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/react/README.md b/packages/react/README.md index 04da5d47a5..c8b442adf5 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -188,10 +188,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. @@ -214,6 +215,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 From 795457c73637ccb5fe98e4308531176fedc918e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 16:32:32 +0000 Subject: [PATCH 4/5] perf(i18n,react): fit the ceiling footnote inside the framework chunk's budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured, with a control, not assumed. `check:eager-closure` weighs the console's eagerly-loaded chunks per chunk as well as in aggregate, and the first build of this branch put `framework` 0.2 KB OVER its 511.7 KB ceiling. The control says the bytes are mine, so they get paid for rather than budgeted around — a build of plain `origin/main` (1688986a3) in a second worktree measures `framework` at 510.8 KB with 0.9 KB of headroom, against 511.9 KB on this branch. A per-chunk diff of the two eager-closure reports attributes exactly 1,075 gzipped bytes to this branch, split ~227 into `@object-ui/react` and the rest into the eagerly-loaded locale packs. ⛔ The ceiling is NOT raised. The gate offers that route for intended growth; this growth is real but the copy was simply longer than it needed to be. - Both footnote sentences tighten to the form the ruling itself uses — "showing first N of M records; narrow the filter" — in all ten packs. The copy is shorter AND closer to the ruled wording. - The same two sentences ship twice in `framework` (the `en` pack and the provider-less fallback map), so each edit counts double; the fallback map now says so, with the measured headroom, next to the strings. - `data-ceiling-drawn` / `data-ceiling-total` were test-only attributes on a note that already renders both numbers as text. Dropped; the three pins that read them assert the text instead, which is what a user sees anyway. `framework` is now 511.6 KB against the 511.7 KB ceiling and the gate exits 0. ⚠️ 0.2 KB is not comfort. `main` moved this chunk 510.8 KB in the hour before this measurement, and CI weighs the MERGE ref — so another lane landing framework bytes first can turn this red with no further change here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .changeset/7210-non-grid-row-ceiling.md | 4 ++-- packages/i18n/src/locales/ar.ts | 14 ++++++------- packages/i18n/src/locales/de.ts | 14 ++++++------- packages/i18n/src/locales/en.ts | 14 ++++++------- packages/i18n/src/locales/es.ts | 14 ++++++------- packages/i18n/src/locales/fr.ts | 14 ++++++------- packages/i18n/src/locales/ja.ts | 14 ++++++------- packages/i18n/src/locales/ko.ts | 14 ++++++------- packages/i18n/src/locales/pt.ts | 14 ++++++------- packages/i18n/src/locales/ru.ts | 14 ++++++------- packages/i18n/src/locales/zh.ts | 14 ++++++------- .../ObjectCalendar.rowCeiling-7210.test.tsx | 2 -- .../src/ObjectMap.rowCeiling-7210.test.tsx | 2 -- .../src/utils/nonGridRowCeiling.test.tsx | 9 ++++----- .../react/src/utils/nonGridRowCeiling.tsx | 20 +++++++++++++------ 15 files changed, 90 insertions(+), 87 deletions(-) diff --git a/.changeset/7210-non-grid-row-ceiling.md b/.changeset/7210-non-grid-row-ceiling.md index e3adf73812..c17c4eae21 100644 --- a/.changeset/7210-non-grid-row-ceiling.md +++ b/.changeset/7210-non-grid-row-ceiling.md @@ -20,8 +20,8 @@ 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 to see the rest."* Below the ceiling nothing -changes: the full set draws and no footnote appears. +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 diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 5778b626ff..afc2213958 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -129,13 +129,13 @@ const ar = { record: "سجل", retry: "إعادة المحاولة", printDialogHint: "يفتح مربع حوار الطباعة في المتصفح (ليس تصديرًا إلى PDF)", - // The non-grid row ceiling's footnote (objectui#7210). Two keys, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: "يتم عرض أول {{shown}} من أصل {{total}} سجل — قم بتضييق عامل التصفية لعرض الباقي.", - rowCeilingNoteUnknownTotal: "يتم عرض أول {{shown}} سجل — هناك المزيد من السجلات المطابقة لهذا العرض. قم بتضييق عامل التصفية لعرض الباقي.", + // 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 2d99ccb1dc..baccfc6340 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -125,13 +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, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: "Es werden die ersten {{shown}} von {{total}} Datensätzen angezeigt — grenzen Sie den Filter ein, um die übrigen zu sehen.", - rowCeilingNoteUnknownTotal: "Es werden die ersten {{shown}} Datensätze angezeigt — weitere Datensätze entsprechen dieser Ansicht. Grenzen Sie den Filter ein, um die übrigen zu sehen.", + // 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 a1ca6463fc..7c082bc082 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -149,13 +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, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: 'Showing the first {{shown}} of {{total}} records — narrow the filter to see the rest.', - rowCeilingNoteUnknownTotal: 'Showing the first {{shown}} records — more records match this view. Narrow the filter to see the rest.', + // 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 1933593aea..e260482c95 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -124,13 +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, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: "Mostrando los primeros {{shown}} de {{total}} registros — acota el filtro para ver el resto.", - rowCeilingNoteUnknownTotal: "Mostrando los primeros {{shown}} registros — hay más registros que coinciden con esta vista. Acota el filtro para ver el resto.", + // 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 02df187c0d..32e94b1b54 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -125,13 +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, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: "Affichage des {{shown}} premiers enregistrements sur {{total}} — affinez le filtre pour voir les autres.", - rowCeilingNoteUnknownTotal: "Affichage des {{shown}} premiers enregistrements — d’autres enregistrements correspondent à cette vue. Affinez le filtre pour voir les autres.", + // 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 018d93991d..ee6e9f34a2 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -125,13 +125,13 @@ const ja = { record: "レコード", retry: "再試行", printDialogHint: "ブラウザーの印刷ダイアログを開きます(PDF エクスポートではありません)", - // The non-grid row ceiling's footnote (objectui#7210). Two keys, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: "{{total}} 件中、最初の {{shown}} 件を表示しています — 残りを表示するにはフィルターを絞り込んでください。", - rowCeilingNoteUnknownTotal: "最初の {{shown}} 件を表示しています — このビューにはさらに多くのレコードがあります。残りを表示するにはフィルターを絞り込んでください。", + // 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 b944cabe1d..24f748d7cd 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -125,13 +125,13 @@ const ko = { record: "레코드", retry: "다시 시도", printDialogHint: "브라우저의 인쇄 대화 상자를 엽니다(PDF 내보내기가 아닙니다)", - // The non-grid row ceiling's footnote (objectui#7210). Two keys, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: "전체 {{total}}개 레코드 중 처음 {{shown}}개를 표시하고 있습니다 — 나머지를 보려면 필터를 좁히세요.", - rowCeilingNoteUnknownTotal: "처음 {{shown}}개 레코드를 표시하고 있습니다 — 이 뷰에 해당하는 레코드가 더 있습니다. 나머지를 보려면 필터를 좁히세요.", + // 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 226403c361..47b5da4eed 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -124,13 +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, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: "Mostrando os primeiros {{shown}} de {{total}} registros — restrinja o filtro para ver os demais.", - rowCeilingNoteUnknownTotal: "Mostrando os primeiros {{shown}} registros — há mais registros que correspondem a esta visualização. Restrinja o filtro para ver os demais.", + // 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 8e29609a11..b2c8f3a7f5 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -131,13 +131,13 @@ const ru = { record: "Запись", retry: "Повторить", printDialogHint: "Открывает диалог печати браузера (это не экспорт в PDF)", - // The non-grid row ceiling's footnote (objectui#7210). Two keys, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: "Показаны первые {{shown}} из {{total}} записей — сузьте фильтр, чтобы увидеть остальные.", - rowCeilingNoteUnknownTotal: "Показаны первые {{shown}} записей — этому представлению соответствует больше записей. Сузьте фильтр, чтобы увидеть остальные.", + // 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 51c2d6779d..39b01ecdff 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -132,13 +132,13 @@ const zh = { record: '记录', retry: '重试', printDialogHint: '打开浏览器打印对话框(不是导出 PDF)', - // The non-grid row ceiling's footnote (objectui#7210). Two keys, not one, - // because there are two conditions: an adapter that reported a `total` - // states the fact with BOTH numbers; one that reported none still gets a - // definite sentence — the probe row proves more rows exist — it just - // cannot name how many. Same split as `grid.grouping.partialNotice`. - rowCeilingNote: '仅显示 {{total}} 条记录中的前 {{shown}} 条 — 请缩小筛选范围以查看其余记录。', - rowCeilingNoteUnknownTotal: '仅显示前 {{shown}} 条记录 — 此视图还有更多记录。请缩小筛选范围以查看其余记录。', + // 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 index 1ed7af7094..0ceb7a56aa 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx @@ -100,8 +100,6 @@ describe('objectui#7210 ruling a′ — the calendar caps at the platform ceilin const note = await screen.findByRole('note'); expect(note.getAttribute('data-row-ceiling-note')).toBe('non-grid'); - expect(note.getAttribute('data-ceiling-drawn')).toBe(String(NON_GRID_ROW_CEILING)); - expect(note.getAttribute('data-ceiling-total')).toBe(String(TOTAL_ROWS)); expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); expect(note.textContent).toContain(String(TOTAL_ROWS)); }); diff --git a/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx b/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx index 1a70049894..37b763f503 100644 --- a/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx +++ b/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx @@ -96,8 +96,6 @@ describe('objectui#7210 ruling a′ — the map caps at the platform ceiling, lo const note = await screen.findByRole('note'); expect(note.getAttribute('data-row-ceiling-note')).toBe('non-grid'); - expect(note.getAttribute('data-ceiling-drawn')).toBe(String(NON_GRID_ROW_CEILING)); - expect(note.getAttribute('data-ceiling-total')).toBe(String(TOTAL_ROWS)); expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); expect(note.textContent).toContain(String(TOTAL_ROWS)); }); diff --git a/packages/react/src/utils/nonGridRowCeiling.test.tsx b/packages/react/src/utils/nonGridRowCeiling.test.tsx index e5082fcac5..02da4a2c78 100644 --- a/packages/react/src/utils/nonGridRowCeiling.test.tsx +++ b/packages/react/src/utils/nonGridRowCeiling.test.tsx @@ -80,16 +80,15 @@ describe('objectui#7210 — the non-grid row ceiling', () => { const note = screen.getByRole('note'); expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); expect(note.textContent).toContain('41234'); - expect(note.getAttribute('data-ceiling-drawn')).toBe(String(NON_GRID_ROW_CEILING)); - expect(note.getAttribute('data-ceiling-total')).toBe('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. - expect(note.textContent).toMatch(/more records match this view/i); - expect(note.getAttribute('data-ceiling-total')).toBe(''); + // 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 index 00c06d7903..cf8994b8db 100644 --- a/packages/react/src/utils/nonGridRowCeiling.tsx +++ b/packages/react/src/utils/nonGridRowCeiling.tsx @@ -135,11 +135,21 @@ export function applyNonGridRowCeiling(result: unknown): NonGridCeiling }; } +/** + * The provider-less fallback for the two keys above — `createSafeTranslation`'s + * stand-in for the pack value, so a host with no `I18nProvider` renders the + * sentence rather than the raw key. `check:i18n-call-site-keys` holds these + * byte-identical to the `en` pack. + * + * ⚠️ This copy ships TWICE in the eagerly-loaded `framework` chunk — once here + * and once in the `en` pack — and that chunk's gzip ceiling had 0.9 KB of + * headroom when this landed (measured: `origin/main` 510.8 KB against a 511.7 + * KB per-chunk ceiling). Keep both sentences terse; the ruling's own form is + * "showing first N of M records; narrow the filter", which is what they say. + */ const NOTE_DEFAULTS = { - 'common.rowCeilingNote': - 'Showing the first {{shown}} of {{total}} records — narrow the filter to see the rest.', - 'common.rowCeilingNoteUnknownTotal': - 'Showing the first {{shown}} records — more records match this view. Narrow the filter to see the rest.', + 'common.rowCeilingNote': 'Showing the first {{shown}} of {{total}} records. Narrow the filter.', + 'common.rowCeilingNoteUnknownTotal': 'Showing the first {{shown}} records. Narrow the filter.', }; const useCeilingNoteTranslation = createSafeTranslation(NOTE_DEFAULTS, 'common.rowCeilingNote'); @@ -188,8 +198,6 @@ export function NonGridRowCeilingNote({

{text} From 91876eb5536ad15f687885b2edf693cd34af4526 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:09:42 +0000 Subject: [PATCH 5/5] fix(react): resolve the ceiling note's copy at RENDER, not at module scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI red on `Test (shard 1/4)`: two files died at module-mock time with 'No "createSafeTranslation" export is defined on the "@object-ui/i18n" mock' — zero tests failed, both files failed to import. The cause is placement, not the mock. `nonGridRowCeiling` is re-exported from `@object-ui/react`'s entry, so a module-scope `createSafeTranslation(...)` factory ran on IMPORT for everything that touches the barrel — and threw inside any test that partially mocks `@object-ui/i18n` with an object literal instead of `importOriginal`. Enumerated rather than guessed: 92 files in this repo mock that package, and the mock lacks `importOriginal` in 26 of them (plus DeclaredActionsBar, whose i18n mock lacks it while the file uses the helper elsewhere) — a blast radius no barrel-level module-scope call should have. Fixed at the cause, so no test file changes: the component now calls `useObjectTranslation()` at render. That hook also interpolates on the provider-less path (objectui#6219), so `{{shown}}` / `{{total}}` are filled whether or not the host mounted an I18nProvider — which is what the retired factory was there for. ⛔ No test skipped, quarantined, or converted. The `defaultValue` is read from the `en` pack rather than retyped, and is dereferenced inside the component for the same reason the hook call is: a module-scope `en.common.…` read would reintroduce the identical trap. It also stops shipping the same English twice in one eagerly-loaded chunk, and makes an inline default that disagrees with `en` unrepresentable rather than merely policed by check:i18n-call-site-keys. Verified over the whole enumerated population plus both named CI casualties and the react suite: 96 files / 1085 tests, all passing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .../react/src/utils/nonGridRowCeiling.tsx | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/react/src/utils/nonGridRowCeiling.tsx b/packages/react/src/utils/nonGridRowCeiling.tsx index cf8994b8db..58fc8d9993 100644 --- a/packages/react/src/utils/nonGridRowCeiling.tsx +++ b/packages/react/src/utils/nonGridRowCeiling.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { extractRecords } from '@object-ui/core'; -import { createSafeTranslation } from '@object-ui/i18n'; +import { useObjectTranslation, en } from '@object-ui/i18n'; /** * The platform's hard row ceiling for a NON-GRID visualisation — gantt, @@ -135,25 +135,6 @@ export function applyNonGridRowCeiling(result: unknown): NonGridCeiling }; } -/** - * The provider-less fallback for the two keys above — `createSafeTranslation`'s - * stand-in for the pack value, so a host with no `I18nProvider` renders the - * sentence rather than the raw key. `check:i18n-call-site-keys` holds these - * byte-identical to the `en` pack. - * - * ⚠️ This copy ships TWICE in the eagerly-loaded `framework` chunk — once here - * and once in the `en` pack — and that chunk's gzip ceiling had 0.9 KB of - * headroom when this landed (measured: `origin/main` 510.8 KB against a 511.7 - * KB per-chunk ceiling). Keep both sentences terse; the ruling's own form is - * "showing first N of M records; narrow the filter", which is what they say. - */ -const NOTE_DEFAULTS = { - 'common.rowCeilingNote': 'Showing the first {{shown}} of {{total}} records. Narrow the filter.', - 'common.rowCeilingNoteUnknownTotal': 'Showing the first {{shown}} records. Narrow the filter.', -}; - -const useCeilingNoteTranslation = createSafeTranslation(NOTE_DEFAULTS, 'common.rowCeilingNote'); - /** * 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). @@ -188,12 +169,37 @@ export function NonGridRowCeilingNote({ truncated: boolean; className?: string; }) { - const { t } = useCeilingNoteTranslation(); + // ⚠️ 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 }) - : t('common.rowCeilingNoteUnknownTotal', { shown: drawn }); + ? t('common.rowCeilingNote', { + shown: drawn, + total, + defaultValue: en.common.rowCeilingNote, + }) + : t('common.rowCeilingNoteUnknownTotal', { + shown: drawn, + defaultValue: en.common.rowCeilingNoteUnknownTotal, + }); return (