Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror - #7367
Merged
Conversation
… instead of crashing, and declare the row shape in the mirror
A gantt whose `items` is not an array, whose row is `null`, or whose
`row.items` is a truthy non-array crashed the render with a `TypeError`
from ordinary JSON that the declared zod mirror accepted:
`findUnusableGanttDate` read the row walk defensively and
`calculateDateRange` re-read it bare one line later. A guard in either
reader only relocated the crash into the render loop (ablation-proven on
the card, four prior relocations on this path).
Render-time door: `classifyGanttRows` reads the raw shape ONCE and either
refuses it through a new key, `timeline.gantt.unusableRange.malformedRow`
("items[0] is null, which is not a row shape"), naming the authored path
and the value, or hands the three readers (the date scan, the range
computation, the render loop) one normalized `GanttRow[]`. Never the
`malformedDate` copy, which named the wrong fault. The key lands in `en`
and the nine sibling locale packs, and in the plugin's provider-less
default table as `en`'s byte-identical twin.
Author-time door: `TimelineSchema.items` declares every element an object
and a row's own `items`, when present, an array, so `validate` refuses
`items: [null]` and `items: [{ items: 5 }]` before a renderer is reached.
Feed items carry no `items` key and parse as before; every in-repo
`type: 'timeline'` fixture parses green on both sides.
The card's THREW table is re-run as a pin (each row REFUSED at its path,
the five CONTROL rows drawing with unchanged counts); the 7027 pin's rows
that documented these inputs as a defect now pin the refusal, and its two
revoked-proxy rows moved with the reads they exercise.
Maintainer ruling 2026-09-02 (A+), objectui#7164.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHThoContributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
os-litant
marked this pull request as ready for review
September 2, 2026 13:39
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Sep 2, 2026
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#7164
Clause-②: yes — contract review required before release
Maintainer ruling 2026-09-02 (director seat, summon #8, comment 5507933270): A+ — refuse the chart through a NEW diagnostic key naming the malformed row and the fault, never the
malformedDatecopy; AND tighten the zod mirror sonullrows and non-arrayitemsare refused byvalidatebefore a renderer is reached. Option B (skip the row) and C (document) not taken. This PR executes that ruling as written. Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHThoWhat changed
Render-time door (
packages/plugin-timeline/src/renderer.tsx). One normalizer,classifyGanttRows(items), reads the raw shape ONCE and returns either{ ok: true, rows }— areadonly GanttRow[]whoseitemsis an array by construction — or{ ok: false, path, value }. Three refusals, in walk order:itemsnot an array →items; anull/undefinedrow →items[i]; a truthy non-arrayrow.items→items[i].items. The three readers the card named —findUnusableGanttDate,calculateDateRangeand the render loop — now all consume the verdict; none re-readsschema.items. The refusal renders the existingrole="alert"surface (#6759's element, samedata-testid) through the new key, with{{path}}and{{value}}(the value spelled byspellGanttDateValue, so no author code runs).onItemClickstill receives the author's own row object (pinned).The key —
timeline.gantt.unusableRange.malformedRow, placed directly aftermalformedDateinside the existingunusableRangeblock in all ten packs (ar de en es fr ja ko pt ru zh), each a real translation in the register of that pack'smalformedDateline; plus the byte-identical English twin in the plugin's provider-less default table (useTimelineTranslation.ts, asmalformedDatehas). English:Author-time door (
packages/types/src/zod/data-display.zod.ts).TimelineSchema.itemsis nowz.array(TimelineRowSchema)where the (non-exported, per the parity-registry convention) row schema isz.object({ items: z.array(z.any()).optional() }).passthrough(): every element an object; a row'sitems, when present, an array. Nothing else narrowed — feed items carry noitemskey and parse as before; bars stayz.any(). The TS twin (data-display.ts) keepsitems?: any[](its docblock already carries both shapes in prose) with a note pointing at the mirror; the parity ledger registered NO drift (any[]is assignable to the narrowed input), soKnownDriftis untouched.Pins. New
timeline-gantt-malformed-row-7164.test.tsx(22 tests: the card's table, controls, order,onItemClickidentity, the key); newtimeline-items-row-shape-7164.test.tsinpackages/types(accept-set table on the new mirror, a rebuilt OLD mirror as the two-sided control, fixture census over the JSON documents). The 7027 pin'spin 6(which documented these inputs as a defect and said it was expected to go red on repair) now pins the REFUSAL; two rows of itspin 5moved with the reads they exercise (below). The 6907 pin's "no key was added"ittitle is corrected to what it asserts.Changeset
.changeset/7164-gantt-malformed-row-refusal.md:@object-ui/plugin-timelinepatch ·@object-ui/typesminor — the mirror's accept set narrows (stated plainly in the changeset) ·@object-ui/i18npatch.Contract-review pack
(a) The card's table, re-run on
8e1dc8274through the realTimelineRendererRED first, on
a67abdc88(same probe, before any edit):GREEN, on
8e1dc8274(the new pin asserts each line; 22/22 pass):Every refusal renders zero bars and zero axis cells, contains the new key's clause and NOT
malformedDate's. Order pinned: anullrow atitems[1]wins over an unparseable date atitems[2]; a well-formed row with a bad date still takesmalformedDateunchanged.(b) The mirror's accept set —
safeParse,origin/main(a67abdc88) vs head (8e1dc8274)Measured by a probe that rebuilt the old declaration (
z.array(z.any()).optional()) on the same base:Three corners where
validateis now stricter than the renderer —[{ items: null }],[0],[[]]— are refused at authoring and still DRAW (empty / unlabelled row), because the ruling fixed the render door at three shapes and pinned those rows as drawing CONTROLs. The invariant that holds on both sides: the renderer never crashes on a documentvalidateadmits, and is only ever more lenient thanvalidate, never the reverse. The primitive-row corner is filed as #7364 for a ruling; it is not widened here.(c) Fixture census — every in-repo
type: 'timeline'document through the tightened mirror12 documents, 0 new refusals (each
accepton main and on head):Population:
grep -rloverexamples/,packages/*/catalog(none exist),content/docs,packages/*/examplesand the schema-catalog tests fortype: 'timeline'/"type": "timeline". The mdx fence at line 69 is the schema's TYPE signature (items?: TimelineItem[]), not a document, and is excluded. The three JSON fixtures and the examples JSON are pinned intimeline-items-row-shape-7164.test.ts; the docs fences were censused on this PR.Ablation — each refusal is load-bearing, and the failure without it is measured, not assumed
Committed first; each leg mutated
renderer.tsxwith an anchor replacement proven on disk (anchor count 1 → 0, marker 0 → 1, blob hash moved), ran the new pin, and restored withgit checkout HEAD -- pathproven bygit hash-objectequal to the HEAD blob (36abf53e…) and an emptygit diff HEAD, trap-guarded, absolute paths. Nodistis involved (the pin imports../renderer; vitest aliases workspace packages tosrc), so no rebuild leg.items-not-an-array refusal: 4 red / 18 green. Direction: silent DRAW, not a crash —items: {}/'x'/5rendered an empty gantt with no diagnostic (no diagnostic rendered: expected null not to be null), because the normalizer's own walk is tolerant. That is the worse failure mode the refusal prevents.null-row refusal: 3 red / 19 green. Direction: relocation into the normalizer —TypeError: Cannot read properties of null (reading 'items')atrenderer.tsx:345:26(classifyGanttRows), exactly the card's relocation pattern.row.itemsrefusal: 7 red / 15 green. Direction: relocation intocalculateDateRange—TypeError: row.items.flatMap is not a functionatrenderer.tsx:367:25, and the'x'row falling back to the DATE copy.Exotic-class pins that moved (stated, exercised)
Array.isArrayinclassifyGanttRowsruns before U1 (items.length) and U4 (rowItems.length) can. A revokedProxydies at the first operation that touches it, so two of the 7027 pin's four revoked-position rows now throwCannot perform 'IsArray' on a proxy that has been revoked(pin 4's class) instead of'get'; the trap-message rows U1–U6 are unchanged because the trap writes the message. Reachability argument unchanged: JSON cannot spell a proxy. Docblocks onfindUnusableGanttDate,calculateDateRange,spellGanttDateValueand the gantt branch updated to say what is true now.Verification (final commit
8e1dc8274)pnpm exec vitest run --maxWorkers=2 packages/plugin-timeline/ packages/types/ packages/i18n/→Test Files 168 passed (168) · Tests 2689 passed (2689);os-verify-lock: VERDICT command-exit 0.pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-timeline^..." buildexit 0):pnpm --filter @object-ui/plugin-timeline run type-check(echoedtsc --noEmit && tsc -p tsconfig.test.json) EXIT=0;@object-ui/types(echoedtsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json) EXIT=0;@object-ui/i18nEXIT=0.--listFileson the test tsconfigs: the three edited plugin-timeline test files present (3), the new types test present (1) — the new tests are in the compiled set.--no-inline-config, JSON format) over the 18 changed source/test files: errors 0, warnings 50 (all pre-existing-patternno-explicit-any/react-refreshclasses; no new rule class).check-changeset-presence✅ (16 source files of 3 released packages, 1 changeset declared) ·check-changeset-no-major✅ ·check-changeset-fixed✅ ·check:control-bytes✅ (6056 files) ·check:i18n-keys✅ (every call-site key resolves; 2907 en keys) ·check:i18n-drift✅ (0 en values changed, 1 key added — parity's business, andall-locales-key-parityis green) ·check:i18n-dead-keysreport unchanged ·check:vi-mock-specifiers✅ ·check:vi-mock-inherit✅.objectstack/scripts/pm/check-governed-merges.mjs --teston the final 19-path list):0 of 19 path(s) hit the register— NOT governed.check:eager-closure: NOT MEASURED (needs a fullapps/consolebuild; not run under the foreground cap). Estimate: the ten added lines total 4,037 bytes raw, 1,960 bytes gzip-9 in isolation; theframeworkchunk's headroom inscripts/check-eager-closure-budget.mjsis 524,000 − 514,863 = 9,137 bytes. One key × ten packs fits with ~7 KB to spare; CI weighs it.Deviations from the dispatch (each declared, none silent)
items: [{ items: 'x' }]was NAMED throughmalformedDateon main by accident (a string is index-readable). The ruling's door is "a row whoseitemsis a TRUTHY NON-ARRAY", and a string is one, so it is now REFUSED throughmalformedRownaming the true fault (items[0].items is "x"). Keeping it on the date copy would have required a string-specific exemption preserving what the card itself called an undesigned asymmetry. Pinned explicitly as the one row whose outcome, not its crash, changed.{{value}}, not{{fault}}. The sibling key in the same block uses{{path}}/{{value}}and the same speller; the "fault" is carried by the sentence ("which is not a row shape"), which reads correctly at all three path levels (pinned) — one key, no untranslatable clause pushed through a hole.null/non-object" narrowed tonull/undefined. ZONE 1 6(a) pinsitems: [0]and[[]]as drawing CONTROLs, and the ruling namesnullrows; a non-object predicate would flip those controls. The corner is filed (finding(plugin-timeline): a gantt row that is a non-null primitive or an array (items: [0],['x'],[true],[[]]) draws an unlabelled empty row silently — whilevalidatenow refuses it #7364) rather than decided here.useTimelineTranslation.ts) — not on the dispatch's file list, but the package's own mirror ofenfor the two sibling keys, and without it the provider-less host (every unit test) renders the bare key.revokedPositionstyping in the 7027 pin (message per row) so the two moved sites are asserted by their new message rather than loosened.Out of scope, filed
items: [0],['x'],[true],[[]]) draws an unlabelled empty row silently — whilevalidatenow refuses it #7364 — a non-null primitive or array gantt row draws an unlabelled empty row silently whilevalidatenow refuses it (Blocked-by this card).nullgantt BAR (items: [{ items: [null] }]) is refused throughmalformedDateas "startDate is undefined, which is not a valid date" — the wrong fault, one level below #7164 #7365 — anullBAR is refused throughmalformedDatenaming "startDate is undefined" — the wrong fault, one level below this card (Blocked-by this card).ObjectTimeline.tsxis read, not edited, per the ruling. Draft: stays draft pending the in-seat contract review; not marked ready, no auto-merge.🤖 Generated with Claude Code
https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
Generated by Claude Code