From fa2a7fbc06e7cb9ee4796ed944beb4a23a03abb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 15:48:25 +0000 Subject: [PATCH 1/2] fix(non-grid): correct the row ceiling's changeset, pins and rationale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge correctives to #7210's platform row ceiling (#7507), from the Clause-② review that ran as an audit after the PR landed ungated. - Changeset: the four view packages carry the behaviour break, so they move from `patch` to `minor`; the example footnote is replaced with the copy that actually renders (measured: no thousands separators on either the provider or the provider-less path); `NonGridCeilingResult` joins the export list. - Map and calendar pins now grade the cap they are named for. Both were green under a mutation that drew 2,001 rows, because they asserted `$top` and the footnote and never the count that reached the view. - `ObjectCalendar`'s external-`data` sync clears `rowCeiling`, the one `setData` path that did not. Latent today, pinned now. - Comment-only: the false "the only package all four already depend on" rationale on `@object-ui/react`'s entry (all four also depend on `core`, `components` and `types` — the real reason was the round's barrel fence, and the home is an open decision on #7508), and the stale "~1 KB of headroom" note in the ten locale packs, which named a chunk that no longer holds them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .changeset/7210-non-grid-row-ceiling.md | 23 +-- .../7507-non-grid-ceiling-correctives.md | 24 +++ packages/i18n/src/locales/ar.ts | 5 +- packages/i18n/src/locales/de.ts | 5 +- packages/i18n/src/locales/en.ts | 5 +- packages/i18n/src/locales/es.ts | 5 +- packages/i18n/src/locales/fr.ts | 5 +- packages/i18n/src/locales/ja.ts | 5 +- packages/i18n/src/locales/ko.ts | 5 +- packages/i18n/src/locales/pt.ts | 5 +- packages/i18n/src/locales/ru.ts | 5 +- packages/i18n/src/locales/zh.ts | 5 +- ...Calendar.externalDataCeiling-7507.test.tsx | 148 ++++++++++++++++++ .../ObjectCalendar.rowCeiling-7210.test.tsx | 32 ++++ .../plugin-calendar/src/ObjectCalendar.tsx | 7 + .../src/ObjectMap.rowCeiling-7210.test.tsx | 33 +++- packages/react/src/index.ts | 24 ++- 17 files changed, 317 insertions(+), 24 deletions(-) create mode 100644 .changeset/7507-non-grid-ceiling-correctives.md create mode 100644 packages/plugin-calendar/src/ObjectCalendar.externalDataCeiling-7507.test.tsx diff --git a/.changeset/7210-non-grid-row-ceiling.md b/.changeset/7210-non-grid-row-ceiling.md index c17c4eae2..7adf0e20b 100644 --- a/.changeset/7210-non-grid-row-ceiling.md +++ b/.changeset/7210-non-grid-row-ceiling.md @@ -1,10 +1,10 @@ --- '@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 +'@object-ui/plugin-gantt': minor +'@object-ui/plugin-calendar': minor +'@object-ui/plugin-map': minor +'@object-ui/plugin-tree': minor --- A non-grid view's fetch now carries a platform row ceiling, and crossing it is @@ -19,9 +19,13 @@ bound a request that never carried a cap to begin with. **What changed.** Those four fetches now ask for `NON_GRID_ROW_CEILING_TOP` rows, draw at most `NON_GRID_ROW_CEILING` of them, and when the result set was -larger they render a footnote naming both numbers: *"Showing the first 2,000 of -41,234 records. Narrow the filter."* Below the ceiling nothing changes: the full -set draws and no footnote appears. +larger they render a footnote naming both numbers, verbatim as it renders: +*"Showing the first 2000 of 41234 records. Narrow the filter."* Below the +ceiling nothing changes: the full set draws and no footnote appears. + +The four view packages take a **minor**, not a patch: a result set above the +ceiling is no longer drawn in full, which is a behaviour break whatever the +fixed group does to the released version number. **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 @@ -40,5 +44,6 @@ 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. +`NON_GRID_ROW_CEILING_TOP`, `applyNonGridRowCeiling`, `NonGridRowCeilingNote` +and the type `NonGridCeilingResult`. Two new `common.*` i18n keys carry the +footnote copy in all ten packs. diff --git a/.changeset/7507-non-grid-ceiling-correctives.md b/.changeset/7507-non-grid-ceiling-correctives.md new file mode 100644 index 000000000..1d72d6d1f --- /dev/null +++ b/.changeset/7507-non-grid-ceiling-correctives.md @@ -0,0 +1,24 @@ +--- +--- + +Post-merge correctives to objectui#7210's non-grid row ceiling (objectui#7507), +all of them inside a change that has not been released yet — so nothing here is +a user-visible fix, and the ceiling's own changeset carries the release note. + +- `.changeset/7210-non-grid-row-ceiling.md`: the four view packages move from + `patch` to `minor` (they carry the behaviour break), the example footnote is + replaced with the copy that actually renders — no thousands separators, since + the i18next config declares no `format` and the provider-less path + interpolates through `String(v)` — and the export list names + `NonGridCeilingResult`. +- `ObjectCalendar`'s external-`data` sync now also clears `rowCeiling`, so a + footnote raised by this component's own truncated fetch cannot outlive the + rows it described. Latent today: the only host that passes `data` passes it + from mount. Pinned in `ObjectCalendar.externalDataCeiling-7507.test.tsx`. +- The map and calendar ceiling pins now assert the row count handed to the + view, not only `$top` and the footnote; the four pins' reverse-verification + docblocks are rewritten to the mechanism that was measured rather than the + one that was predicted. +- Comment-only: the false "the only package all four already depend on" + rationale on `@object-ui/react`'s entry, and the stale "~1 KB of headroom" + note in the ten locale packs. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index b73908439..eda98184a 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -133,7 +133,10 @@ const ar = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: "يتم عرض أول {{shown}} من أصل {{total}} سجل. ضيّق عامل التصفية.", rowCeilingNoteUnknownTotal: "يتم عرض أول {{shown}} سجل. ضيّق عامل التصفية.", }, diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e93b239ff..e063401cd 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -129,7 +129,10 @@ const de = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: "Erste {{shown}} von {{total}} Datensätzen. Filter eingrenzen.", rowCeilingNoteUnknownTotal: "Erste {{shown}} Datensätze. Filter eingrenzen.", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 9d8dea77f..a5bcd714f 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -153,7 +153,10 @@ const en = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: 'Showing the first {{shown}} of {{total}} records. Narrow the filter.', rowCeilingNoteUnknownTotal: 'Showing the first {{shown}} records. Narrow the filter.', }, diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 2375c995d..6d81730bd 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -128,7 +128,10 @@ const es = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: "Mostrando los primeros {{shown}} de {{total}} registros. Acota el filtro.", rowCeilingNoteUnknownTotal: "Mostrando los primeros {{shown}} registros. Acota el filtro.", }, diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 5e48730a1..7004d59af 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -129,7 +129,10 @@ const fr = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: "Affichage des {{shown}} premiers enregistrements sur {{total}}. Affinez le filtre.", rowCeilingNoteUnknownTotal: "Affichage des {{shown}} premiers enregistrements. Affinez le filtre.", }, diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 036874fb2..0e1cc958a 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -129,7 +129,10 @@ const ja = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: "{{total}} 件中、最初の {{shown}} 件を表示しています。フィルターを絞り込んでください。", rowCeilingNoteUnknownTotal: "最初の {{shown}} 件を表示しています。フィルターを絞り込んでください。", }, diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 0730fcdcc..e697cef48 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -129,7 +129,10 @@ const ko = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: "전체 {{total}}개 중 처음 {{shown}}개를 표시합니다. 필터를 좁히세요.", rowCeilingNoteUnknownTotal: "처음 {{shown}}개를 표시합니다. 필터를 좁히세요.", }, diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 0691e0e05..8b1fa5db3 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -128,7 +128,10 @@ const pt = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: "Mostrando os primeiros {{shown}} de {{total}} registros. Restrinja o filtro.", rowCeilingNoteUnknownTotal: "Mostrando os primeiros {{shown}} registros. Restrinja o filtro.", }, diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index ac48e1ef6..18dc4fbd1 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -135,7 +135,10 @@ const ru = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: "Показаны первые {{shown}} из {{total}} записей. Сузьте фильтр.", rowCeilingNoteUnknownTotal: "Показаны первые {{shown}} записей. Сузьте фильтр.", }, diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 62dda095e..eecd56217 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -136,7 +136,10 @@ const zh = { // 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. + // eagerly loaded, and since objectui#7399 these bytes are budgeted by the + // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the + // baseline it was measured from, about sixty short keys' worth across ten + // locales. `pnpm check:eager-closure` prints the figure in force. rowCeilingNote: '仅显示 {{total}} 条记录中的前 {{shown}} 条。请缩小筛选范围。', rowCeilingNoteUnknownTotal: '仅显示前 {{shown}} 条记录。请缩小筛选范围。', }, diff --git a/packages/plugin-calendar/src/ObjectCalendar.externalDataCeiling-7507.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.externalDataCeiling-7507.test.tsx new file mode 100644 index 000000000..988663e55 --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.externalDataCeiling-7507.test.tsx @@ -0,0 +1,148 @@ +/** + * 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#7507 — the row-ceiling footnote must not outlive the fetch that + * raised it (objectui#7210's ceiling, ruling a′). + * + * `ObjectCalendar` has five `setData` paths. Four of them also reset + * `rowCeiling`; the external-`data` sync was the one that did not, so a + * component that first drew its OWN truncated fetch and then had a short + * `data` array handed to it kept showing "Showing the first 2000 of 9876 + * records" over twelve rows. The note would be describing a result set that is + * no longer on screen — which is the same defect the ceiling exists to + * prevent, pointing the other way: not a silent cut, but a loud claim of one + * that did not happen. + * + * ⚠️ Latent, not reported. Today's only host that passes `data` is + * `ObjectView`, and it passes it from mount, so `hasExternalData` is true + * before the internal fetch can ever run and the stale state is unreachable. + * That is exactly why it is pinned rather than only fixed: nothing about the + * prop's contract says a host may not start without `data` and supply it + * later, and a latent defect with no test is one refactor away from being a + * reported one. + * + * REVERSE VERIFICATION — direction predicted before running: remove the + * `setRowCeiling({ truncated: false })` from the external-`data` effect in + * `ObjectCalendar` and this file goes red at the "the note is gone" assertion + * (the note keeps rendering, still naming 2000 and 9876), while the control + * case — a component that never truncated — 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 } from '@object-ui/react'; +import { ObjectCalendar } from './ObjectCalendar'; + +vi.mock('@object-ui/plugin-detail', () => ({ + RecordDetailDrawer: () => null, + deriveRecordPageHref: () => null, +})); + +// Same reason as the sibling rowCeiling pin: the month grid draws at most four +// events per day cell, so the count has to come off an attribute. +vi.mock('./CalendarView', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + CalendarView: ({ events }: any) => ( +
+ ), + }; +}); + +const TOTAL_ROWS = 9876; +const NOW = new Date(); + +function makeRows(n: number, offset = 0) { + return Array.from({ length: n }, (_, i) => { + const d = new Date(NOW.getFullYear(), NOW.getMonth(), ((i + offset) % 28) + 1); + const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate(), + ).padStart(2, '0')}`; + return { + id: String(i + offset + 1), + subject: `Event ${i + offset + 1}`, + start_at: iso, + end_at: iso, + }; + }); +} + +function makeDataSource(storeSize: number) { + const store = makeRows(storeSize); + return { + find: vi.fn(async (_resource: string, params: any) => { + 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#7507 — an external `data` hand-off clears the row-ceiling note', () => { + it('a truncated own fetch, then a short external `data`: the note is GONE', async () => { + const dataSource = makeDataSource(TOTAL_ROWS); + const { rerender } = render(); + + // Live control: the note really was raised by this component's own fetch, + // so its absence below is a change of state and not a query that never + // matched anything. + const note = await screen.findByRole('note'); + expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING)); + expect(note.textContent).toContain(String(TOTAL_ROWS)); + await waitFor(() => + expect(screen.getByTestId('calendar-view').getAttribute('data-event-count')).toBe( + String(NON_GRID_ROW_CEILING), + ), + ); + + const short = makeRows(12); + rerender(); + + await waitFor(() => + expect(screen.getByTestId('calendar-view').getAttribute('data-event-count')).toBe('12'), + ); + expect(screen.queryByRole('note')).toBeNull(); + }); + + it('external `data` from mount: still no note, and the rows are the parent’s', async () => { + const dataSource = makeDataSource(TOTAL_ROWS); + const short = makeRows(9); + + render(); + + await waitFor(() => + expect(screen.getByTestId('calendar-view').getAttribute('data-event-count')).toBe('9'), + ); + // The parent owns the query here, so this component never fetched and has + // nothing to report a ceiling about. + expect(dataSource.find).not.toHaveBeenCalled(); + expect(screen.queryByRole('note')).toBeNull(); + }); +}); diff --git a/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx index 0ceb7a56a..e50ddca4e 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx @@ -41,6 +41,23 @@ vi.mock('@object-ui/plugin-detail', () => ({ deriveRecordPageHref: () => null, })); +// The month grid is irrelevant here — what this file has to observe is HOW MANY +// records reached the view layer, and the grid deliberately hides that: it draws +// at most four events per day cell, so 2,000 events and 2,001 events paint the +// same picture. Stubbing the child the way the gantt pin stubs `GanttView` puts +// the count on an attribute where an assertion can reach it. `importOriginal` +// keeps the module's other exports (`resolveEventColor` and friends) live, the +// same idiom `ObjectCalendar.unscheduled-7071` uses. +vi.mock('./CalendarView', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + CalendarView: ({ events }: any) => ( +
+ ), + }; +}); + const TOTAL_ROWS = 9876; const NOW = new Date(); @@ -98,6 +115,17 @@ describe('objectui#7210 ruling a′ — the calendar caps at the platform ceilin expect(params.$top).toBe(NON_GRID_ROW_CEILING_TOP); } + // ⭐ The ruling's own words — "the DOM row count equals the ceiling". The + // `$top` and the footnote below it are both true of a view that then drew + // every row the adapter sent: measured on the merged commit, replacing the + // capped hand-off with the raw response left this file green at 4/4 with + // 2,001 events on the grid. This is the assertion that was missing. + await waitFor(() => + expect(screen.getByTestId('calendar-view').getAttribute('data-event-count')).toBe( + String(NON_GRID_ROW_CEILING), + ), + ); + 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)); @@ -112,6 +140,10 @@ describe('objectui#7210 ruling a′ — the calendar caps at the platform ceilin await waitFor(() => expect(calls.length).toBeGreaterThan(0)); await waitFor(() => expect(screen.queryByText(/Loading/i)).toBeNull()); + // Below the ceiling the count is the whole set, not the ceiling — the other + // half of "draws at most N", and what keeps the case above from passing on a + // view that simply caps everything at 2,000 unconditionally. + expect(screen.getByTestId('calendar-view').getAttribute('data-event-count')).toBe('12'); expect(screen.queryByRole('note')).toBeNull(); }); }); diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 630957c4c..73eb0908a 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -343,6 +343,13 @@ export const ObjectCalendar: React.FC = ({ useEffect(() => { if (hasExternalData) { setData(externalData!); + // ...and drop any ceiling this component's OWN fetch had reported + // (objectui#7210). A parent that hands over `data` owns the query, so it + // owns whether that query was capped; a `truncated` left over from a + // fetch whose rows are no longer on screen is a footnote about a result + // set that is not being drawn. Every other `setData` path here already + // resets it — this was the one that did not. + setRowCeiling({ truncated: false }); } }, [externalData, hasExternalData]); diff --git a/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx b/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx index 37b763f50..54a7fa351 100644 --- a/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx +++ b/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx @@ -100,7 +100,33 @@ describe('objectui#7210 ruling a′ — the map caps at the platform ceiling, lo expect(note.textContent).toContain(String(TOTAL_ROWS)); }); - it('below the ceiling: there is NO footnote', async () => { + it('above the ceiling: exactly N markers reach the map, not N+1', async () => { + const calls: Array> = []; + const dataSource = makeDataSource(TOTAL_ROWS, calls); + + // ⭐ The ruling's own words — "the DOM row count equals the ceiling". The + // `$top` and the footnote in the case above are both true of a map that + // then plotted every row the adapter sent: measured on the merged commit, + // replacing the capped hand-off with the raw response left this file green + // at 4/4 with 2,001 markers on the map. This is the assertion that was + // missing, and it is a case of its own for one reason: + // + // ⚠️ `enableClustering={false}` is what makes the count OBSERVABLE at all. + // The map's default is to cluster above 100 markers, and a cluster bubble + // is exactly a marker count folded into one DOM node — the defence the + // file's own docblock names as what makes a silent cut invisible here. + // Clustering is a pure function of the marker array, so turning it off + // changes what is on screen and not what reached the view; with it on, + // 2,000 and 2,001 markers render the same handful of bubbles. + render(); + + await waitFor(() => expect(calls.length).toBeGreaterThan(0)); + await waitFor(() => + expect(screen.queryAllByTestId('map-marker').length).toBe(NON_GRID_ROW_CEILING), + ); + }); + + it('below the ceiling: every marker draws and there is NO footnote', async () => { const calls: Array> = []; const dataSource = makeDataSource(20, calls); @@ -108,6 +134,11 @@ describe('objectui#7210 ruling a′ — the map caps at the platform ceiling, lo await waitFor(() => expect(calls.length).toBeGreaterThan(0)); await waitFor(() => expect(screen.queryByText(/Loading map/i)).toBeNull()); + // 20 is under the map's own 100-marker clustering threshold, so every + // record is its own DOM marker here with no prop needed. The other half of + // "draws at most N": it keeps the case above from passing on a map that + // caps everything at 2,000 unconditionally. + expect(screen.queryAllByTestId('map-marker').length).toBe(20); expect(screen.queryByRole('note')).toBeNull(); }); }); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 087f6f28f..86c0a3d5b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -100,10 +100,26 @@ export { // 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. +// and tree (objectui#7210, maintainer ruling a′). One constant across the +// four, because a per-plugin copy would be four constants wearing one name, +// which is the thing the ruling ruled out. +// +// ⚠️ WHY IT LIVES HERE is a scheduling fact, not an architectural one, and the +// comment that used to stand here said otherwise: "the only package all four +// already depend on". That is measured FALSE — +// `packages/plugin-{gantt,calendar,map,tree}/package.json` each also list +// `@object-ui/core`, `@object-ui/components` and `@object-ui/types`. The real +// reason is that #7210 was dispatched with a same-round barrel fence over +// exactly those: `core` was held by #7349, `types` and `components` by #7182, +// and `@object-ui/react` has no subpath export, so "one constant, not in those +// three" left the package's sole entry as the only reachable home. +// +// ⛔ Do not read this placement as a decision that it belongs here. The +// non-React half (`applyNonGridRowCeiling`, `NonGridCeilingResult`) wraps +// `core`'s own `extractRecords` and would sit beside it; whether these five +// symbols move, and whether `NON_GRID_ROW_CEILING_TOP` should be published at +// all, is an OPEN maintainer decision on objectui#7508. Anything that grows a +// dependency on this exact shape makes that decision more expensive. export { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP, From a221c9288ffd85fadaa3b526661dbe9222b86854 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 15:57:25 +0000 Subject: [PATCH 2/2] docs(non-grid): rewrite the four ceiling pins' reverse-verification docblocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four predicted a mechanism that does not happen. Measured on this branch, each ablation proven on disk and restored byte-identically: - gantt, tree, calendar, map: deleting `$top: NON_GRID_ROW_CEILING_TOP` goes red at the `$top` ASSERTION and nowhere else. Not at the footnote (gantt, calendar, map) and not at the row count (tree): an adapter with no `$top` answers with the whole filtered set, `applyNonGridRowCeiling` slices it to the ceiling from the rows in hand, and both the drawn count and the note stay correct. The probe row bounds the response; it is not the detector's only input once an unbounded response has arrived. - calendar and map additionally record the second ablation their new count assertions exist for: handing the view the raw response instead of the capped rows is red at the count, 2001 against 2000 — the mutation that left both files green at 4/4 before this branch. A pin whose docblock names the wrong discriminator is worse than one that names none: the next reader trusts it while deciding what an edit may safely break. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .../ObjectCalendar.rowCeiling-7210.test.tsx | 21 +++++++++++++++---- .../src/ObjectGantt.rowCeiling-7210.test.tsx | 21 ++++++++++++++----- .../src/ObjectMap.rowCeiling-7210.test.tsx | 21 +++++++++++++++---- .../src/ObjectTree.rowCeiling-7210.test.tsx | 19 ++++++++++++----- 4 files changed, 64 insertions(+), 18 deletions(-) diff --git a/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx index e50ddca4e..bc195b81d 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.rowCeiling-7210.test.tsx @@ -24,10 +24,23 @@ * `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. + * REVERSE VERIFICATION — MEASURED on two separate ablations (objectui#7507), + * because this file grades two different things and one of them was missing: + * + * 1. Remove `$top: NON_GRID_ROW_CEILING_TOP` from `ObjectCalendar`'s record + * fetch ⇒ red at the **`$top` assertion**, and there only; 1 failed / + * 1 passed. NOT at the footnote, which is what this docblock used to + * predict. An adapter with no `$top` answers with the whole filtered set, + * `applyNonGridRowCeiling` slices it to the ceiling from the rows in + * hand, and both the event count and the note stay correct. Losing the + * `$top` is a bandwidth regression, not a correctness one. + * 2. Hand the view the RAW response instead of the capped rows + * (`setData(capped.rows)` → `setData(result.data ?? capped.rows)`) ⇒ red + * at the **event-count assertion**, 2001 against 2000. Before #7507 that + * mutation left this file green at 4/4: `$top` was still sent and the + * footnote still rendered, while 2,001 events were drawn. That is the + * hole the count assertion below closes, and it is the ruling's own pin + * text — "the DOM row count equals the ceiling". */ import React from 'react'; diff --git a/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx b/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx index 05cc97909..566c981a8 100644 --- a/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.rowCeiling-7210.test.tsx @@ -26,11 +26,22 @@ * 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. + * REVERSE VERIFICATION — MEASURED, and corrected from what this docblock used + * to predict (objectui#7507). Removing `$top: NON_GRID_ROW_CEILING_TOP` from + * `ObjectGantt`'s reload turns the truncation case red at the **`$top` + * assertion**, 1 failed / 2 passed; the below-ceiling case stays green. + * + * ⚠️ It does NOT go red at the footnote. The prediction that it would — "no + * probe row ⇒ `truncated` false ⇒ no note" — reads the mechanism backwards, + * and a wrong mechanism in a docblock is worse than none, because the next + * reader trusts it while deciding what an edit is safe to break. What actually + * happens: an adapter with no `$top` answers with the WHOLE filtered set, so + * `applyNonGridRowCeiling` sees far more rows than the ceiling, slices to it, + * and reports `truncated` from the rows in hand. The drawn count stays 2,000 + * and the note still names both numbers. The probe row bounds the RESPONSE; it + * is not what makes truncation detectable once an unbounded one has arrived. + * So in this file the `$top` is graded by the `$top` assertion and by nothing + * else — which is the reason that assertion is not redundant with the note. */ import React from 'react'; diff --git a/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx b/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx index 54a7fa351..46ccd9d70 100644 --- a/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx +++ b/packages/plugin-map/src/ObjectMap.rowCeiling-7210.test.tsx @@ -20,10 +20,23 @@ * 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. + * REVERSE VERIFICATION — MEASURED on two separate ablations (objectui#7507), + * because this file grades two different things and one of them was missing: + * + * 1. Remove `$top: NON_GRID_ROW_CEILING_TOP` from `ObjectMap`'s fetch ⇒ red + * at the **`$top` assertion**, and there only; 1 failed / 2 passed. NOT + * at the footnote, which is what this docblock used to predict. An + * adapter with no `$top` answers with the whole filtered set, + * `applyNonGridRowCeiling` slices it to the ceiling from the rows in + * hand, and both the marker count and the note stay correct. Losing the + * `$top` is a bandwidth regression, not a correctness one. + * 2. Hand the view the RAW response instead of the capped rows + * (`setData(capped.rows)` → `setData(result.data ?? capped.rows)`) ⇒ red + * at the **marker-count assertion**, 2001 against 2000. Before #7507 that + * mutation left this file green at 4/4: `$top` was still sent and the + * footnote still rendered, while 2,001 markers were plotted. That is the + * hole the count case below closes, and it is the ruling's own pin text — + * "the DOM row count equals the ceiling". */ import React from 'react'; diff --git a/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx b/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx index 7e46ce678..1495e1c42 100644 --- a/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx +++ b/packages/plugin-tree/src/ObjectTree.rowCeiling-7210.test.tsx @@ -22,11 +22,20 @@ * 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. + * REVERSE VERIFICATION — MEASURED, and corrected from what this docblock used + * to predict (objectui#7507). Removing `$top: NON_GRID_ROW_CEILING_TOP` from + * `ObjectTree`'s record fetch turns the truncation case red at the **`$top` + * assertion** and there only, 1 failed / 1 passed; the below-ceiling case + * stays green. + * + * ⚠️ The row count does NOT move, and the footnote does not disappear. The old + * prediction — "the whole store arrives, nothing is capped, `truncated` is + * false" — got the first clause right and drew the wrong conclusion from it. + * The whole store does arrive; `applyNonGridRowCeiling` then slices it to the + * ceiling and reports `truncated` from the rows in hand, so the rendered + * `` count is still exactly 2,000 and the note still names both numbers. + * Losing the `$top` is a BANDWIDTH regression here, not a correctness one, and + * the `$top` assertion is the only thing in this file that sees it. */ import React from 'react';