Skip to content

fix(plugin-timeline,i18n): refuse object-bound variant: gantt instead of throwing - #6749

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6655-object-timeline-gantt-refuse-loudly
Aug 29, 2026
Merged

fix(plugin-timeline,i18n): refuse object-bound variant: gantt instead of throwing#6749
os-sales merged 1 commit into
mainfrom
claude/issue-6655-object-timeline-gantt-refuse-loudly

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#6655

The defect

The two timeline item shapes are not interchangeable, and this path crossed them.

ObjectTimeline maps each record to a flat feed item — one per record, no nested items. The renderer's gantt branch reads a gantt row instead (row.items[].startDate). Against composed feed items every row.items is undefined, so allDates is empty, Math.min() over it is Infinity, and new Date(Infinity).toISOString() throws RangeError: Invalid time value during render. No guard, no diagnostic — the component simply threw.

Re-derived on this PR's base faac0d935 (the card's line numbers were from merge-base f241a4df4 and are stale): calculateDateRange is at renderer.tsx:165 and throws at :174, reached from TimelineRenderer at :424.

What this implements

The maintainer ruling of 2026-08-29 on the card adopted option 2, refuse loudly, verbatim 「同意」. Option 1 — composing real gantt rows from records — was presented and explicitly not taken; that capability stays open and unruled, and nothing here implements any part of it.

The object-bound path now renders an author-facing diagnostic naming the limitation instead of throwing.

