fix(plugin-timeline): spell a refused gantt date by a rule, not by String - #7026
Merged
Conversation
…tring`
`spellGanttDateValue` fills the `{{value}}` hole of the unusable-gantt-date
alert, whose stated job is to name the value the author actually wrote. Its
`String(value)` fallback was written when nothing but `[object Object]`-shaped
values could reach it. #6905's type rule routes the whole non-date type space
through it, and it failed three ways — measured on b458300:
endDate: [] -> "endDate is , which is not a valid date"
endDate: ['2024-01-01'] -> "endDate is 2024-01-01, ..."
endDate: [0] -> "endDate is 0, ..."
endDate: 0n -> "endDate is 0, ..."
endDate: {toString: () => '2024-01-01'} -> "endDate is 2024-01-01, ..."
endDate: {toString() { throw }} -> THREW, uncaught, mid-render
endDate: {get [Symbol.toStringTag]() { throw }} -> THREW, uncaught, mid-render
endDate: Object.create(null) -> THREW TypeError
It VANISHES, it LIES (naming a text that IS a valid date, or a number that IS
an accepted one — `0` is a kept gantt date), and it THROWS. The third is the
one that matters: #6759 built this helper "total by construction" and #6905
made that property load-bearing, but the type gate only stopped `new Date`
from throwing. `String(value)` handed control to author-supplied `toString` /
`Symbol.toStringTag` one line later, so the crash class did not go away — it
moved out of `findUnusableGanttDate` and into the speller.
The rule, now recorded on the helper with its ground:
Spell the value when the LANGUAGE owns its spelling.
Name its TYPE when producing text would run AUTHOR code.
Every primitive is spelled as the author typed it, `bigint` included — `0n`
keeps its `n` rather than reading as the accepted `0`. A `Date` uses
`Date.prototype.toString.call`, byte-identical to `String` for every Date with
a `[[DateValue]]` slot but not hijackable by a subclass override. Everything
else is named by type — `an array`, `a function`, `an object` — chosen with
`Array.isArray` and `typeof`, which read no author-controlled property.
`JSON.stringify` is refuted, not overlooked: `JSON.stringify(0n)` throws, and
so does a cycle. A bounded rendering is refused on the same ground — even an
element count is not total, because `Array.isArray` is true of a Proxy whose
`length` trap throws.
Unchanged, and pinned: the accept/reject set (#6781's ruling), the `undefined`
/ `null` / quoted-string spellings (#6759, #6770), the shared inverted-range
diagnostic, and the i18n keys — the article rides in the existing `{{value}}`
hole, so `packages/i18n`'s ten locale packs are not opened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
os-sam
marked this pull request as ready for review
August 31, 2026 13:38
os-sam
enabled auto-merge
August 31, 2026 13:38
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
|
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 #6907
spellGanttDateValuefills the{{value}}hole oftimeline.gantt.unusableRange.malformedDate— the alert whose entire job, perits own docblock, is "to name the value the author actually wrote". Its
String(value)fallback was written when nothing but[object Object]-shapedvalues could reach it. This PR replaces that fallback with a rule, recorded
with its ground.
Premise check first
The card describes population 2 as "armed once #6781 lands". #6781 has
landed —
85f6a6097(PR #6905) is onmain. So every misleading row below islive on
maintoday, not pending. The card's ground is stronger than filed, notweaker.
What was measured on the base (
b458300ca), before any changeOne row item, a throwaway probe,
startDate: '2024-01-01'withendDatevaried:endDatemaintoday[]endDate is , which is not a valid date['2024-01-01']endDate is 2024-01-01, ...[0]endDate is 0, ...0nendDate is 0, ...{}endDate is [object Object], ...{toString: () => '2024-01-01'}endDate is 2024-01-01, ...new Map()endDate is [object Map], ...function myFn() {...}{toString() { throw }}{get [Symbol.toStringTag]() { throw }}Object.create(null)TypeError: Cannot convert object to primitive valueThree faults, not one:
String([])is the empty string — the card's filed case,and the exact blur the docblock says it quotes strings to avoid.
accepted value.
0nreading as0is the worst:0alone is a keptgantt date under finding(plugin-timeline): non-string gantt dates that coerce to the epoch (
0,false,[]) still draw a 649-column 1970 axis with no diagnostic #6781.crashes on
main. finding(plugin-timeline): two more unusable gantt date ranges — a malformed date still throws, and an inverted author-pinned range silently draws a negative-width bar on no axis #6759 built this helper "total by construction" andfix(plugin-timeline): judge gantt dates by TYPE — string, finite number, or Date #6905 made that property load-bearing by putting the type gate before
new Date. But the gate only stoppednew Datefrom throwing;String(value)handed control to author-suppliedtoString/Symbol.toStringTagone line later. The crash class did not go away — itmoved out of
findUnusableGanttDateand into the speller. That turns thisfrom diagnostic polish into a repair of the totality property both earlier
cards depend on.
The rule
Every primitive has a spelling fixed by the grammar: it is literally the text
the author typed, and it cannot be confused with another value's spelling. A
non-primitive has no such spelling — the only way to get text out of one is to
call
toString/Symbol.toPrimitive/Symbol.toStringTag, all of which arewhatever the authored document happens to carry. The diagnostic would be quoting
the very data it is refusing, as if it were trustworthy.
[]an array['2024-01-01']2024-01-01an array[0]0an array0n00n{}[object Object]an object{toString: () => '2024-01-01'}2024-01-01an objectnew Map()[object Map]an objectfunction myFna function{toString() { throw }}an object{get [Symbol.toStringTag]() { throw }}an objectObject.create(null)an object'not-a-date'"not-a-date""not-a-date"(#6759 pin)''""""(#6759 pin)undefinedundefinedundefined(#6759 pin)nullnullnull(#6770 pin)Symbol('oops')Symbol(oops)Symbol(oops)(#6781 pin)false/NaNfalse/NaNfalse/NaNnew Date(NaN)Invalid DateInvalid Datebigintis the one place the rule keeps naming the value where the cardoffered
a bigint: a bigint is a primitive, its literal syntax is fixed by thelanguage, and
0nis both what the author typed and unmistakably not theaccepted
0. The rule decided that, not taste.The four-facet analysis
1. 实际业务需求 — real business need. The population is real and measured,
and the repair the author needs is unwrap the wrapper. An array or a
toString-able object in a date column is what a mapping layer emits for ato-many relation or a column returned as a list — the same class of fault #6781
measured for
false. The path half already does the finding and is alwaysexact (
items[0].items[0].endDate); the author can read their own document atthat path. What they cannot read is why it was refused, and today the sentence
actively denies it —
endDate is 2024-01-01, which is not a valid datesendsthem to check a date format that is already correct. Naming the type is the half
of the sentence they do not already have.
2. 项目长远合理性 — long-term soundness. This is a rule with a ground, not a
fourth special case: it is derived from a property of the value's kind
(language-owned spelling vs author code), so it closes the whole class rather
than the rows on the card — no later card for
Map, for a function, forObject.create(null). It also restores the totality property #6759 declaredand #6905 made load-bearing, rather than leaving a crash class parked in a
helper that documents itself as total. And it is contract-first in the direction
this repo requires: the refusal stays exactly as ruled, only its report
changes.
3. 防 AI 写错 — making AI-written metadata hard to get wrong. The strongest
facet here. A self-contradictory diagnostic is the worst possible input to an
agent authoring metadata:
endDate is 2024-01-01, which is not a valid dateinvites it to re-format a date that is already correct, and to loop.
endDate is an arraynames the repair in the sentence. And the crash rows are worse still —an AI author gets a blank screen with no message at all, the one failure mode
from which no correction can be inferred. Declared = enforced only helps if the
enforcement can say what it enforced.
4. 创业阶段不扩散 — startup scope discipline. This is the smallest change
that closes the class: one helper, no new i18n key, no new file surface, no
recursion, no new dependency, no change to the accept set. The hybrid
("type plus a bounded rendering") is declined — see below — and declining it is
what keeps the change this small.
Why the other two candidates lose
JSON.stringify— refuted, and re-measured here.JSON.stringify(0n)throwsTypeError: Do not know how to serialize a BigInt; it throws again on a cycleand silently drops
undefinedmembers. It would put fault 3 straight back intothe one helper that must not have it. This PR adds no
JSON.stringifycall.It remains only where #6759 already had it and where it is total: on a
string,as the quoting.
The hybrid — declined on measurement, not on taste. The rule refuses any
rendering of a non-primitive because rendering reads something off it. Even the
most bounded version imaginable — an element count — is not total:
Array.isArrayistruefor aProxywhoselengthtrap throws (measured).A hybrid would buy a few characters of detail by giving back exactly the
crash-freedom this card is about.
For the same reason the type name is chosen with
Array.isArray/typeofandnot
Object.prototype.toString.call— the more informative spelling(
[object Map]) consultsSymbol.toStringTag, which can be a throwing getter(measured).
Totality — proven, not asserted
All eight
typeofresults are covered and no branch falls through to authorcode. Pin 4 renders each of these and asserts the helper does not throw, still
names the path, and leaks no author-controlled text into the sentence:
an object whose
toStringthrows · whoseSymbol.toStringTaggetter throws ·whose
valueOfthrows · whoseSymbol.toPrimitivethrows ·Object.create(null)·a cyclic object · a cyclic array · a
Proxythrowing on every property ·a
Datesubclass withtoStringhijacked to throw ·0n·{ endDate: undefined }· a symbol · a function · a class constructor ·Map·Set·RegExp· an array of hostile members.The single reflective operation the helper performs,
instanceof Date, is theone
isGanttDateTypealready performed on the same value in order to refuse it,so this function adds no throw site the accept gate does not already have.
DateusesDate.prototype.toString.callrather thanStringfor that reason:measured byte-identical for every
Dateowning a[[DateValue]]slot (both thevalid reading and
Invalid Date), but aclass X extends DateoverridingtoStringcan hijackStringand throw — the builtin cannot be.Constraints the card set, each checked
"not-a-date"/""/" "(finding(plugin-timeline): two more unusable gantt date ranges — a malformed date still throws, and an inverted author-pinned range silently draws a negative-width bar on no axis #6759),undefined(finding(plugin-timeline): two more unusable gantt date ranges — a malformed date still throws, and an inverted author-pinned range silently draws a negative-width bar on no axis #6759),null(finding(plugin-timeline): anullgantt date is silently coerced to the epoch — a 649-column axis and a negative-width bar, with no diagnostic #6770),Symbol(oops)(finding(plugin-timeline): non-string gantt dates that coerce to the epoch (0,false,[]) still draw a 649-column 1970 axis with no diagnostic #6781) — read first, thenre-pinned in pin 3 as the whole clause an author reads, and the existing
finding(plugin-timeline): two more unusable gantt date ranges — a malformed date still throws, and an inverted author-pinned range silently draws a negative-width bar on no axis #6759 / finding(plugin-timeline): a
nullgantt date is silently coerced to the epoch — a 649-column axis and a negative-width bar, with no diagnostic #6770 / finding(plugin-timeline): non-string gantt dates that coerce to the epoch (0,false,[]) still draw a 649-column 1970 axis with no diagnostic #6781 suites pass unchanged.0still accepted andstill draws its chart, string / finite timestamp /
Datestill render, finding(plugin-timeline): a gantt timeline with an EMPTY literalitemsarray throws —calculateDateRangereduces an empty list #6750'sempty-gantt sentinel intact, one diagnostic at the first fault.
Dateends (compared against the builtin, so the pin is timezone-independent),a numeric end, a one-pinned-end inversion and finding(plugin-timeline): a gantt timeline with an EMPTY literal
itemsarray throws —calculateDateRangereduces an empty list #6750's degenerate boundary. Italso asserts the type-naming branches are unreachable from that site: both
ends passed
findUnusableGanttDateto get there, so each is a string, a finitenumber or a
Date.that a spelling needing a new sentence ("is not a date") would be a new key
across all ten locale packs in
packages/i18n, a different file surface. Aspelling that carries its own article does not need one:
"endDate is an array, which is not a valid date" is grammatical in the
existing hole.
packages/i18nis untouched, and pin 7 asserts the keys andtheir holes are unchanged. This was a reason to prefer this spelling, per
the card's instruction to prefer a solution that avoids the key.
Verification
Union run on the final commit
52dec0a08:pnpm exec vitest run packages/plugin-timeline --maxWorkers=2— 18 files,211 tests, all passed (25 of them new in this PR).
pnpm --filter @object-ui/plugin-timeline type-check— clean(
tsc --noEmit && tsc -p tsconfig.test.json;--listFilesconfirms both thenew test file and
renderer.tsxare inside that program, so "type-checkclean" is a statement about the code that changed).
eslinton both changed files — 0 errors, 0 fatal, 19 warnings, allpre-existing categories (
no-explicit-any,react-refresh) matching thesibling test files.
Declared lint narrowing. eslint was run on the two changed files rather than
repo-wide, and the narrowing is measured rather than assumed: (1) the diff
touches exactly two lintable files and no eslint config; (2)
--format jsonreports
2files linted,0errors; (3) the flat config sets noproject/projectService, so type-aware linting is off and a file's verdictdepends only on its own contents plus the shared config — this diff therefore
cannot move the verdict of any untouched file. CI runs the full farm regardless.
The before/after readings above are from a throwaway probe rendered through
TimelineRenderer, run on the unmodified base and again on this branch; theprobe file is not committed, and every reading it produced is now covered by a
committed pin.
Out of scope, recorded
Object.create(Date.prototype)— a slot-lessDateimpostor — passesisGanttDateType'sinstanceof Dateand then crashes insidefindUnusableGanttDate'snew Date(value), before reaching this helper.That is a boundary of #6781's accept gate, not of the speller, and it is
unreachable from JSON metadata or from any data adapter (both produce real
Dates or plain values). Left untouched deliberately; noted here so a reviewercan disagree.
Generated by Claude Code