Skip to content

fix(plugin-timeline): refuse a null gantt date instead of drawing it at the epoch - #6782

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6770-null-gantt-date-epoch
Aug 29, 2026
Merged

fix(plugin-timeline): refuse a null gantt date instead of drawing it at the epoch#6782
os-sales merged 1 commit into
mainfrom
claude/issue-6770-null-gantt-date-epoch

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #6770

What was wrong

new Date(null).getTime() is 0, not NaN — the Unix epoch, not an invalid
date — so a null gantt date passed objectui#6759's parse guard untouched and
reached the arithmetic as 1970-01-01.

Re-derived on my own base c6732825d (which already carries objectui#6759, PR
#6772), with a throwaway probe, one row item, before any change:

input diagnostic axis bar
endDate: null none 649 columns, Jan 1970Jan 2024 left: 100%; width: -100%;
startDate: null none 651 columns left: 0%; width: 100%;
both null none ["Jan 1970"] left: 0%; width: 100%;
minDate/maxDate pinned null none Jan 2024Mar 2024 (correct) correct
endDate: 0 none 649 columns left: 100%; width: -100%;

The filed case is the first row. The two after it are the reason this is a
refusal rather than a repair: they do not look broken. A full-width bar under a
one-bucket Jan 1970 axis is a chart a reader would believe.

The arm taken, and the pin that decided it

Refuse, reusing objectui#6759's role="alert" diagnostic. The card asked
for a ruling request if the choice was genuinely open; measurement says it is
not, because this input class is already adjudicated one spelling over:

  • objectui#6759 refuses an absent date — new Date(undefined) is NaN — and
    pins it: "an ABSENT date is the same input class and is named as
    undefined"
    (timeline-gantt-unusable-date-range.test.tsx, pin 1). Measured
    on this base, an omitted endDate already renders the diagnostic. null is
    that same absence with a different spelling, and which of the two a document
    carries is decided by the record mapping upstream, not by the author. Making
    the render differ on it would make the chart depend on how a mapping layer
    spells "no value".
  • spellGanttDateValue already spells null as itself, written by that card for
    "one they wrote as empty". Until this change nothing could reach that branch:
    null parses, and a pinned null is discarded by the caller's ||.
  • The other two arms fail the test both neighbouring cards already applied.
    "Runs to the end of the axis" is a new rendering behaviour that needs a spec
    key of its own rather than a meaning smuggled into null; "fall back to
    startDate" substitutes a plausible value for one the document does not carry
    — the consumer-side tolerance objectui#6750 and objectui#6759 both rejected,
    and AGENTS.md commandment #0.1 with them.

The card's ⭐ note — a null from a data source differs in kind from an author
typing a wrong date — is real, and it does not separate null from undefined,
which is the spelling already refused. If the product wants tasks with no end
date to render open-ended, that is a feature with an authored spelling of its
own, not a coercion of null; worth a separate card if it is wanted.

Where the line is: null, not "coerces to the epoch"

The predicate is value === null, deliberately nothing wider. 0 coerces to the
epoch as well (measured above, same 649-column axis) — but 0 is a legitimate
epoch timestamp, indistinguishable from an author who means 1970-01-01, and
startDate: 1704067200000 renders 2024 correctly today. Both a numeric
timestamp and a Date instance are pinned as still rendering. The other
epoch-coercing values (0, false, []) are filed separately as
objectui#6781 rather than swept in here.

Unchanged, and pinned as unchanged

  • A pinned minDate: null / maxDate: null is falsy, so the caller's
    schema.minDate || dateRange.minDate discards it and the rows' own range still
    renders — the same reasoning objectui#6759 applied to minDate: ''.
  • objectui#6750's empty-list sentinel still answers items: [].
  • The healthy path's bar geometry is re-asserted at its full float spelling.
  • The guard stays gantt-only.

Wording, and why no new i18n key

The diagnostic is objectui#6759's timeline.gantt.unusableRange.malformedDate,
whose {{value}} hole is filled as null. A null-specific sentence would be a
new key in all ten locale packs under packages/i18n, which is outside this
card's file surface and would drag in nine translations. Flagging it as the one
reviewable choice here: the rendered sentence reads "items[0].items[0].endDate
is null, which is not a valid date. Every gantt date has to parse …"
, and the
trailing clause is loose for a value that technically does parse.

Verification

Union re-run at d6fe6d3c4, the final commit, on a clean tree:

  • pnpm exec vitest run packages/plugin-timeline/Test Files 16 passed (16),
    Tests 136 passed (136) (14 of them this card's new pins).
  • pnpm --filter @object-ui/plugin-timeline type-check — exit 0, and
    tsc --listFiles confirms both the edited renderer and the new test file are
    in the program (1 hit each), so "typecheck clean" is a statement about this
    diff.
  • pnpm --filter @object-ui/plugin-timeline lint — 0 errors (92 pre-existing
    no-explicit-any warnings, none new in kind).
  • Gates: check-control-bytes, check-vi-mock-specifiers, check:i18n-keys,
    check-changeset-presence, check-changeset-no-major, check-changeset-fixed,
    check-changeset-overwrite, check-lint-coverage, check-type-check-coverage,
    check-shell-escape-residue — each exit 0 with its own verdict line.

Reverse verification (ablation). With the fix committed, the base renderer
was put back on disk pinned by commit sha, the mutation was confirmed on disk
before reading anything (isUnusable occurrences 3 → 0, doesNotParse 0 → 3,
and the on-disk blob hash equal to the base blob), then the new pin file was run:
6 failed | 8 passed. The six are exactly this card's refusal pins; the eight
that stay green are the unchanged-behaviour pins, which must pass on the base
too. Restoration is git checkout HEAD -- on an absolute path, proved by
git diff HEAD empty and the on-disk hash equal to the HEAD blob, and the pins
were re-run green afterwards. No rebuild leg: the test imports ../renderer
relatively, so vitest transforms the source and no dist/ sits in the path.

Declared narrowing. check:eager-closure and check:sdui-registration-pins
both require a full apps/console build; without one they report
No console build to weigh (exit 2 = measured nothing, not a failure) and are
left to CI. Neither subject is touched here: this diff adds no new runtime
strings — the diagnostic reuses keys already in the eager closure — and no
registration array changes.

Generated by Claude Code


Generated by Claude Code

…t at the epoch

`new Date(null).getTime()` is `0`, not `NaN` — the epoch, not an invalid date —
so a `null` `startDate` / `endDate` passed objectui#6759's parse guard and
reached the arithmetic as `1970-01-01`. Measured on c673282, with #6759's
guards already in the tree:

    CASE-NULL-END   endDate: null   -> no alert, axis 649 columns
                    (Jan 1970 … Jan 2024), bars ["left: 100%; width: -100%;"]
    CASE-NULL-START startDate: null -> no alert, axis 651 columns,
                    bars ["left: 0%; width: 100%;"]
    CASE-NULL-BOTH  both null       -> no alert, axis ["Jan 1970"],
                    bars ["left: 0%; width: 100%;"]

The last two are why this refuses rather than repairs: they do not look broken.

The arm is #6759's own, not a new one. That card already refuses this input
class one spelling over — `new Date(undefined)` is `NaN`, so an omitted
`endDate` is refused and named — and which spelling a document carries is
decided by the record mapping upstream, not by the author.

The scan's predicate is `value === null`, deliberately not "coerces to the
epoch": `0` is a legitimate epoch timestamp and `startDate: 1704067200000`
renders 2024 correctly today. A PINNED `null` stays unjudged, because the
caller's `schema.minDate || dateRange.minDate` discards it before anything
reads it.

Fixes #6770

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

Metric Value Budget
Eager closure (gzip, 45 chunks) 3174.0 KB 3222.7 KB
Main entry chunk (gzip) 148.2 KB 350 KB
Entry file index-CoqYmF90.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 511.75KB 116.33KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 240.93KB 60.76KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 64.68KB 18.35KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.48KB 34.51KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.46KB 62.46KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.03KB 32.64KB
plugin-gantt (index.js) 165.23KB 40.37KB
plugin-grid (index.js) 201.57KB 54.55KB
plugin-kanban (index.js) 53.14KB 14.64KB
plugin-list (index.js) 113.15KB 27.59KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 28.95KB 8.33KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 72.12KB 23.98KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-timeline): a null gantt date is silently coerced to the epoch — a 649-column axis and a negative-width bar, with no diagnostic

2 participants