Convention matched, and why. The repo has more than one convention for "this authoring shape is not supported" on a renderer. I matched plugin-dashboard/src/PivotTable.tsx (objectui#2941), which is the nearest neighbour on every axis that matters: it refuses an out-of-vocabulary authored enum value on a renderer, after the hooks so hook order stays stable, through a role="alert" box that names both the refused value and what the surface does render — and its own comment calls this "refuse loudly", the same phrase the ruling uses. The two alternatives are a poorer fit: DatasetReportRenderer's out-of-spec chart type is a muted dashed note precisely because a grouped table beneath still carries the numbers, and DashboardGridLayout lowers an unsupported widget to a text node; neither applies where nothing renders at all. The markup also matches this same file's existing timeline-error state, so ObjectTimeline has one diagnostic shape rather than two.

The refusal keys on authored items, not on the variant

ObjectTimeline is also the renderer behind the bare timeline key (view:timeline in ./index; the presentational registration in ./renderer carries skipFallback so the bare key lands here). The in-repo catalog fixture plugin-timeline/gantt-style-timeline.json is exactly that — type: 'timeline', variant: 'gantt', literal gantt rows — and it flows through this component untouched. A refusal keyed on variant alone would have taken it, and every other authored gantt, down with it. The condition therefore reuses the same schema.items test effectiveItems itself makes, and both are commented as coupled.

The ruling's second clause

the composed scale stops being silently inert on that path

Resolved by the second of the two routes the card allowed — the refusal is what the author now sees instead of a silently-ignored option — not by dropping the composition on the feed variants. That route was closed by evidence, not by preference: objectui#6355's landed pin ObjectTimeline.scaleComposition.test.tsx renders variant: 'vertical' and asserts schema.scale === 'year' and resolveTimelineScale(schema) === 'year' on it. Gating the composition on gantt would turn that pin red, which is the ruling's own pin 2 ("object-bound plus vertical or horizontal, unchanged") failing. So the axis is still composed for the feed variants exactly as before, and on the gantt variant the author who set it is now told why it has no effect rather than getting a crash.

Measured, not asserted: pin 1b renders an object-bound gantt carrying timeline.scale: 'year' and shows the diagnostic present and the year axis those records would have produced (2099, 2100) absent from the DOM.

The diagnostic goes through the i18n channel

An English literal would have tripped check:i18n-keys, whose message is explicit that packages/i18n/src/locales/en.ts is the source of truth and that an inline default is not a fix. The key is added to all ten locale packs, mirrored byte-identically into this package's provider-less TIMELINE_DEFAULT_TRANSLATIONS table (the seam timeline-scale-vocabulary-defaults.test.ts pins).

The German value quotes with the „…“ pair that pack's convention requires (de-quote-pairing-3876.test.ts bans U+0022 in any de value). That adds one correctly-paired span, so the two census assertions in that file move 54 to 55 with a line naming this card — the maintenance those counters are explicitly designed for, and rdq stays 0, which is the load-bearing half.

Evidence

All readings on this branch. Test and gate readings after the final commit 371dd804a; the red-first reading is the one that must predate it.

Red first, on the unmodified base faac0d935. The pin was written and run before any fix:

AssertionError: expected [Function] to not throw an error but 'RangeError: Invalid time value' was thrown
❯ calculateDateRange packages/plugin-timeline/src/renderer.tsx:174:37
❯ TimelineRenderer packages/plugin-timeline/src/renderer.tsx:424:25
Tests 4 failed | 6 passed (10)

The 4 red are pins 1 and 1b. The 6 green are pins 2 and 3 — already passing on the base, which is what makes them a real "unchanged" baseline rather than filler.

Ablation, against the committed implementation at 371dd804a. The refusal block was deleted, the deletion proven on disk before any run (the timeline-unsupported-variant marker went 1 to 0 and an injected marker went 0 to 1; git diff --stat showed 1 insertion, 9 deletions), and the restore was trapped on EXIT INT TERM with absolute paths. No rebuild is involved on either leg: the pin imports the mutated module by relative specifier (import { ObjectTimeline } from '../ObjectTimeline'), so vitest compiles that source directly and no dist can stale it.

  • ablated: Tests 4 failed | 6 passed (10), RangeError: Invalid time value — the same 4, and pins 2 and 3 stayed green under ablation, so the pin discriminates rather than going uniformly red.
  • restored: proven, not assumed — git diff HEAD empty and on-disk blob 1b67bd7e1dd2c104263e298df48ee69f3bdefc18 equal to that path's HEAD blob hash.
  • restored green: Test Files 71 passed (71), Tests 1016 passed (1016).

Suites (@object-ui/i18n and @object-ui/plugin-timeline, at 371dd804a): Test Files 71 passed (71), Tests 1016 passed (1016). Both packages' type-check: Done for tsc --noEmit && tsc -p tsconfig.test.json, and the new pin is verifiably inside that program — tsc -p tsconfig.test.json --listFiles lists it.

Gates (each quoted from its own verdict line, at 371dd804a):

  • check:control-bytesOK (scanned 5606 tracked text file(s); skipped 85 binary)
  • check:i18n-keys2587/2587 literal keys resolve (it was 2586/2587 with the key missing, which is how the en-pack requirement was found)
  • check:i18n-driftNo en value changed in this range / 1 key(s) added, 0 removed
  • check:vi-mock-specifiersOK (3938 tracked source file(s) … 751 relative specifier(s) resolved)
  • check:self-importNo package names itself inside its own src/.
  • check:phantom-depsEvery in-scope import is declared by the package that publishes it.
  • check:esm-specifiers — exit 0, no finding
  • check:i18n-dead-keys — a report, not a gate, by its own footer

Lint, narrowed and declared. CI runs pnpm lint (turbo run lint, each package's own eslint .) across all 47 workspace projects; this run covered the 2 the diff touches — Scope: 2 of 47 workspace projects — with 0 errors in both (plugin-timeline 20 files linted, 71 warnings; i18n 84 files, 34 warnings; counts from eslint's own --format json). The narrowing excludes nothing: eslint.config.js declares no projectService, no parserOptions and no project key, so type-aware linting is off and this diff cannot move the verdict on a file it does not contain. plugin-timeline went 72 to 71 warnings mid-review — the one new warning was react-refresh/only-export-components on a constant I had exported, so it is module-local now and adds no public surface.

NOT MEASURED — neither green nor red, reported with the reason:

  • check:sdui-registration-pins — refused to run: No console build to weigh at apps/console/dist/assets, and the gate itself says "This is exit 2, not a pass … a run with nothing to read has measured nothing". It needs pnpm --filter @object-ui/console build. This diff changes no registration, no sideEffects array and no bundle entry.
  • check:readme-exports — reported its own population collapsed: 2098 export symbol(s) read from 10 of 40 package(s) (27 unbuilt), every finding of the form "its type entry is not on disk — run pnpm build first" and none in a package this diff touches. It needs a full repo build.

Both are cheap for CI, which builds the repo anyway.

Out of scope, filed separately

A literal-rows gantt with an emptyitems array (items: []) still throws the same RangeErrorcalculateDateRange has no empty-list guard, and an empty array is truthy so it takes the authored path. That is the crash site's own missing guard on the legitimate literal-rows path, which this card is explicitly not the fix site for. Recorded in the report to PM rather than widened into this PR.


Generated by Claude Code

…ad of throwing
`ObjectTimeline` maps each record to a flat FEED item — one per record, no
nested `items` — while the renderer's gantt branch reads a gantt ROW
(`row.items[].startDate`). Every `row.items` was therefore `undefined`,
`calculateDateRange` reduced an empty list, `Math.min()` over it was `Infinity`,
and `new Date(Infinity).toISOString()` threw `RangeError: Invalid time value`
mid-render. No guard, no diagnostic — the component simply threw.
Per the maintainer ruling of 2026-08-29 this refuses loudly rather than
composing gantt rows from records (that capability was presented and NOT
adopted; it stays open and unruled). The object-bound path now renders an
author-facing diagnostic naming the limitation, matching the nearest sibling
convention in this repo — `PivotTable`'s out-of-vocabulary aggregation refusal:
a `role="alert"` box that names the refused value and what the surface does
render.
The condition keys on whether `items` were AUTHORED, not on the variant alone.
This component also answers the bare `timeline` key, and the in-repo catalog
fixture `plugin-timeline/gantt-style-timeline.json` is a literal gantt flowing
through it; refusing on `variant` alone would have taken it down. The feed
variants and the presentational `TimelineRenderer` are untouched.
The diagnostic goes through the package's own translate channel rather than an
English literal, so it is added to all ten locale packs. The German value quotes
with `„…“` per that pack's convention, which moves the `de` quote-pairing census
54 -> 55 with a line naming this card.
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)3179.2 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-BYyGzOAd.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)239.31KB60.18KB
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)26.98KB7.78KB
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)67.73KB22.54KB
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): an object-bound timeline with variant: "gantt" throws — calculateDateRange reads a row shape ObjectTimeline never composes

2 participants

@os-sales@claude