Skip to content

fix(plugin-charts,core): conjoin the chart drill filter instead of spreading it - #9016

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-8944-chart-drill-filter-compose
Sep 10, 2026
Merged

fix(plugin-charts,core): conjoin the chart drill filter instead of spreading it#9016
os-steve merged 1 commit into
mainfrom
claude/issue-8944-chart-drill-filter-compose

Conversation

@os-steve

@os-steve os-steve commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #8944

The defect

ObjectChartSchema.filter admits BOTH a spec FilterArray ([['region','=','emea']]) and the ObjectQL $filter object ({ region: 'emea' }), and both are read — both travel verbatim to ds.aggregate / ds.find as $filter. The drill seam composed them by spreading the widget's filter into an object literal:

return { ...(schema.filter || {}), ...computeDrillFilter(drillDown, drillEvent, { groupByField }) };

Spreading an ARRAY yields index keys, so an authored FilterArray drilled as { '0': ['region','=','emea'], stage: 'won' } — the widget's own conditions replaced by a key the query layer ignores. Nothing errored; the drawer opened and looked right.

Direction of the failure. The widget's filter is what NARROWS. Dropping it made the drilled list a superset — it showed records the chart itself was scoped to exclude. Not a security boundary, but the worse direction for a silent bug.

The composition rule — named, and taken from an existing helper

widget.filter AND drill.filter.

The two are independent filter SOURCES and a drill must satisfy both: the click context only says which bucket of the widget's scope the user asked for, so it may narrow that scope and may never widen it.

This rule is not picked here. It is the contract mergeFilterNodes (@object-ui/core) already states — "combine filter sources under a single and, each as its OWN child" — the sink every other multi-source filter in this repo already goes through (ObjectView, RelatedList, LineItemsPanel, RecordPickerDialog, ElementDataSourceGate, buildEffectiveFilter). toFilterNode beneath it already lowers all three filter shapes in circulation, which is exactly what makes the array arm survive.

A new composeDrillFilter seam in @object-ui/core (beside computeDrillFilter) applies that sink at the drill seam and documents the rule in one place, then lowers the composed node back to the FilterCondition object dialect with parseFilterAST — the spec's single lowering sink — because that is the dialect both drill sinks take (the drawer hands the value to object-data-table's filter, and DrillNavigationContext.openRecordList declares Record of unknown and serializes it to filter[...] URL params).

Measured, so the compatibility claim is not a guess:

widget filter composed
[['region','=','emea']] { $and: [{ region: 'emea' }, { stage: 'won' }] }
{ region: 'emea' } { $and: [{ region: 'emea' }, { stage: 'won' }] }
absent { stage: 'won' } — flat, identical to the spread
(pre-fix, array arm) { "0": ["region","=","emea"], "stage": "won" }

A lone surviving source lowers back to exactly the flat object the spread produced, so a chart with no filter of its own drills byte-identically to before; only a genuinely composed pair gains the $and.

The one consequence that needed fixing with it

The composed $and reaches serializeDrillFilterParams (app-shell) through target: 'navigate' and the drawer's "Open in list". That writer took the String(value) path for it — $and holds an ARRAY, so it was neither null nor a non-array object — and emitted a bogus filter[$and]=[object Object],[object Object] while both real conditions vanished. That is the outcome that function's own contract says it never produces, and it fails in the same widening direction as the card.

It now flattens a top-level (and nested) $and into the flat filter[...] params its own READ side already ANDs back together, and skips a bare array comparand rather than stringifying it. Purely additive: no shape it handled before changes.

Assertions

Per-arm and both-together, asserted as semantics rather than as the absence of index keys. The composed filter is run through ValueDataSource (a real matcher for both $filter dialects) over a fixture built so each source excludes a different row:

  • widget filter alone (region = emea) selects a, c
  • click context alone (stage = won) selects a, bthe superset a dropped widget filter produces
  • conjoined selects a — the only correct answer

Both single-source answers are asserted as live controls so the fixture cannot go vacuous silently. Cases: array arm; multi-condition array arm; object arm; no-filter regression control; widget array arm conjoined with an authored drillDown.filter; and the drawer sink proving it drills by the same composed value as the navigate arm.

Ablation

Reverting ObjectChart.tsx alone to the spread, with the rest of the change in place (mutation proven on disk by blob hash + grep counts in both directions, restored via git checkout HEAD -- and verified by an empty git diff HEAD):

