diff --git a/.changeset/7364-timeline-nonobject-row.md b/.changeset/7364-timeline-nonobject-row.md new file mode 100644 index 0000000000..66875185c1 --- /dev/null +++ b/.changeset/7364-timeline-nonobject-row.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-timeline': patch +--- + +Gantt: a row that is not an object is now refused instead of drawn as an empty, +unlabelled row (objectui#7364). `items: [0]`, `items: ['x']`, `items: [true]` +and `items: [[]]` used to render a blank row with no word to the author, while +`validate` already refused every one of them at authoring time. The renderer now +agrees with `validate`: the chart shows the existing +`timeline.gantt.unusableRange.malformedRow` diagnostic, naming the row's path +and value (for example `items[0] is 0, which is not a row shape`). No new i18n +key and no locale pack change. Well-formed object rows, a row with no `items` +key and a row whose `items` is `null` draw exactly as before. diff --git a/packages/plugin-timeline/src/__tests__/timeline-gantt-date-brand-7027.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-gantt-date-brand-7027.test.tsx index 1159032988..9132c48c70 100644 --- a/packages/plugin-timeline/src/__tests__/timeline-gantt-date-brand-7027.test.tsx +++ b/packages/plugin-timeline/src/__tests__/timeline-gantt-date-brand-7027.test.tsx @@ -586,7 +586,11 @@ describe('pin 5 — the SIX non-total reads UPSTREAM of the speller (objectui#71 // touches it, so these two now throw `IsArray` (pin 4's class) instead of // `get`. Same reachability argument, same exclusion; the site moved. ['as `items` itself (dies at `Array.isArray` in `classifyGanttRows`, upstream of U1)', () => ({ items: revokedProxy([]) }), REVOKED_ISARRAY], - ['as a ROW (dies at U3 — now read in `classifyGanttRows`)', () => ({ items: [revokedProxy()] }), REVOKED_GET], + // objectui#7364: `classifyGanttRows` now asks `Array.isArray(row)` (a row + // must be an object, not an array) before U3 reads `row.items`, so a + // revoked proxy as a ROW dies at `IsArray` too. Same exclusion; the site + // moved one operation up, inside the same function. + ['as a ROW (dies at `Array.isArray` in `classifyGanttRows`, upstream of U3)', () => ({ items: [revokedProxy()] }), REVOKED_ISARRAY], ["as a row's `items` (dies at `Array.isArray` in `classifyGanttRows`, upstream of U4)", () => ({ items: [{ label: 'R', items: revokedProxy([]) }] }), REVOKED_ISARRAY], ['as an ITEM (dies at U6)', () => ({ items: [{ label: 'R', items: [revokedProxy()] }] }), REVOKED_GET], ]; @@ -682,17 +686,19 @@ describe('pin 6 — `calculateDateRange`’s three reads: the DEFECT, now REPAIR // The live controls that make the nine rows above readings rather than a // broken harness, and that fence the accept set: every one of these drew // before the repair and draws after it. The ruling refused the three lines - // above and nothing wider; a non-null primitive row (`0`) and an array row - // (`[]`) have no `.items` and draw as an unlabelled empty row, exactly as - // they did — the zod mirror is the door that refuses those, at authoring. + // above and nothing wider. + // + // A non-null primitive row (`0`) and an array row (`[]`) stood here as + // controls, drawing an unlabelled empty row, until objectui#7364 (ruling + // 5809218505, letter A) DELIBERATELY SUPERSEDED that clause of the #7164 + // ruling: the renderer now refuses them as `validate` already did. They + // are asserted as REFUSALS just below, not dropped. const drawn: [string, Record][] = [ ['an ordinary row', { items: [goodRow()] }], ['an empty items list', { items: [] }], ['a row with no `items` key', { items: [{ label: 'R' }] }], ['a row whose `items` is null', { items: [{ label: 'R', items: null }] }], ['a row whose `items` is an empty array', { items: [{ label: 'R', items: [] }] }], - ['a row that is the number 0', { items: [0] }], - ['a row that is an empty array', { items: [[]] }], ]; for (const [label, schema] of drawn) { const { container } = gantt(schema); @@ -700,6 +706,13 @@ describe('pin 6 — `calculateDateRange`’s three reads: the DEFECT, now REPAIR expect(axisOf(container).length, `${label} drew no axis`).toBeGreaterThan(0); } + // objectui#7364 — the two former controls, now REFUSED through the row door. + for (const [row, spelled] of [[0, '0'], [[], 'an array']] as const) { + const { container } = gantt({ items: [row] }); + expect(diagnosticOf(container) ?? '').toContain(`items[0] is ${spelled}, ${ROW_CLAUSE}`); + expect(barCountOf(container)).toBe(0); + } + // The string that did NOT crash before — index-readable, so the old walk // named `items[0].items[0].startDate is undefined` through the DATE copy // by accident. A string is a truthy non-array, which is the ruling's row diff --git a/packages/plugin-timeline/src/__tests__/timeline-gantt-malformed-row-7164.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-gantt-malformed-row-7164.test.tsx index 94735a4470..ef999b1ac9 100644 --- a/packages/plugin-timeline/src/__tests__/timeline-gantt-malformed-row-7164.test.tsx +++ b/packages/plugin-timeline/src/__tests__/timeline-gantt-malformed-row-7164.test.tsx @@ -34,6 +34,9 @@ * CONTROL items: [{ items: null }] DREW bars=0 axisCells=1 * CONTROL items: [0] / [[]] DREW bars=0 axisCells=1 * + * (That last line is SUPERSEDED — see "The non-object ROW" below. The table + * is the reading on `a67abdc88` and is kept as it was measured.) + * * ## Why the repair is ONE reader, not a guard in each * * The card measured, by ablation, that a tolerant `calculateDateRange` closes @@ -52,10 +55,24 @@ * its own, naming the authored path and the value. Never through * `malformedDate`: "items[0] is null, which is not a valid date" names the * wrong fault for a row that is not a row. - * - DRAWN — the five CONTROL shapes, with the SAME bar and axis-cell counts as + * - DRAWN — the CONTROL shapes, with the SAME bar and axis-cell counts as * before the change. The accept set is the ruling's and no wider. * - THREW is what this card removes. * + * ## The non-object ROW — a control SUPERSEDED by objectui#7364 + * + * The A+ ruling of objectui#7164 required `items: [0]` and `items: [[]]` to + * keep DRAWING, and this file pinned them as CONTROLS. They drew an empty, + * unlabelled row while `validate` (the zod mirror, `TimelineSchema.items`) + * refused both at authoring time. The objectui#7364 ruling — comment + * 5809218505, letter A, maintainer 「同意」 — DELIBERATELY SUPERSEDES that + * clause: `classifyGanttRows` refuses every row that is not an object (a + * number, a string, a boolean, an array), through the same `malformedRow` key, + * so the renderer agrees with `validate`. Those two pins are therefore + * REWRITTEN to expect the refusal, not deleted, and joined by `['x']` and + * `[true]`, the other two shapes the card names. The well-formed object row + * stays a control that still DRAWS. + * * ## Assertions count BAR ELEMENTS, never styles * * #6759's rule, inherited through every gantt pin since: a bar whose geometry @@ -185,7 +202,7 @@ describe('the card’s THREW rows now REFUSE, each naming its path and value (ob }); }); -describe('the NAMED row takes the row door, and the five CONTROL rows still DRAW with the same counts', () => { +describe('the NAMED row takes the row door, and the CONTROL rows still DRAW with the same counts', () => { it("items: [{ items: 'x' }] — the card's NAMED row is now REFUSED through malformedRow, naming the true fault", () => { // On `a67abdc88` this row did not crash: a string is index-readable, so // the old walk reached `'x'[0]?.startDate`, read `undefined`, and named @@ -208,8 +225,9 @@ describe('the NAMED row takes the row door, and the five CONTROL rows still DRAW ['items: []', { items: [] }, 0, 1], ["items: [{ label: 'R' }] — a row with no items key", { items: [{ label: 'R' }] }, 0, 1], ['items: [{ items: null }] — a row whose items is null (the empty row)', { items: [{ label: 'R', items: null }] }, 0, 1], - ['items: [0] — a row that is the number 0', { items: [0] }, 0, 1], - ['items: [[]] — a row that is an empty array', { items: [[]] }, 0, 1], + // `items: [0]` and `items: [[]]` stood here as controls until + // objectui#7364 (ruling 5809218505, A) superseded them — they are pinned + // as REFUSALS in the block below, with the reason in the header. ]; for (const [label, schema, bars, axisCells] of controls) { @@ -227,6 +245,60 @@ describe('the NAMED row takes the row door, and the five CONTROL rows still DRAW }); }); +describe('a row that is NOT AN OBJECT is refused — objectui#7364 supersedes the #7164 control', () => { + /** + * Ruling 5809218505 (letter A): the renderer refuses what `validate` already + * refuses. The first two rows are the #7164 controls REWRITTEN — before + * objectui#7364 each drew `bars=0 axisCells=1` with no label and no word to + * the author; the other two are the card's remaining shapes. `value` is the + * speller's own spelling (`spellGanttDateValue`): an array is `an array`, a + * string is quoted. + */ + const refused: [string, Record, string][] = [ + ['items: [0] — a row that is the number 0 (a #7164 control, superseded)', { items: [0] }, '0'], + ['items: [[]] — a row that is an empty array (a #7164 control, superseded)', { items: [[]] }, 'an array'], + ["items: ['x'] — a row that is a string", { items: ['x'] }, '"x"'], + ['items: [true] — a row that is a boolean', { items: [true] }, 'true'], + ]; + + for (const [label, schema, value] of refused) { + it(`${label} -> REFUSED at items[0]`, () => { + const { container } = gantt(schema); + const text = diagnosticOf(container); + expect(text, 'no diagnostic rendered — the row drew').not.toBeNull(); + expect(text).toContain(`items[0] is ${value}, ${ROW_CLAUSE}`); + expect(text).not.toContain(DATE_CLAUSE); + expect(barCountOf(container)).toBe(0); + expect(axisOf(container)).toEqual([]); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + }); + } + + it('a non-object row BESIDE well-formed rows is named by its own index, and wins over a later bad date', () => { + const { container } = gantt({ + items: [goodRow(), 0, { label: 'R', items: [{ title: 'T', startDate: 'never', endDate: 'ever' }] }], + }); + const text = diagnosticOf(container) ?? ''; + expect(text).toContain(`items[1] is 0, ${ROW_CLAUSE}`); + expect(text).not.toContain('startDate'); + }); + + it('CONTROL — a well-formed object row still DRAWS, and so does the empty-row family', () => { + const drawn: [string, Record, number][] = [ + ['an ordinary row', { items: [goodRow()] }, 1], + ['two ordinary rows', { items: [goodRow(), goodRow()] }, 2], + ["a row with no items key", { items: [{ label: 'R' }] }, 0], + ['a row whose items is null', { items: [{ label: 'R', items: null }] }, 0], + ]; + for (const [label, schema, bars] of drawn) { + const { container } = gantt(schema); + expect(diagnosticOf(container), `${label} was refused`).toBeNull(); + expect(barCountOf(container), label).toBe(bars); + expect(axisOf(container).length, `${label} drew no axis`).toBeGreaterThan(0); + } + }); +}); + describe('the three readers consume one verdict — the contract the render loop keeps', () => { it('onItemClick still receives the AUTHORED row object, not a normalized copy', () => { // The render loop reads `label` and `items` off the verdict, but the row diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index cfd4bc3c86..965371a779 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -292,7 +292,9 @@ type GanttRowsVerdict = * ## The three refusals, in walk order * * 1. `items` is not an array -> path `items` - * 2. a row is `null` (or `undefined`) -> path `items[i]` + * 2. a row is not an object -> path `items[i]` + * (`null`, `undefined`, a number, a string, a boolean, an array, or + * any other non-object — objectui#7364 widened this line from `null`) * 3. `row.items` is a TRUTHY non-array -> path `items[i].items` * * Each names the authored location and the value — spelled by @@ -308,15 +310,31 @@ type GanttRowsVerdict = * REFUSE, not skip: drawing the well-formed rows and dropping the malformed * one silently is the consumer-side tolerance objectui#6750 and objectui#6759 * both refused, and the ruling declined it (option B). Nor is the set widened - * past the three lines above. The shapes NEXT to them keep drawing, as - * measured before the change and pinned as CONTROLS: `items: []`, a row with - * no `items` key, a row whose `items` is `null` (both the empty row), and a - * row that is a non-null primitive or an array (`[0]`, `[[]]`), which has no - * `.items` and draws as an unlabelled empty row. The zod mirror - * (`@object-ui/types`, `TimelineSchema.items`) refuses those last two at - * AUTHORING time — an element must be an object — so the door a document - * meets first is the stricter one; the renderer is only ever more lenient than - * `validate`, never the reverse, and never crashes on what `validate` admits. + * past the three lines above. The shapes NEXT to them keep drawing, pinned as + * CONTROLS: `items: []`, a row with no `items` key, and a row whose `items` + * is `null` (all the empty row). The renderer is only ever as lenient as + * `validate` or more, never the reverse, and never crashes on what `validate` + * admits. + * + * ## A row that is not an object (objectui#7364, ruling 5809218505, A) + * + * objectui#7164's ruling kept `items: [0]` and `items: [[]]` DRAWING, as + * CONTROLS: a non-null primitive or an array has no `.items` and no `.label`, + * so it drew an EMPTY, UNLABELLED row and nothing told the author. The zod + * mirror (`@object-ui/types`, `TimelineSchema.items`) already refused both at + * AUTHORING time, so the declared and the rendered accept sets disagreed in + * that corner. The objectui#7364 ruling (letter A, maintainer 2026-09-24) + * DELIBERATELY SUPERSEDES that clause: line 2 now refuses every row that is + * not an object, through the SAME `malformedRow` key — the existing copy + * already reads `items[0] is 0, which is not a row shape` — so no key and no + * pack string was added. + * + * "Not an object" is `typeof row !== 'object' || row === null || + * Array.isArray(row)`. That is the set JSON can spell and the mirror refuses + * (`z.object` refuses `null`, numbers, strings, booleans and arrays). No + * prototype test is made: a `Date`, a class instance or a `Proxy` row cannot + * be written in JSON, a prototype read would be one more non-total site for a + * revoked proxy, and the live-`Proxy` row control must keep drawing. * * ## What this does to the exotic exclusion (objectui#7153, pin 5) * @@ -326,13 +344,17 @@ type GanttRowsVerdict = * trap writes them. `Array.isArray` is a NEW non-total site for a revoked * `Proxy` (it throws `Cannot perform 'IsArray' …`, the same class * `spellGanttDateValue` already declares), so a revoked proxy handed as `items` - * or as a row's `items` now dies here rather than at U1 / U4. Both moves are - * stated and exercised in `timeline-gantt-date-brand-7027.test.tsx`, and the - * reachability argument is unchanged: JSON cannot spell a proxy. + * or as a row's `items` now dies here rather than at U1 / U4 — and, since + * objectui#7364 asks `Array.isArray(row)` before U3 reads `row.items`, a + * revoked proxy handed as a ROW dies at `IsArray` too rather than at U3. All + * three moves are stated and exercised in + * `timeline-gantt-date-brand-7027.test.tsx`, and the reachability argument is + * unchanged: JSON cannot spell a proxy. * * Pinned by `./__tests__/timeline-gantt-malformed-row-7164.test.tsx`, which * re-runs the card's THREW table (now REFUSED, each naming its path) with the - * five drawing controls unchanged. + * drawing controls unchanged, and — objectui#7364 — the non-object rows that + * table used to keep as controls, now REFUSED. */ function classifyGanttRows(items: unknown): GanttRowsVerdict { if (!Array.isArray(items)) return { ok: false, path: 'items', value: items }; @@ -340,7 +362,10 @@ function classifyGanttRows(items: unknown): GanttRowsVerdict { const rows: GanttRow[] = []; for (let rowIndex = 0; rowIndex < items.length; rowIndex++) { const row = items[rowIndex]; - if (row == null) return { ok: false, path: `items[${rowIndex}]`, value: row }; + // objectui#7364 — any non-object row, not only `null` (see above). + if (typeof row !== 'object' || row === null || Array.isArray(row)) { + return { ok: false, path: `items[${rowIndex}]`, value: row }; + } const rowItems = row.items; if (rowItems && !Array.isArray(rowItems)) { @@ -1003,7 +1028,7 @@ const isGanttDateType = (value: unknown): value is string | number | Date => * * 2. `calculateDateRange`'s THREE — ordinary JSON, and a live defect until * objectui#7164 REPAIRED it: `classifyGanttRows` now refuses `items` that is - * not an array, a `null` row and a truthy non-array `row.items` through + * not an array, a non-object row and a truthy non-array `row.items` through * `timeline.gantt.unusableRange.malformedRow`, and every reader below it * consumes that verdict. They were never this class and never p3, and the * repair does not change that: the sentence "JSON cannot spell this" is @@ -1375,10 +1400,11 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time if (variant === 'gantt') { /** * objectui#7164 — the rows are judged as ROWS, once, before anything - * reads them. `items` not an array, a `null` row, or a row whose `items` - * is a truthy non-array refuses the chart through its own key, naming the - * authored path and the value; see `classifyGanttRows` for the table, - * the accept set and why a guard in one reader only relocated the crash. + * reads them. `items` not an array, a non-object row (objectui#7364), or + * a row whose `items` is a truthy non-array refuses the chart through its + * own key, naming the authored path and the value; see + * `classifyGanttRows` for the table, the accept set and why a guard in + * one reader only relocated the crash. * The refusal surface is the one #6759 established below (same element, * same channel) — one `role="alert"` for every way a gantt cannot draw. */ diff --git a/packages/types/src/__tests__/timeline-items-row-shape-7164.test.ts b/packages/types/src/__tests__/timeline-items-row-shape-7164.test.ts index 43e289f021..74749b400d 100644 --- a/packages/types/src/__tests__/timeline-items-row-shape-7164.test.ts +++ b/packages/types/src/__tests__/timeline-items-row-shape-7164.test.ts @@ -37,11 +37,13 @@ * a feed-variant items array accept accept * an ordinary gantt row accept accept * - * `items: [{ items: null }]`, `[0]` and `[[]]` are refused HERE and still DRAW - * in the renderer (as an empty / unlabelled row): the renderer is only ever - * more lenient than `validate`, never the reverse, and it never crashes on a - * document `validate` admits — that is the invariant, and it is asserted at the - * foot of this file over the in-repo fixtures. + * `items: [{ items: null }]` is refused HERE and still DRAWS in the renderer + * (as an empty row): the renderer is only ever as lenient as `validate` or + * more, never the reverse, and it never crashes on a document `validate` + * admits — that is the invariant, and it is asserted at the foot of this file + * over the in-repo fixtures. `[0]` and `[[]]` drew too (as an unlabelled row) + * until objectui#7364 (ruling 5809218505, letter A) superseded that clause: + * the renderer now refuses them as well, through `malformedRow`. * * ## The bar level: a deliberate stop, since SUPERSEDED *