Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/7364-timeline-nonobject-row.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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],
];
Expand Down Expand Up @@ -682,24 +686,33 @@ 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<string, unknown>][] = [
['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);
expect(diagnosticOf(container), `${label} was refused`).toBeNull();
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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, unknown>, 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<string, unknown>, 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
Expand Down
66 changes: 46 additions & 20 deletions packages/plugin-timeline/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
*
Expand All @@ -326,21 +344,28 @@ 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 };

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)) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Loading