6 red / 5 green — 5 of the 7 cases in the new file, plus the objectui#3354 navigate pin. The two that stay green are by design: the live-control case (green on both trees, that is its job) and the no-filter regression control (single-source composition really is byte-identical to the spread).

⚠️ The observed pre-fix row set is [], not the ['a','b'] superset, and the difference is worth stating rather than smoothing over. ValueDataSource is stricter than the wire: it names the nonsense field explicitly —

ValueDataSource: filter comparand for field '0' on operator 'implicit equality' is an ARRAY,
which is a declared comparand only for $in / $nin / $between. Rows are excluded rather than
passed through.

— so in the in-memory matcher the index key excludes everything, while a server that simply ignores an unrecognized key widens instead. Both are wrong and the fixture discriminates either way; the card describes the wire direction, and this is the matcher direction.

Gates

gate exit evidence
type-check (4 packages + closures) 0 turbo 33 successful, 33 total
lint (eslint, changed files) 0 7 files, 0 errors, 88 pre-existing no-explicit-any warnings
plugin-charts suite (root) 0 55 files / 508 tests
app-shell + types suites (root) 0 843 files / 9872 passed, 1 skipped
core suite (root) 0 141 files / 3017 tests
check-control-bytes 0 7210 tracked text files
check-changeset-presence 0 6 source files / 4 released packages, 1 changeset
check-governed-queue-guard --test 0 NOT GOVERNED — ordinary PR
check:doc-types 0 needs no build
check:doc-snippets 0 after the scoped build (35 successful); it exits 2 = PREREQUISITE NOT MET on an unbuilt tree
check:doc-examples 0 same, after the same build
check:new-line-citations 0 0 new cross-file citations added
check:phantom-deps 0 the new @objectstack/spec/data import is declared by core

Lint was narrowed to the diff, and the narrowing is measured rather than assumed: file count read from eslint --format json (7), and eslint.config.js / eslint-rules/ declare no project / projectService / tsconfigRootDir, so type-aware linting is not enabled and this diff cannot move the verdict of any file it does not touch. The repo-wide turbo run lint farm stays CI's run.

Acceptance notes


🤖 Generated with Claude Code

https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB

…reading it

`ObjectChartSchema.filter` admits both a spec `FilterArray` and the ObjectQL
`$filter` object, and both are read. The drill seam composed them by spreading
the widget's filter into an object literal, which is correct for the object arm
and silent nonsense for the array arm: spreading an array yields index keys, so
an authored `FilterArray` drilled as `{ '0': [...], stage: 'won' }` and the
widget's own conditions were dropped for a key the query layer ignores.

The direction matters: the widget's filter is what narrows, so dropping it made
the drilled list a superset of what the chart itself was scoped to.

Compose through a new `composeDrillFilter` seam in `@object-ui/core`, which
applies the rule `widget.filter AND drill.filter` via `mergeFilterNodes` — the
repo's single filter sink, whose contract already states it — and lowers the
result back to the FilterCondition object dialect with `parseFilterAST`. A lone
surviving source lowers back to the flat object the spread produced, so a chart
with no filter of its own drills exactly as before.

`serializeDrillFilterParams` learns to flatten that `$and` into the flat
`filter[...]` params its own read side already ANDs back together; without it a
composed filter took the `String(value)` path and emitted a bogus `filter[$and]`
while both real conditions vanished.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.1 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-DNXp8VQp.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) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
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) 11.08KB 4.58KB
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) 500.20KB 114.67KB
core (index.js) 7.95KB 3.19KB
create-plugin (index.js) 28.21KB 9.54KB
data-objectstack (index.js) 207.32KB 57.37KB
fields (index.js) 247.01KB 62.29KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.99KB 0.87KB
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 (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.64KB 19.98KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 130.98KB 34.54KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 253.71KB 65.78KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 210.87KB 57.30KB
plugin-kanban (index.js) 46.03KB 14.30KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
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) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
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) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
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 (expression.js) 0.20KB 0.18KB
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.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

Copy link
Copy Markdown
Collaborator Author

In-seat review of record — Clause-② (contract delta), domain:ui @ objectui seat

Verdict: land. One acceptance note below is falsified and becomes a filed card; it is not a change to this PR.

