Skip to content

fix(plugin-kanban): decline to draw a card badge with no label - #8733

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-8489-kanban-empty-badge-label
Sep 9, 2026
Merged

os-justin merged 2 commits into
mainfrom
claude/issue-8489-kanban-empty-badge-label

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8489

What was wrong

ObjectKanban's explicit-card-field loop opens with if (raw == null || raw === '') continue; — which an empty array passes — and four lines later forks on isPicklist, true for any field carrying declared options. That fork never calls getCellRenderer, so objectui#8481's landed rule for the shared @object-ui/fields renderers ("an empty array is not a cell value") cannot reach it: the branch resolves a label and a colour itself and pushes a plain object onto cardBadges.

With nothing to resolve, the label came out as the empty string and the card drew a fully styled, fully coloured pill with no children in it — on the most commonly authored of the three shapes, because a picklist that declares its options is the normal case.

Addresses re-located by content on 5ece49bbc (the card's line numbers were from 7cf6f38fb): the guard is still at line 604, the isPicklist fork at 605-609, and the badge push at 629. They match the card exactly — nothing had moved.

The repair

Both cardBadges.push sites now decline a badge whose resolved label is empty. That is deliberately narrower than teaching the kanban what an "empty value" is — at the push site the only question left is is there a label to draw? — and asking exactly that closes the half of the defect that has nothing to do with arrays: any value resolving to an empty label drew the same pill, the empty array being merely the shape that is easy to hit.

The comparison is against the empty string, not falsiness, so a legitimately authored '0' label keeps rendering.

Second push site, repaired in the same shape (declared)

cardBadges.push occurs twice in this file. Besides the explicit cardFields loop, the legacy semantic-field heuristic — the branch that runs when a board authors no cardFields at all — resolves its label the same way (the file's own comment says so) and had the identical defect, reproduced below. It is repaired identically, skipping the push rather than breaking so an unlabelled field cannot consume one of the two badge slots. Same file, same defect class, same gate family, and it is pinned at the DOM alongside the dispatched branch; a repair on one alone leaves the pill drawable from the other.

Evidence

All measurements at 6e3b9ffe5. DOM shapes are spelled in words below on purpose — tag-shaped fragments are eaten from GitHub bodies even inside backticks.

Leg 1 — reproduced before repairing

packages/plugin-kanban/src/ObjectKanban.emptyBadgeLabel.test.tsx, run on the unrepaired tree: 4 failed, 5 passed. Each of the four failures printed the pill that should not exist — a lone division element whose class list is the badge primitive's own base (inline-flex, items-center, whitespace-nowrap, rounded-full, border, px-2.5, py-0.5) plus the indigo colour set, with no children and no text at all between its opening and closing tags. Reproduced for both shapes, on both branches.

Leg 2 — repaired, and populated badges byte-identical

After the repair the same file is 9 passed, and the whole package is 35 files / 232 tests passed. Byte-identity is not asserted by construction: POPULATED_BADGE_HTML and LEGACY_POPULATED_BADGE_HTML in the test file are the outer markup of the four populated pills captured from a run before the repair, and the suite compares the post-repair markup against those captured strings.

Leg 3 — non-regression, derived from the two plausible wrong fixes

Both mutate a read site (the new guard), never a pin, from a committed tree; each proves the mutation on disk in both directions (anchor counts plus git hash-object against the HEAD blob) with a line-total gate, and restores by state (git diff HEAD clean and the blob back to ef675ce63). Per-test classification comes from vitest's JSON reporter.

leg mutation result
3a drop any badge whose label is falsy NEGATIVE — landed, did not discriminate (9/9 passed)
3b drop empty arrays only discriminates — 7 passed, 2 failed: both non-array pins red, both array pins green

Leg 3a is reported as negative, not green. The mutation landed (blob moved, anchors flipped, line-total held) and nothing reddened, because at this site !label and label === '' are behaviourally equivalent: opt?.label || String(raw) lets no falsy label through, so the only falsy label that can reach the push is the empty string. The plausible wrong fix therefore is not wrong here — a real measurement, and a correction to the expectation the card set. === '' is kept anyway as the intent-revealing spelling, and the '0' pin still asserts the user-facing guarantee (it is observed red under leg 4a).

Leg 3b is the one that matters: it is exactly the fix that special-cases the empty array, and the fixture is built so that it passes both array pins and reddens both non-array pins. A fix pinned only on the array would have read as complete.

Leg 4 — caricatures, both directions

leg mutation result
4a never push a badge 4 passed, 5 failed — both census pins, both byte-identity pins, and the '0' pin
4b always push a badge (both guards deleted; line-total gate -2) 5 passed, 4 failed — all four absence pins

Every one of the nine pins was observed red under leg 3 or leg 4. No leg was void: every mutation moved the blob, flipped its anchors and held its line-total gate, and every restore was verified by state rather than by an exit code.

Assertions are at the DOM, not at cardBadges

Counts are taken from rendered nodes, scoped to one card through the accessible-name handle SortableCard already gives every card (list-item role plus an aria-label of the card title). queryByText with an empty string cannot express "no pill", and it throws on multiple matches as well as none, so the pins count nodes at a selector instead — and the selector is proved, not assumed, to match exactly one node per badge: the first case in each group asserts the populated card's badge text list has exactly the expected entries. The selector uses the badge primitive's own base classes (inline-flex and whitespace-nowrap together with rounded-full), which the two avatar nodes that make a bare rounded-full count double elsewhere in this repo do not carry.

Gates run locally

check verdict
vitest run packages/plugin-kanban/ 35 files / 232 tests passed
adjacent kanban consumers (plugin-view, app-shell, plugin-list, types) 5 files / 192 tests passed
pnpm --filter @object-ui/plugin-kanban type-check exit 0, with the dependency closure built first
eslint . (whole repo, 4594 files) 0 errors, 12400 pre-existing warnings
check-changeset-presence.mjs declares 1 changeset
check-changeset-no-major.mjs no major bump
check-control-bytes.mjs OK, 6919 tracked text files
check-governed-queue-guard.mjs --test NOT GOVERNED — ordinary route

The type-check genuinely covers the new test file: it is absent from the main program and present in tsconfig.test.json's, verified with --listFiles, and type-check runs both.

Notes

  • One premise of the card is corrected: an option whose declared label is the empty string cannot on its own produce an empty label, because opt?.label || String(raw) falls back to the stringified value; the i18n hop cannot introduce one either, since useObjectLabel's resolve() rejects a translation equal to the empty string and returns its fallback. The empty label always arrives through String(raw). The card's central finding — the empty pill, and the second half being independent of arrays — is unaffected and confirmed by rendering.
  • objectui#8534 (KanbanImpl mirroring its columns prop into state) is not folded in and the fixture did not trip over it.
  • @object-ui/fields' renderers are untouched — objectui#8481 owns those.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

os-justin and others added 2 commits September 9, 2026 01:26
`ObjectKanban`'s explicit-card-field loop opens with
`if (raw == null || raw === '') continue;` — which an empty array passes —
and then forks on `isPicklist`, true for any field carrying declared
`options`. That fork never calls `getCellRenderer`, so the shared
`@object-ui/fields` renderers' rule that an empty array is not a cell value
cannot reach it: it resolves a label and a colour itself and pushes a plain
object onto `cardBadges`. With nothing to resolve the label came out as the
empty string, and the card drew a fully styled, fully coloured pill with no
children in it — on the most commonly authored shape of the three, because a
picklist that declares its options is the normal case.

Both `cardBadges.push` sites now decline a badge whose resolved label is
empty. That is narrower than teaching the kanban what an "empty value" is:
at the push site the only question left is whether there is a label to draw,
and asking exactly that also closes the half that is independent of arrays —
any value resolving to an empty label drew the same pill, the empty array
being merely the shape that is easy to hit. The comparison is against the
empty string rather than falsiness, so a legitimately authored '0' label
keeps rendering.

The legacy semantic-field heuristic that runs when a board authors no
`cardFields` resolves its label the same way and had the same defect; it is
repaired in the same shape, skipping the push rather than breaking so an
unlabelled field cannot consume one of the two badge slots.

Pinned at the DOM, not at `cardBadges`: counts taken from rendered nodes
scoped per card, with the counting selector proved to match exactly one node
per badge. Every populated badge is pinned byte-for-byte against markup
captured before the repair.

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

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3482.0 KB 3512.7 KB
Main entry chunk (gzip) 144.0 KB 350 KB
Entry file index-D8qjsfct.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) 15.67KB 5.75KB
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) 499.42KB 114.32KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 244.96KB 61.76KB
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) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 251.39KB 65.04KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 134.16KB 33.47KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.18KB 56.62KB
plugin-kanban (index.js) 55.50KB 15.75KB
plugin-list (index.js) 112.73KB 27.69KB
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.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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) 81.07KB 26.86KB
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.55KB 2.45KB
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) 13.64KB 4.59KB
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

@os-justin
os-justin marked this pull request as ready for review September 9, 2026 01:46
@os-justin
os-justin enabled auto-merge September 9, 2026 01:46
@os-justin
os-justin added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit c6ae7b2 Sep 9, 2026
35 checks passed
@os-justin
os-justin deleted the claude/issue-8489-kanban-empty-badge-label branch September 9, 2026 02:19
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-kanban): the card's OWN picklist branch never reaches the cell renderer, so an empty array still paints an empty coloured badge pill

1 participant