Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

buildChartSeries' pivot branch silently drops a null SECOND-dimension group: its measure is written to the bucket but never becomes a series, so the value vanishes from the chart #4673

Closed

Description

@yinlianghui

Measured while implementing #4508, filed rather than widened into it: #4508 and its two predecessors (#4466, #4497) are all about the FIRST dimension's bucket. This is the same defect one dimension over, and nothing has ever covered it.

What is wrong

In buildChartSeries' pivot branch (packages/core/src/utils/chart-series.ts):

const gId = String(row[groupKey] ?? '');
if (gId !== '' && !seriesKeys.includes(gId)) seriesKeys.push(gId);
byX.get(xId)[gId] = row[measure];

A row whose SECOND dimension is null/undefined (or '') gets gId === ''. The gId !== '' gate means it never joins seriesKeys — but the line below still writes the measure into the bucket under the '' key. So the value is present in the emitted row and no series ever binds to it, i.e. the renderer is never told to draw it.

buildChartSeries(
  [{ status: 'Backlog', priority: 'High', est_hours: 5 },
   { status: 'Backlog', priority: null,   est_hours: 40 }],
  ['status', 'priority'], ['est_hours'],
)
// data:   [{ status: 'Backlog', High: 5, '': 40 }]
// series: [{ dataKey: 'High', label: 'High' }]      <- no binding for the 40

The chart draws a 5-hour bar. The 40 hours are in the data and on no mark.

Why it is the same defect as #4466

#4466's measured case was exactly this shape on the x-axis: [{user_id: null, event_count: 51}, {user_id: 'Dev Admin', event_count: 2}] drew one bar and silently dropped the dominant group while the y-axis scale still accommodated it. Quoting that card's framing: the chart "was not merely empty, it understated its own data without saying so." A null second-dimension group understates it the same way, and for the same reason — no explicit bucket for "no value".

#4466 answered it for the first dimension with NULL_CATEGORY_LABEL; #4497 extended that answer to the pivot's first dimension; #4508 gave that bucket an identity distinct from its label. The second dimension was never in scope of any of the three, and the gId !== '' gate is still the pre-#4466 answer: drop it.

Drill consequence too

findChartSeriesRow's pivot arm matches the series dimension as String(r[gDim] ?? '') === s, so a null second-dimension row is reachable only by clicking a segment that is never drawn. Dead by construction rather than by accident.

The decision the fix needs

Not mechanical, which is why this is a card and not a drive-by. The first dimension's answer is a DISPLAY label supplied by the renderer (nullCategoryLabel, from the locale bundle — @object-ui/core is React-free and cannot read it). A series key, unlike an axis tick, is also a row key — it names a column in the emitted rows and a dataKey a renderer binds to. So the second dimension needs the label/identity split #4508 established for the first, but with the extra constraint that the key must not collide with a measure name or another series value. Worth deciding alongside whether '' (a genuine empty-string group) and null get separate series here, which is #4508's collision 1 in the series axis.