Clause-② is engaged and I want it stated rather than inferred: composeDrillFilter is a new published exportpackages/core/src/index.ts:72 is export * from './utils/drill-down.js', so the function reaches consumers the moment this lands. That is a widening of @object-ui/core's public surface. Per objectstack#17285 this lane's in-seat default-tier review plus the gates is the review of record, so this comment is it.

What I verified myself rather than accepting from the report

The undefined contract. The docblock promises "Returns undefined when neither source carries anything." I read the installed @objectstack/spec 17.4.0: parseFilterAST opens with const lowered = lowerFilterAST(filter); if (lowered === void 0) return void 0;, and lowerFilterAST opens with if (filter == null) return void 0. So the promise holds through the real sink, not by assumption.

The same read also explains the compatibility claim instead of leaving it asserted: lowerFilterAST collapses a single-child ['and', X] to X. That is why a lone surviving source lowers back to the flat object the spread produced — the byte-identical no-filter path is a property of the sink, not a special case in this seam.

The interaction with objectui#9019, which the report flags as semantic adjacency. This matters more than the report allows, and it lands in this PR's favour. #9019 makes toFilterNode answer undefined for a TRUE-identity group, and mergeFilterNodes sits on toFilterNode. So after #9019, composeDrillFilter({ $and: [] }, drill) drops the identity and composes the drill alone. Before #9019 that same input put a plain object in AST child position, which isFilterAST refuses. composeDrillFilter absorbs the new undefined correctly because parseFilterAST already tolerates it. The two changes agree in both orders of landing; no coordination needed.

Blast radius of the URL-writer change. serializeDrillFilterParams has exactly one consumer — packages/app-shell/src/views/useOpenRecordList.ts:33 (control lit: parseUrlFilterTriples found in the same file by the same probe). So the $and-flattening change is enumerable and contained.

On the parts that were done well, briefly

The composition rule was not invented here — it is mergeFilterNodes' stated contract, and the fence I dispatched said a stop was only owed if no existing helper settled it. One did, so not stopping was correct.

Fixing serializeDrillFilterParams in the same PR was right and not scope creep: the composed $and reaches it, and leaving it would have moved the bug from the memo to the URL writer — same widening direction, new address.

The ablation reports 6 red / 5 green with both greens explained by design, and the report volunteers that the observed pre-fix row set is [] rather than the ['a','b'] superset the card predicted, with the matcher's own error text as the reason. Volunteering a direction mismatch instead of smoothing it is the standard this lane wants.

⚠️ FALSIFIED — the ObjectPivotTable acceptance note. This becomes a card.

The note reads: "ObjectPivotTable.tsx composes its drill filter with the same spread. Not the same defectPivotTableSchema.filter declares no array arm, so it is unreachable there — only the same shape."

The observation is true and the inference does not follow. Measured on origin/main, every control lit:

  • PivotTableSchema lives in packages/types/src/data-display.ts:1821, not in objectql.ts. It declares 12 members and none of them is filter — so "declares no array arm" is true only because it declares no filter at all.
  • BaseSchema declares 21 members, none of them filter, so nothing is inherited.
  • The only declaration of filter that reaches the component is the local props intersection in ObjectPivotTable.tsx:41schema: PivotTableSchema & { …; filter?: any; }.

any admits an array. Nothing in the type system stops an author writing filter: [['region','=','emea']] on a pivot, and ObjectPivotTable.tsx:269 is the identical statement this PR just removed from the chart:

const merged = { ...(schema.filter || {}), ...baseFilter };

⇒ The pivot arm is not unreachable; it is undeclared, which is strictly more permissive than the chart's any[] | Record union, not less. Whether a live pivot authors the array form is unmeasured and is a fair input to grading — but "unreachable" is not the reason to leave it, so the note's basis is gone.

This is not a request to widen this PR. Different block, different file, different authoring surface — the same split that produced objectui#8883 out of objectui#7299. I am filing it with the measurement above so the reasoning is on the record rather than in a review comment, and this PR lands on its own merits.

There is a second finding in the same three lines worth carrying into that card: filter?: any is a local member declared on a props intersection rather than on the schema — the second-declaration class objectui#6357 measured — and it sits directly beneath a comment explaining that the bind key used to be declared exactly that way and was removed for exactly that reason.

Gates

34 of 34 checks resolved on this head — all success or skipped, none red, none in flight. Landing now.


Generated by Claude Code

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-charts): the ObjectChart drill-down spread mis-composes the filter ARRAY arm into index keys, silently dropping the widget's own filter

2 participants