Skip to content

fix(plugin-timeline): render an empty gantt as a zero-row grid instead of throwing - #6758

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6750-gantt-empty-items-guard
Aug 29, 2026
Merged

fix(plugin-timeline): render an empty gantt as a zero-row grid instead of throwing#6758
os-sales merged 1 commit into
mainfrom
claude/issue-6750-gantt-empty-items-guard

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#6750

Base b76ca6764 (includes PR #6749 / card #6655, commit f7ea89bda). All readings below were taken on that base or on the implementation commit a04c3cac2, each named where it is quoted.

The defect, reproduced on this base before any change

calculateDateRange reduced the 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. My own throwaway probe on b76ca6764, both entry points:

PROBE-A throw: RangeError: Invalid time value # TimelineRenderer { variant: 'gantt', items: [] }
PROBE-B throw: RangeError: Invalid time value # ObjectTimeline same schema, authored empty array

An empty gantt is the ordinary empty state of a valid schema, not a malformed document. Any author or generator that builds items from a collection emits items: [] 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, and generateTimeScaleHeaders turns 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 degenerate min === max range is not inverted: start > end is false when they are equal, so the loop runs exactly once. Measured on b76ca6764 with min = max = 2026-03-15:

scaleheaders
hour["Mar 15, 12 AM"]
day["Mar 15"]
week["Week 1"]
month["Mar 2026"]
quarter["Q1 2026"]
year["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 — a totalDuration === 0 guard. 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 pinning minDate === maxDate — divided 0 / 0 into NaN, and the bar was handed left: NaN%; width: NaN%. That is neither a crash nor a visible error: the CSSOM rejects both declarations, so React left the element with no style attribute at all and the bar rendered unpositioned and zero-width. Measured on b76ca6764, a single { startDate: '2024-05-01', endDate: '2024-05-01' } row:

div.absolute.h-8.rounded-md.transition-colors.cursor-pointer.bg-blue-500 title="T" # and NO style attribute at all

(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 with items: [] 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.tsx is 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.tsx stubs ./renderer, so assertions there stay green whether or not the gantt branch is reached). The clock is frozen to 2026-03-15 with vi.useFakeTimers({ toFake: ['Date'] }) so the sentinel is pinned rather than written around whatever day CI runs on.

pinholds
1TimelineRenderer + empty gantt: no throw, zero-row grid, valid axis, one bucket on every spec scale
2ObjectTimeline + same schema: no throw, same zero-row grid, #6655's diagnostic stays away
3author-pinned minDate/maxDate with items: [] renders exactly that range, no rows
4a NON-empty gantt is byte-for-byte unchanged — full float bar geometry, not a tolerance
5agenerateTimeScaleHeaders on the degenerate range, and its pre-existing refusal of inverted/unparseable ranges
5bcalculateBarDimensions on both routes to a zero-width axis
6#6655's object-bound refusal still fires, undisturbed

Pin 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:

axis: ["Jan 2024","Feb 2024","Mar 2024"]
bars: ["left: 0%; width: 33.33333333333333%;",
"left: 34.44444444444444%; width: 65.55555555555556%;",
"left: 15.555555555555555%; width: 34.44444444444444%;"]

Ablation, against the committed implementation a04c3cac2

Two 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 ../renderer by relative specifier, so the test resolves the source file directly (leg A's stack frame names calculateDateRange 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 on EXIT INT TERM with absolute paths and proven three times.

HEAD blob for packages/plugin-timeline/src/renderer.tsx = 683aee563c9d4cddd6d597afd8a0f04ede4b741a.

legmutation, proven on diskresult
A — remove the calculateDateRange empty guardremoved-text count 1 to 0, injected marker 1, 1 file changed, 1 insertion(+), 1 deletion(-)7 failed / 6 passed — pins 1 (all four), 2 (both), 3 RED; pins 4, 5a, 5b, 6 GREEN
B — remove the calculateBarDimensions degenerate guardremoved-text count 1 to 0, injected marker 1, 1 file changed, 1 insertion(+), 3 deletions(-)2 failed / 11 passed — pin 5b (both cases) RED with expected [ null ] to deeply equal [ 'left: 0%; width: 100%;' ], the literal missing-style failure; pins 1, 2, 3, 4, 5a, 6 GREEN
C — restored13 passed (13)

Restore 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 generateTimeScaleHeaders property 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.

checkexitverdict line
vitest run packages/plugin-timeline0Test Files 14 passed (14) / Tests 102 passed (102)
pnpm --filter @object-ui/plugin-timeline type-check0tsc --noEmit && tsc -p tsconfig.test.json, script name echoed
eslint . --no-inline-config (package)021 files, 0 errors, 83 warnings — all pre-existing rule classes
check:control-bytes0pass
check:vi-mock-specifiers0pass
check:self-import0pass
check:esm-specifiers0pass
check:shell-escape-residue0pass
check:phantom-deps0pass
check:sdui-registration-pins0All 12 registration(s) a sideEffects array promises are present in the built console (513 chunks weighed…)
check:readme-exports1partially measured — see below

Every exit code was captured by redirect-then-capture, never after a pipe.

check:readme-exports needs 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/cli and @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.

typecheck was confirmed to actually cover the new test file rather than excluding it — tsc -p tsconfig.test.json --listFiles reports 1 hit for timeline-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.js declares no project / 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/.tsx file in the diff lives in packages/plugin-timeline/; the changeset is Markdown and outside eslint's files: ['**/*.{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

…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_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3180.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CP9ojAqS.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.66KB116.30KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)240.41KB60.56KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.89KB12.91KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.44KB34.48KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.43KB62.45KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.96KB32.63KB
plugin-gantt (index.js)165.20KB40.37KB
plugin-grid (index.js)201.53KB54.54KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)113.11KB27.58KB
plugin-map (index.js)20.17KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)27.19KB7.82KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)69.47KB23.06KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-timeline): a gantt timeline with an EMPTY literal items array throws — calculateDateRange reduces an empty list

2 participants

@os-sales@claude