Also worth ruling: whether a null second-dimension group should draw at all, or whether the honest answer for a grouped-bar chart is to name it the way hasNoCategoryKey (framework#4033) names an unprojected dimension. Both are defensible; the current behaviour — carry the number, draw nothing, say nothing — is not.

Related: #4466, #4497, #4508, #4507, #1759 (the pivot branch's origin).

Activity

  1. added
    bugSomething isn't working
    target:v17v17 发布窗口工作集(GA 前排查 2026-08-04)
    on Aug 15, 2026
  2. added theissue type on Aug 15, 2026
  3. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    ContributorAuthor

    First-touch grading + board admission + delegated direction (seat repo:objectui; session session_01RnQd8iMMUwXQEV1crFmQiQ, 2026-08-15): promoted — pm:queue + target:v17, type Bug. Board basis: silent data understatement — the measure is carried in the emitted row and bound to no drawn mark, which is the #4466 harm verbatim, one dimension over.

    Direction (delegated ruling; maintainer veto window OPEN): the current behavior — carry the number, draw nothing, say nothing — is not one of the defensible options. The #4466 doctrine extends to the second dimension unchanged: a known-empty group draws (the null-category treatment), while an unprojected key refuses (hasNoCategoryKey, framework#4033) — "known to be empty" and "cannot know" are different facts and already have different answers on the first dimension. Choosing refusal here would give the two dimensions two different doctrines for the same fact, which is the #3314 shape.

    Mechanism is the implementer's, with the card's constraint binding: a series key is also a row key and a renderer dataKey, so the null-group series needs the #4508 label/identity split with a collision-safe key (must not collide with a measure name or a real group value), and the ''-vs-null distinction follows #4508's collision-1 answer. ⛔ Stop-clause: if a collision-safe key cannot be carried through the renderer binding without leaking onto an authoring surface (data on a chart schema), stop and report — do not trade the leak for the draw.

    Sequencing: queued behind #4508's merge (same file region, chart-series.ts — the pivot branch this card edits is the one #4508's PR rewrites).


    Generated by Claude Code

  4. self-assigned this
    on Aug 15, 2026
  5. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    ContributorAuthor

    Claim: PM loop round 8
    Session: session_01RnQd8iMMUwXQEV1crFmQiQ
    Branch: claude/issue-4673-null-second-dim-series
    Worktree: objectui-issue-4673
    Domain: repo:objectui
    File surface: packages/core/src/utils/chart-series.ts (pivot branch + findChartSeriesRow series arm) + chart-series.nullCategory.test.ts (+ a consumer-level test in plugin-charts/plugin-dashboard test files if needed). ⛔ AdvancedChartImpl.tsx's handleCartesianClick region is held by in-flight #4672 — if the mechanism needs a renderer-side edit beyond the existing nullCategoryLabel plumbing, name the exact region and stop on any overlap (explain in the report).
    Container & model: M, mode:subagent, model: opus
    Serial constraints cleared: the blocker #4508 / PR #4677 is MERGED — this branches from a main carrying chartBucketId / CHART_BUCKET_ID_KEY / pivotBucketId, which are the identity primitives the ruled direction builds on; #4672 holds the cartesian click region (no core files); #4676 holds metadata-admin/plugin-designer — disjoint.


    Generated by Claude Code

  6. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    ContributorAuthor
    {
      "issue": 4673,
      "status": "done",
      "branch": "claude/issue-4673-null-second-dim-series",
      "pr": "https://github.com/objectstack-ai/objectui/pull/4681",
      "premise_still_valid": true,
      "summary": "Premise A verified on current origin/main after #4677 merged: the `gId !== ''` gate and the unconditional `byX.get(xId)[gId] = row[measure]` write both survive at chart-series.ts:351-353 — #4677 rewrote only the FIRST dimension's bucketing, so the card's defect is real and its quoted code is accurate even though its line numbers moved. New `pivotSeriesBuckets` is the single place deciding what a second-dimension group IS (chartBucketId), what it SAYS (nullCategoryLabel, per Premise B — verified that both renderers already pass it for the first dimension, so no renderer edit was needed), and which COLUMN carries it; buildChartSeries and findChartSeriesRow both go through it, so writer and reader cannot drift. Key assignment is injective by construction: a group keys by its own display label unless that label is shared with another group, reserved by the row (xKey / CHART_BUCKET_ID_KEY), or equal to some group's identity — those key by identity, and since no surviving label is any identity the two key spaces cannot meet. Ordinary pivots are byte-identical, so no encoded key reaches an authoring surface and the stop-clause was not triggered. Premise C answered by extending the identity match rather than re-widening string tolerance. Also fixed within the same function and named in the PR body: a group value equal to the x-axis dimension NAME previously overwrote the axis value on its own bucket row.",
      "tests": "All at final commit a6a2282bb (= branch head; PR #4681). SWEEP: `pnpm exec vitest run packages/core/ packages/plugin-charts/ packages/plugin-dashboard/` -> 'Test Files 171 passed (171) / Tests 2497 passed (2497)'. TYPECHECK: `pnpm --filter @object-ui/core --filter @object-ui/plugin-charts --filter @object-ui/plugin-dashboard --workspace-concurrency=2 run type-check` -> 'Scope: 3 of 47 workspace projects', each running a real tsc, all Done (dependency closure built first via `--filter '<pkg>^...' build`; non-zero-match confirmed). LINT: eslint --quiet on the 3 changed files, clean. GATES: check-changeset-presence ('3 source file(s) of 2 released package(s) changed ... declares 1 changeset(s)'), check-changeset-no-major, check-changeset-fixed, check-control-bytes ('scanned 4208 tracked text file(s)') — all green. NEW COVERAGE: 21 cases in chart-series.nullCategory.test.ts (64 total in file) + 4 DOM cases in the new AdvancedChartImpl.pivotNullSeries.test.tsx. PRE-FIX MEASUREMENT of the card's repro, taken through the real renderer: data [{status:'Backlog', High:5, '':40}], series [{dataKey:'High'}], rects 1 — the 40 on no mark; post-fix 2 rects with the taller bar at exactly 8x the height. REVERSE VERIFICATION, direction predicted BEFORE running, two legs. Writer ablated (gId gate restored): predicted red in the buildChartSeries blocks and the DOM file, GREEN in reader-only drill cases — measured exactly that, 14 red/50 green in core and 3 red/1 green at the DOM, with 'resolves the null group's segment', 'resolves the empty-string group to ITS row' and 'never matches a row that lacks the second dimension key' staying green (the reader half is independently correct), and every first-dimension suite green. Reader ablated (String(r[gDim] ?? '') === s restored): predicted red in the drill block only — measured 7 red/57 green including 'expected 1 to be 2', a WRONG-row failure rather than a dead one. DOGFOOD/ABLATION DISCIPLINE: the DOM legs resolve @object-ui/core from dist, so core was rebuilt for every leg and the mutation was proved to have reached the artifact each time (4 ablation markers present when ablated, 0 in both src and dist after restore, so no mutated code stays live).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #4682 (label `finding`, unassigned): DatasetWidget.handleChartDrill titles the drill drawer with `ev.series`, the raw dataKey — so an identity-keyed group's drawer reads `[null]` instead of `(None)`. Drill resolves to the correct rows; only the title is opaque. The fix needs a `seriesLabel` on ChartSegmentClickEvent written by AdvancedChartImpl's click handlers, i.e. the handleCartesianClick region held by #4672, so it carries a Blocked-by line rather than being done here.",
        "filed as #4683 (label `finding`, unassigned): an unprojected SECOND dimension renders a grouped chart with zero series and no explanation — `hasNoCategoryKey` (framework#4033) has no counterpart on the series axis. Pre-existing and unchanged in mark count by this PR. Distinct from #4507, which is the first dimension's guard being blinded rather than absent; stated in the card why fixing #4507 does not produce this answer.",
        "NOT filed as a new card — noted on #4672 instead (comment 5301373955), because it lives inside that in-flight card's completion scope and scattering it would duplicate the line #4672 is already rewriting: handleCartesianClick forwards `ap?.dataKey ? String(ap.dataKey) : undefined`, and `''` is falsy, so the empty-string group's click sends `series: undefined` and survives only because findChartSeriesRow coerces it back to `''`. The two halves agree by arithmetic, not design. Pinned on the core side by a test that documents the coupling; the renderer side is #4672's region."
      ]
    }

    Held region respected: AdvancedChartImpl.tsx's handleCartesianClick was NOT edited — the mechanism needed no renderer-side change, because the null group's series carries the same nullCategoryLabel the renderers already pass for the first dimension. The two places where a renderer edit would have been the better answer are #4682 and the #4672 note above, both deferred rather than taken.

    Surprise worth surfacing: the empty-string second-dimension group's drill works end-to-end today only through the falsy-dataKey/?? '' coincidence described above. It is correct, but it is not designed, and #4672's rewrite sits directly on top of it.


    Generated by Claude Code


    Generated by Claude Code

  7. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    ContributorAuthor

    ACCEPT — PR #4681 (reviewer of record; readings taken on GitHub at head a6a2282bb, matching the report's gate-union HEAD).

    CI on this head is converging — ready + auto-merge flips when Lint / Type Check / Test shards conclude green (timer round covers it). Card closes on merge; pm:dispatched comes off then. This merge also unblocks #4672's re-dispatch (its A work was sequenced behind this PR).


    Generated by Claude Code

  8. added 2 commits that reference this issue on Aug 17, 2026
    af025ee
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingtarget:v17v17 发布窗口工作集(GA 前排查 2026-08-04)

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions