Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-timeline): render an empty gantt as a zero-row grid instead of throwing - #6758
Merged
Merged
Conversation
…d of throwing
`calculateDateRange` reduced an empty list with no guard: `allDates` is `[]`,
`Math.min()` over no arguments is `Infinity`, and `new Date(Infinity)
.toISOString()` throws `RangeError: Invalid time value` during render. Both
entry points crashed identically — `TimelineRenderer` given `{ variant:
'gantt', items: [] }`, and `ObjectTimeline` given the same schema, whose
authored empty array is truthy and so passes straight through.
An empty gantt is the ordinary empty state of a valid schema, not a malformed
document: any generator that builds `items` from a collection emits `items: []`
the moment the collection is empty.
Covers the whole gantt branch in one pass, because patching only the crash site
moves it two stops down the same branch:
- `calculateDateRange` returns a one-day sentinel range anchored on today when
the rows carry no dates at all. One day is the smallest coherent range; how
much time an empty gantt should show is a question about what an empty gantt
should look like, which this change deliberately does not answer.
- `generateTimeScaleHeaders` needed no change, recorded as a measured verdict
rather than an assumption: a degenerate `min === max` range is not inverted,
so the loop runs once and every scale emits exactly one bucket.
- `calculateBarDimensions` gains a `totalDuration === 0` guard. A zero-width
axis divided `0 / 0` into `NaN`; the CSSOM rejects `left: NaN%` and `width:
NaN%`, so React left the bar with no `style` attribute at all — unpositioned
and zero-width, an invisible failure rather than a crash.
An author-pinned `minDate` / `maxDate` is untouched by the sentinel: the branch
resolves `schema.minDate || dateRange.minDate`, so a pinned range with
`items: []` renders exactly that range with no rows in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49Contributor
✅ 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 08:00
Uh oh!
There was an error while loading. Please reload this page.
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#6750
Base
b76ca6764(includes PR #6749 / card #6655, commitf7ea89bda). All readings below were taken on that base or on the implementation commita04c3cac2, each named where it is quoted.The defect, reproduced on this base before any change
calculateDateRangereduced the empty list with no guard:allDatesis[],Math.min()over no arguments isInfinity, andnew Date(Infinity).toISOString()throwsRangeError: Invalid time valueduring render. My own throwaway probe onb76ca6764, both entry points:An empty gantt is the ordinary empty state of a valid schema, not a malformed document. Any author or generator that builds
itemsfrom a collection emitsitems: []the moment the collection is empty.The whole branch in one pass, not just the one throw
Triage put all three stops in scope, because patching only the crash site moves it two stations down the same branch. What each site got, and why:
1.
calculateDateRange— the throw itself. Returns a one-day sentinel range anchored on today when the rows carry no dates at all. The span is one day — the smallest coherent range — deliberately: how much time an empty gantt should show is a question about what an empty gantt should look like, and that was left open (see below). A one-day window makes the smallest possible claim, andgenerateTimeScaleHeadersturns it into exactly one bucket on every scale, so the axis is valid and non-empty with zero rows under it.2.
generateTimeScaleHeaders— no change needed, and that is a MEASURED verdict, not an assumption. Its existing guard already refuses an unparseable or inverted range by drawing nothing, and a degeneratemin === maxrange is not inverted:start > endis false when they are equal, so the loop runs exactly once. Measured onb76ca6764with min = max =2026-03-15:["Mar 15, 12 AM"]["Mar 15"]["Week 1"]["Mar 2026"]["Q1 2026"]["2026"]The verdict is recorded in the function's own docstring rather than left to be re-derived, and pin 5a holds it.
3.
calculateBarDimensions— atotalDuration === 0guard. This is the site that fails silently rather than loudly, and it is not reachable from the empty case at all (no rows means no bars), which is exactly why it needs its own pin. A zero-width axis — every task starting and ending on the same day, or an author pinningminDate === maxDate— divided0 / 0intoNaN, and the bar was handedleft: NaN%; width: NaN%. That is neither a crash nor a visible error: the CSSOM rejects both declarations, so React left the element with nostyleattribute at all and the bar rendered unpositioned and zero-width. Measured onb76ca6764, a single{ startDate: '2024-05-01', endDate: '2024-05-01' }row:(element spelled as a selector rather than as markup, so the rendered body cannot swallow it)
On a zero-width axis every task covers the whole of it by definition, so the guard returns
{ start: 0, width: 100 }.The author-pinned range is untouched by the sentinel: the branch resolves
schema.minDate || dateRange.minDate, so a pinned range withitems: []renders exactly that range with no rows in it — the free win triage asked to keep.What this deliberately does NOT do
No product judgment about what an empty gantt should look like. "Do not crash" is a correctness floor; whether the empty case should become this repo's standard empty-state panel instead of a zero-row grid is the product option that was left open, and substituting one here would have been taking a decision withheld on purpose. #6655's object-bound refusal is likewise untouched — it lives in
ObjectTimeline, above this code, and stays keyed on whether items were AUTHORED, which is precisely why it does not fire on this card's case.ObjectTimeline.tsxis not in this diff.Pins
13 assertions in
packages/plugin-timeline/src/__tests__/timeline-gantt-empty-items.test.tsx, against the real renderer (ObjectTimeline.test.tsxstubs./renderer, so assertions there stay green whether or not the gantt branch is reached). The clock is frozen to2026-03-15withvi.useFakeTimers({ toFake: ['Date'] })so the sentinel is pinned rather than written around whatever day CI runs on.TimelineRenderer+ empty gantt: no throw, zero-row grid, valid axis, one bucket on every spec scaleObjectTimeline+ same schema: no throw, same zero-row grid, #6655's diagnostic stays awayminDate/maxDatewithitems: []renders exactly that range, no rowsgenerateTimeScaleHeaderson the degenerate range, and its pre-existing refusal of inverted/unparseable rangescalculateBarDimensionson both routes to a zero-width axisPin 4 carries the pre-fix baseline captured on
b76ca6764, in full float spelling on purpose — a guard that rounded, clamped or short-circuited the normal arithmetic would pass a tolerance assertion and fail this one:Ablation, against the committed implementation
a04c3cac2Two legs, one per guard — which is also the proof that the sites are pinned separately, so a later change that fixes one and not the others goes red. No rebuild is involved on either leg: the pins import
../rendererby relative specifier, so the test resolves the source file directly (leg A's stack frame namescalculateDateRange packages/plugin-timeline/src/renderer.tsx:243:37, i.e. the mutated source is what ran). Each mutation was proven on disk by counting the removed guard text and the injected marker, not by an editor exit code; the restore is trapped onEXIT INT TERMwith absolute paths and proven three times.HEADblob forpackages/plugin-timeline/src/renderer.tsx=683aee563c9d4cddd6d597afd8a0f04ede4b741a.calculateDateRangeempty guard1 file changed, 1 insertion(+), 1 deletion(-)calculateBarDimensionsdegenerate guard1 file changed, 1 insertion(+), 3 deletions(-)expected [ null ] to deeply equal [ 'left: 0%; width: 100%;' ], the literal missing-stylefailure; pins 1, 2, 3, 4, 5a, 6 GREENRestore proven on every leg:
blob 683aee563c9d4cddd6d597afd8a0f04ede4b741a == HEAD blob, git diff HEAD empty.Pin 4 stayed green in both mutation legs, which is what makes it a real control: neither guard leaks into the normal path. Pin 5a also stays green in both legs — that is the point of it rather than a weakness: it pins the pre-existing
generateTimeScaleHeadersproperty the sentinel relies on, and its composed form (pin 1's "the axis is VALID") does go red in leg A.Verification
Union run at
a04c3cac2, after the final commit.vitest run packages/plugin-timelineTest Files 14 passed (14)/Tests 102 passed (102)pnpm --filter @object-ui/plugin-timeline type-checktsc --noEmit && tsc -p tsconfig.test.json, script name echoedeslint . --no-inline-config(package)check:control-bytescheck:vi-mock-specifierscheck:self-importcheck:esm-specifierscheck:shell-escape-residuecheck:phantom-depscheck:sdui-registration-pinsAll 12 registration(s) a sideEffects array promises are present in the built console (513 chunks weighed…)check:readme-exportsEvery exit code was captured by redirect-then-capture, never after a pipe.
check:readme-exportsneeds every package built. On the first run its population had collapsed (packagesRead: found 10, floor is 25) — its own output calls that "this run proves nothing", so it was not a red. After building the console dependency closure it read 35 of 40 packages, including@object-ui/plugin-timeline, and the judgement came back clean:378 real, 0 wrong-path, 0 fabricated. It still exits 1 on two packages I did not build locally,@object-ui/cliand@object-ui/plugin-ai— neither is in this diff and neither is in the console's closure. CI builds everything, so this is a local prerequisite gap, not a finding.typecheckwas confirmed to actually cover the new test file rather than excluding it —tsc -p tsconfig.test.json --listFilesreports 1 hit fortimeline-gantt-empty-items.test.tsx.Lint narrowing, declared. The eslint run above is package-scoped rather than repo-wide, and the narrowing is a measurement rather than a gap: (1) the population comes from eslint's own flat config resolution, not from my guess about which files count; (2) the file count, 21, is read from
--format json; (3)eslint.config.jsdeclares noproject/projectService, so type-aware linting is not enabled and every rule is per-file syntactic — this diff cannot move the verdict of any file it does not touch. Every.ts/.tsxfile in the diff lives inpackages/plugin-timeline/; the changeset is Markdown and outside eslint'sfiles: ['**/*.{ts,tsx}']population.The substantive diff is three blocks — a 4-line helper, one guard line, one 3-line guard; everything else is comments and the pins.
Generated by Claude Code