fix(plugin-timeline): refuse an unusable gantt date range with a diagnostic that names the offending value - #6772
Merged
Conversation
… value Two input classes reached the gantt branch with a date range that cannot be drawn, and they failed in opposite directions — which is why they are fixed together, under one policy. A date that does not parse threw `RangeError: Invalid time value`: the crash site objectui#6750 guarded for the EMPTY list, reached through a different input. An inverted author-pinned range drew silent nonsense instead — the axis guard in `generateTimeScaleHeaders` emitted no headers, but the row loop still ran and `calculateBarDimensions` divided by a negative `totalDuration`. Both now render the diagnostic shape objectui#6655 established one file away: a `role="alert"` element naming the offending value and where it was authored. The guard sits ABOVE `calculateDateRange` and `generateTimeScaleHeaders`, so neither is relaxed and objectui#6750's empty-list sentinel is not widened to swallow an author error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
`container` was destructured and never read in the two tests that assert through `screen.getByTestId`. Lint warnings for `packages/plugin-timeline` go 89 -> 87; errors stay at 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
Contributor
❌ 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. Which half objected:
📦 Bundle Size Report
Size Limits
|
…agnostics The per-chunk half of `Bundle Analysis` objected: `framework` measured 502,405 gzipped bytes against a 502,000 ceiling. Raise that ceiling to 512,000 and move `PER_CHUNK_BASELINE['framework']` to 502,405 with it, by the margin this file's other two entries already carry (~2% of the measurement, rounded to the nearest 1,000 — 967,000/948,461 = 1.0195, 399,000/391,095 = 1.0202, and 512,000/502,405 = 1.0191). Headroom is 9,595 bytes = 0.105x REGRESSION_THIS_GATE_MUST_CATCH_BYTES, which is the same multiple the retired framework ceiling carried (9,601). What the bytes buy is recorded in the file: a `RangeError` render crash on a malformed or absent gantt date, and an inverted pinned range that drew a negative-width bar under a zero-column axis and said nothing. There was nothing to trim instead. `plugin-timeline` is a LAZY chunk and is not in the eager closure at all, so every line of the fix costs this budget zero; all of the growth is `packages/i18n` locale data, which lands in `framework`. Measured on the built chunk by deleting the twenty strings and re-gzipping: the ten locales' message text is 1,999 of the ~2,283 bytes the chunk grew by, and per message it costs 1,000 bytes against the 1,002 that #6655's single refusal already costs in the same chunk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
Contributor
✅ 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-sales
marked this pull request as ready for review
August 29, 2026 11:09
This was referenced Aug 29, 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 free
to 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 #6759
A gantt whose date range cannot be used now renders a diagnostic that names the offending value, instead of crashing or drawing a chart that is confidently wrong.
The two defects, and why they land together
objectui#6750 taught the gantt branch about the EMPTY list. Two other input classes were left, and they failed in opposite directions — which is itself the evidence that no policy existed yet, and the reason triage refused to let them be fixed one at a time.
Measured on this card's base
b98352a15, with a throwaway probe before any change. Five inputs, five ways to be wrong:startDate: 'not-a-date'RangeError: Invalid time valuestartDate,endDate: 'also-bad'RangeError: Invalid time valueendDateabsent entirelyRangeError: Invalid time valueminDate: '2030-01-01',maxDate: '2026-03-15'axis: []·bars: ["left: 157.9250720461095%; width: -4.322766570605188%;"]minDate: 'whenever'axis: []·bars: [null]CASE-2b is the one worth reading twice. It was not in the card. A pinned value never reaches
calculateDateRange— the caller resolvesschema.minDate || dateRange.minDateafterwards — so an unparseable pin does not crash. It fails the other way:bars: [null]is a bar element carrying nostyleattribute at all, because the geometry came outNaNand the CSSOM rejectsleft: NaN%. That is the silence objectui#6750's dev measured, and it is why every assertion in the new pin file is positive about the diagnostic and counts bar elements rather than reading a style attribute — a pin phrased as "the bad geometry is gone" would have passed on the unfixed code, for the wrong reason.CASE-1c likewise.
new Date(undefined)is also an invalid date, so a row item that merely OMITSendDatecrashed the render too. It is not a widening: any guard phrased as "every date must parse" covers it, and excluding it would have meant writing extra code to keep one input class crashing.The fix
One guard pair at the top of the gantt branch in
packages/plugin-timeline/src/renderer.tsx:findUnusableGanttDatescans everyrow.items[].startDate/endDate, plus a truthy pinnedminDate/maxDate, and reports the first value that does not parse together with the path the author wrote it at.minDateaftermaxDate) is refused on the same policy.Either one renders the shape objectui#6655 established one file away — a
role="alert"element,data-testid="timeline-unusable-date-range"— carrying two new translated strings across all ten locale packs:The value is quoted, so an empty or space-padded date is visible rather than vanishing into the sentence, and
undefinedis spelled as itself so a forgotten key reads differently from an empty one.What it deliberately does not do
generateTimeScaleHeadersis untouched. Its refusal is what produced the zero-column axis CASE-2's bar hung under, and the fix refuses ABOVE it rather than relaxing it. Its objectui#6750 verdict — "needed no change" — still stands; a note was added to its docstring recording that its refusal is now unreachable from the gantt branch but is not dead code (it is exported and pinned directly).emptyGanttDateRangesentinel is not widened to swallow these. An empty list is an ordinary state; an unparseable value is an author error, and substituting a plausible range for the second is the consumer-side tolerance both cards rejected.minDate/maxDateare declared — schema side, cross-repo — and was fenced out. It was not needed: refusing at the read site covers every measured input.nulldate is left exactly as it was.new Date(null)is the epoch, not an invalid date, so it parses and is not a fault here. It is a real defect in the same family — filed as finding(plugin-timeline): anullgantt date is silently coerced to the epoch — a 649-column axis and a negative-width bar, with no diagnostic #6770, with the measurement.Evidence
Every number below is from a run on
a64e96ca8, the head of this branch, on a clean worktree.Ablation — the pins detect the defect, they do not pass for the wrong reason. The renderer was reverted to the base blob while the new pins and the i18n keys stayed in place, so only the guard was removed. The mutation was confirmed on disk by blob hash before the run, and the restore by blob hash after it:
The 13 that go red are the ones asserting the new behaviour. The 7 that stay green are the ones holding behaviour this card must NOT change — the healthy chart's exact float geometry, objectui#6750's empty-list sentinel and its degenerate
minDate === maxDateaxis,generateTimeScaleHeaders's own refusal, and the gantt-only scoping. Both halves are the point.Gates (union re-run on
a64e96ca8after the final commit; exit codes captured before any pipe):check:i18n-keysprints its own verdict: "Every in-scope call-site key resolves against the en pack (2834 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for…".Lint scope, declared. The repo-wide
pnpm lintwas not run; the two touched packages were linted with the exact per-package invocationturbo run lintmakes (eslint .from the package root), reporting 0 errors in both — 22 files and 84 files respectively, counted from eslint's own--format jsonoutput rather than from a guess about which files count. That narrowing excludes no relevant file: this repo'seslint.config.jsextendstseslint.configs.recommendedand declares noparserOptions.project/projectService, so type-aware linting is off and every file's verdict is computed from that file alone — a diff cannot move the verdict of a file it does not touch. (Warnings went 89 to 87 inplugin-timeline; the two removed were unused bindings in the new pins.)No downstream type sweep, and why. The i18n edit widens
TranslationKeys = typeof enby two nested string properties. Nothing in the repo consumes that type structurally — the only reference outsidepackages/i18n/srcis a bare type re-export inpackages/react/src/index.ts— and the other nine packs are plainconst zh = {…}rather than annotated, so pack parity is held at runtime byall-locales-key-parity.test.ts(green, in the 1049 above). A widening with no structural consumer cannot change a downstream verdict.File surface
packages/plugin-timeline/as fenced, pluspackages/i18n/src/locales/*.ts. The ten locale packs are a mechanical consequence of copying objectui#6655's shape:check:i18n-keysrequires every key at()call site asks for to exist in theenpack, andall-locales-key-parity.test.tsrequires all ten packs to define it. None of the four sibling surfaces named in the dispatch was touched. objectui#6750's pin filetimeline-gantt-empty-items.test.tsxis byte-identical to base (blobacd00f3b5at base, at HEAD and on disk; 30 assertions both sides) — this change is additive to it, and the whole diff is 731 insertions with 0 deletions.Following objectui#6655's own precedent, no docs change: that card added the same class of refusal diagnostic without touching
content/docs/plugins/plugin-timeline.mdxor the package README.Bundle Analysis: the
frameworkper-chunk ceiling, re-baselined (3532228ed)The per-chunk half of
Bundle Analysisobjected ona64e96ca8, and it was the only red check.PER_CHUNK_GZIP_CEILINGS['framework']moves 502,000 to 512,000 andPER_CHUNK_BASELINE['framework']moves 492,399 to 502,405 with it, in one commit, by the documented procedure.Which side moved, measured rather than assumed. The base of this PR is
b98352a15, which is also the tip ofmain, andBundle Analysisran on it as apush. Its own printed lines, against this PR's:main@b98352a15a64e96ca8vendor-objectstackframeworkui-componentsSo this PR moved
frameworkby 2.2 KB. The 0.4 KB overshoot is what is left after a ceiling thatmainhad already drifted to within 1.8 KB of — 0.02x the 89.0 KB regression this gate exists to catch, the thinnest of the three. That drift is not this card's and is not fixed here; it is evidence for objectui#6631, and the re-baseline is what puts the gauge back in range.The local build reproduces CI byte-for-byte. Same chunk hashes as the failing run —
index-ZdY0ak4p.js,framework-CKFkuRYq.js,ui-components-DTTrfOjp.js— so the exact figure the ceiling is derived from is the same one CI weighed:framework= 502,405 gzipped bytes, read fromapps/console/dist/eager-closure.json.Nothing could be trimmed instead — the added code is not in this chunk at all.
plugin-timelineis a LAZY chunk:eager-closure.jsondoes not list it, sofindUnusableGanttDate,spellGanttDateValue, both refusal branches and the twoTIMELINE_DEFAULT_TRANSLATIONSentries cost the eager budget zero. Every byte of the growth ispackages/i18nlocale data, which lands inframework. Confirmed by grep on the built chunks:framework-CKFkuRYq.jsholds the English and Chinese message text and none of the renderer code (timeline-unusable-date-rangeappears 0 times);plugin-timeline-BoFUQkKS.jsholds the renderer code.How much the text costs, and how it compares to the neighbour this copied. Measured on the built
frameworkchunk by deleting the strings and re-gzipping:Per message this diagnostic weighs 1,000 bytes against objectui#6655's 1,002 in the same chunk — the shape it copied, at the same price. It is 2x only because there are two distinct failure cases, and the 1,999 B of message text is essentially all of the ~2,283 B the chunk grew by (the rest is the
unusableRange/malformedDate/invertedkey names, ten times over). The only lever left was to say less, in ten languages, about what the author got wrong.What the bytes buy: a hard
RangeErrorrender crash on a malformed or absent gantt date, and an inverted pinned range that drew a bar atwidth: -4.3%under a header row with zero cells and said nothing. A crash and a confidently-wrong render, both on ordinary author mistakes.The margin follows this file's own convention, not an invented one. All three existing entries are the measurement plus ~2%, rounded to the nearest 1,000: 967,000/948,461 = 1.0195, 502,000/492,399 = 1.0195, 399,000/391,095 = 1.0202. 502,405 x 1.02 = 512,453, to the nearest 1,000 is 512,000 = 1.0191. Headroom is 9,595 bytes = 0.105x
REGRESSION_THIS_GATE_MUST_CATCH_BYTES— the same multiple the retired framework ceiling carried (9,601 = 0.105x), and far under the one-regression boundevaluateHeadroomSensitivityerrors on. No round-up "for future room".The
PER_CHUNK_BASELINEdoc comment now records provenance per key rather than naming one commit for three numbers taken on two, which is itself a small piece of what objectui#6631 is about.Gates, on the final commit
3532228ed:Exit codes were captured by redirecting to a file first, never through a pipe, and each verdict above is the line the gate itself printed. The union was re-run after the final commit;
git rev-parse --short HEADon that run is3532228ed.Measurement session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
Generated by Claude Code