Skip to content

fix(indicator): a falsy child no longer deletes the state mark (#4893) - #4913

Merged
cixzhang merged 3 commits into
mainfrom
fix-4893
Aug 11, 2026
Merged

fix(indicator): a falsy child no longer deletes the state mark (#4893)#4913
cixzhang merged 3 commits into
mainfrom
fix-4893

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #4893 — a regression I introduced in #4890.

The bug

The busy idiom a host actually writes is:

<CheckIndicator state="checked">{isBusy && <Spinner/>}</CheckIndicator>

When isBusy is false that passes false. false is not null, and it is not caught by ?? — so every indicator took its children path, rendered nothing there, and deleted the state mark on every selected row.

before/after

It was wider than the issue says

#4893 was filed against CheckIndicator, because that is where #4890 moved the branch. I probed all three before writing anything, and all three were affected — the siblings' {children ?? mark} has the same hole, and it predates #4890:

children CheckIndicator CheckboxIndicator RadioIndicator
false mark lost, empty span mark lost mark lost
'' mark lost, empty span mark lost mark lost
null / undefined ok ok ok
<b/> replaces mark ✓ replaces mark ✓ replaces mark ✓

So CheckIndicator additionally leaked an empty <span>; the mark loss is the whole family. #4890 widened the CheckIndicator case from one state to every state — before it, the unchecked early return happened to mask it.

The fix

isRenderable(children) in all three — the util the repo already ships for exactly this (utils/isRenderable.ts), and what @astryx/no-nullish-jsx-guard steers to. That rule could not catch this: it sees JSX guards, not an early-return if or a ?? fallback.

0 deliberately still counts as content — it renders the character 0, so it is a real child. There is a test pinning that.

Two contained follow-ons in the same branch, both from the #4890 audit:

  • CheckIndicator's children slot now reserves the glyph's box (1rem, Icon's sm, which is what both indicator sizes resolve to). The doc says children "keeps the indicator's place"; it did not — the row shifted when a Spinner swapped in.
  • and carries the glyph's color (--color-icon-disabled when disabled, else accent), so an inherit-shade Spinner reads correctly. The sibling indicators already set color on their chrome for this reason. That closes the "size/isDisabled dropped on the children path" FIX.

Deliberately not fixed here: ref is dropped on CheckIndicator's glyph path. It looked like a one-liner and is not — Icon in string mode routes through IconFromRegistry, which accepts no ref, so fixing it means changing Icon's ref behavior for every caller. That is its own PR, not a rider on a regression fix. It stays open in the ledger.

New: apps/storybook/stories/Indicator.stories.tsx

This is scope I added on purpose. The indicators had no story file — rubric X9, open as #4894 — and the audit found that is why this bug shipped unseen: the children path had no rendered coverage anywhere, so nothing could have shown it. A regression fix with no way to see the regression would repeat that.

Five stories: AllStates, Sizes, Disabled, BusyChildren (the case above, both columns), BusyToggle (flip isBusy live). This also un-blocks rtl:audit, which currently reports 0 stories / 0 checks for Indicator.

Test plan

The new tests fail against main. I reverted the three source files in this same tree and re-ran:

× CheckIndicator keeps its mark when children is false — the `isBusy && …` idiom
× CheckIndicator keeps its mark when children is empty string
× CheckboxIndicator keeps its mark when children is false — the `isBusy && …` idiom
× CheckboxIndicator keeps its mark when children is empty string
× RadioIndicator keeps its mark when children is false — the `isBusy && …` idiom
× RadioIndicator keeps its mark when children is empty string
   Tests  6 failed | 23 passed (29)

All 29 pass with the fix. Each indicator also has a negative control — a real child still replaces the mark — so "always draw the mark" cannot pass this suite.

Visual, measured rather than asserted. Two Storybooks: main at 47f8bb4560 with this same story file copied in, and this branch. Real Chromium, deviceScaleFactor: 4, story core-indicator--busy-children, neutral light.

DOM count of visible marks in the not-busy column: before 0 of 4, after 4 of 4.

Pixel count of mark-ink inside each indicator's chrome:

before after
CheckIndicator 0 px 156 px
CheckboxIndicator (checked) 0 px 505 px
CheckboxIndicator (indeterminate) 0 px 372 px
RadioIndicator 0 px 1231 px

(For the filled chromes the mark is light-on-accent, so total non-white ink goes down slightly while the mark appears — counting light pixels enclosed by painted ones is the honest measure. Crops in /tmp/shots-4893/crops/.)

Suites: vitest 374 passed across Indicator + CheckboxInput + RadioList + Selector + DropdownMenu + CheckboxList; tsc core clean; CI=true eslint --no-cache 0 errors 0 warnings; check:changesets 55 valid.

Note on the audit trail

The ledger row for Indicator is 79.1 / C with three open BLOCKs (#4893, #4894, #4895). This PR closes #4893 and, via the story file, #4894. Per the rubric's closing protocol I will re-audit and update the ledger once this merges — the score has to move.


Two CI fixes on top, both pre-existing rather than caused here

build-storybook — storybook typecheck. My first push used satisfies Meta + StoryObj<typeof meta>, which makes args required on every story; state is required on an indicator, so the five render-only stories failed. The other 122 story files use const meta: Meta<typeof X> with StoryObj<typeof X>; mine now matches. My fault, caught by CI, fixed in a913c40406.

Vercel — the docsite build. This one is not from this PR. next build type-checks the emitted componentRegistry.ts against the docsite's own copy of the authoring doc types, and two fields had drifted out of that copy: deprecatedFor (added to ComponentThemingTarget by #4712) and replaces (on ComponentThemingDerivedVar, used by TextArea).

Verified with a control rather than asserted: a clean origin/main worktree fails the docsite build with the same 'deprecatedFor' does not exist in type 'ThemingTarget', and passes 315/315 static pages with only the one-file type fix applied. It surfaced here because this PR touches core .doc.mjs inputs, so Vercel's ignored-build-step ran a docsite build for the first time since #4712.

I diffed all six shapes the docsite duplicates; those two were the only gaps. Both authoring interfaces now carry a SYNC: breadcrumb, which is the mechanism the repo already uses to catch this.

One more thing seen while chasing it: /community prerenders a GitHub API call, and a 403 rate-limit from a build machine takes the whole build down (Error occurred prerendering page "/community"). I hit it locally, and the same build passed once the limit reset. The fetchContributors catch returns [], so something downstream of an empty list is the actual crash. Not this PR's to fix, but it is a real build-flake and worth its own issue.


Rebased after #4911 and #4915 landed. Both overlapped this PR while it sat in CI:

What remains here is only the falsy-children fix, its tests, and the story file. Re-verified post-rebase: 280 tests green, core tsc clean, storybook tsc clean.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 11, 2026
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 11, 2026 11:10pm

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Aug 11, 2026
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Indicator (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 551 -
Complexity N/A High (26) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.7KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

github-actions Bot added a commit that referenced this pull request Aug 11, 2026
@cixzhang
cixzhang requested a review from josephfarina as a code owner August 11, 2026 22:53
@cixzhang
cixzhang enabled auto-merge (squash) August 11, 2026 22:54
The busy idiom a host writes is `children={isBusy && <Spinner/>}`, which
passes `false` when it is not busy. `false` is not null and is not caught
by `??`, so every indicator took its children path, rendered nothing in
it, and dropped the checkmark, the checkbox tick and the radio dot on
every selected row.

Filed as #4893 against CheckIndicator, where #4890 moved the branch. It
was wider: measured before writing anything, ALL THREE indicators lost
their mark on `false` and on `''`. CheckIndicator additionally leaked an
empty span. #4890 widened its case from one state to every state -- the
unchecked early return had been masking it.

All three now guard with `isRenderable`, the util the repo already ships
for this. `0` still counts as content: it renders the character "0".

Also, both from the #4890 audit and contained to the same lines:
CheckIndicator's children slot now reserves the glyph's 1rem box and
carries its color, so swapping a Spinner in neither shifts the row nor
loses the disabled shade.

Adds apps/storybook/stories/Indicator.stories.tsx -- the indicators had
no story file (#4894), which is why the children path had no rendered
coverage and this shipped unseen.

Not fixed here: `ref` is dropped on the glyph path. Icon in string mode
routes through IconFromRegistry, which takes no ref, so that is a change
to Icon's ref behavior for every caller -- its own PR.
CI caught what my local run could not: 'satisfies Meta' + StoryObj<typeof
meta> makes `args` required on every story, and `state` is required on an
indicator, so five render-only stories failed the storybook typecheck.

122 of the 123 story files use `const meta: Meta<typeof X>` with
`StoryObj<typeof X>`; mine was the one that did not. Now it matches.

Verified the way CI does, after a full `pnpm build`: storybook typecheck
clean, storybook build completes, core typecheck (incl. tests) clean, cli
typecheck:strict clean, docsite generate + 364 tests green.
…try types

The docsite keeps its own copy of the authoring doc types, and `next build`
type-checks the emitted componentRegistry.ts against it. Two fields had
drifted out of that copy, so any full docsite build failed:

  deprecatedFor  added to ComponentThemingTarget by #4712 (Indicator's
                 renamed theme targets)
  replaces       on ComponentThemingDerivedVar, used by TextArea

Pre-existing on main, not introduced here -- verified by building the
docsite on a clean origin/main worktree, which fails with the same
`'deprecatedFor' does not exist in type 'ThemingTarget'`, and passes
(315/315 static pages) with only this one-file fix applied. It surfaced
on this PR because the change touches core .doc.mjs inputs, so Vercel's
ignored-build-step ran a docsite build for the first time since #4712.

Adds SYNC breadcrumbs on both authoring interfaces, since `check:sync`
is the mechanism the repo already uses to catch exactly this drift.

Diffed all six shared shapes; these two were the only gaps.
github-actions Bot added a commit that referenced this pull request Aug 11, 2026
@cixzhang
cixzhang merged commit cd0b9f6 into main Aug 11, 2026
19 checks passed
github-actions Bot added a commit that referenced this pull request Aug 11, 2026
cixzhang added a commit that referenced this pull request Aug 12, 2026
An indicator is aria-hidden by contract: the owning control supplies the
role and the accessible name, so an indicator a consumer un-hides gets
that control announced twice. All three forwarded a caller's aria-hidden,
role and aria-label straight through, so `<CheckIndicator
aria-hidden="false" />` rendered exactly that. Rubric P3 puts owned aria-*
after {...rest}, at BLOCK severity.

Pre-existing and missed by all three prior audit passes, including the two
I ran on this component today.

Two mechanisms, because one does not cover both cases:

  - Spread order on the siblings: {...rest} moves ahead of their own
    aria-hidden, which is P3's letter.
  - Stripping the three props on all three: ordering cannot help
    CheckIndicator's glyph path, because it renders Icon and Icon
    deliberately lets a caller override its a11y defaults as a documented
    escape hatch. Forwarding would hand that hatch to an element that must
    never take it. And role/aria-label are not emitted by the siblings at
    all, so ordering cannot stop those either -- my own test caught that
    after the first attempt.

Ordinary props still forward: data-*, id, handlers and styling are pinned
by a negative control in each case, so "strip a11y" cannot quietly become
"strip everything".

Also fixes the doc example, under the rubric rule this prompted (X21,
added in wiki 90431f1): Indicator.doc.mjs was the only worked example of
writing a replacement indicator, and it still taught `{children ?? mark}`
-- the exact shape #4913 removed from all three shipped components because
it deletes the state mark on a falsy child (#4893). The example now uses
isRenderable, and the bestPractice that said "render children when
present" now says what "present" means and why `??` is the trap.

Verified: 7 of the new tests go red against origin/main; 386 pass across
the subject and every consumer; core tsc, typecheck:docs and strict lint
clean; docsite generate + 364 tests green.
cixzhang added a commit that referenced this pull request Aug 12, 2026
…ff (#4918) (#4921)

* fix(indicator): the decorative contract is not a caller's to switch off

An indicator is aria-hidden by contract: the owning control supplies the
role and the accessible name, so an indicator a consumer un-hides gets
that control announced twice. All three forwarded a caller's aria-hidden,
role and aria-label straight through, so `<CheckIndicator
aria-hidden="false" />` rendered exactly that. Rubric P3 puts owned aria-*
after {...rest}, at BLOCK severity.

Pre-existing and missed by all three prior audit passes, including the two
I ran on this component today.

Two mechanisms, because one does not cover both cases:

  - Spread order on the siblings: {...rest} moves ahead of their own
    aria-hidden, which is P3's letter.
  - Stripping the three props on all three: ordering cannot help
    CheckIndicator's glyph path, because it renders Icon and Icon
    deliberately lets a caller override its a11y defaults as a documented
    escape hatch. Forwarding would hand that hatch to an element that must
    never take it. And role/aria-label are not emitted by the siblings at
    all, so ordering cannot stop those either -- my own test caught that
    after the first attempt.

Ordinary props still forward: data-*, id, handlers and styling are pinned
by a negative control in each case, so "strip a11y" cannot quietly become
"strip everything".

Also fixes the doc example, under the rubric rule this prompted (X21,
added in wiki 90431f1): Indicator.doc.mjs was the only worked example of
writing a replacement indicator, and it still taught `{children ?? mark}`
-- the exact shape #4913 removed from all three shipped components because
it deletes the state mark on a falsy child (#4893). The example now uses
isRenderable, and the bestPractice that said "render children when
present" now says what "present" means and why `??` is the trap.

Verified: 7 of the new tests go red against origin/main; 386 pass across
the subject and every consumer; core tsc, typecheck:docs and strict lint
clean; docsite generate + 364 tests green.

* refactor(indicator): enforce the decorative contract in the type, not by stripping

Replaces the runtime strip from the previous commit. Dropping a caller's
props into `_ariaHidden` and friends made a prop vanish with no signal;
a type says so at the call site.

The type only gets half of it, and the half matters:

  role         identifier -> excess-property check applies -> compile error
  aria-hidden  hyphenated -> TypeScript EXEMPTS it -> compiles, always

That is a language rule, not a gap in our types -- proven with a probe on
a bare interface with no Astryx involved. So `IndicatorProps` omits all
four (intent, and `role` actually enforced), and each component emits its
own `aria-hidden` AFTER `{...rest}`, which is what keeps a caller from
un-hiding it. That is rubric P3's literal prescription, and it is
attribute order rather than a disappearing prop.

CheckIndicator's glyph path re-asserts `aria-hidden` on the Icon call for
the same reason: Icon puts its a11y defaults BEFORE `{...props}` as a
deliberate escape hatch, which is right for an icon and wrong for an
element that is decorative by contract.

Nothing is stripped now. A forwarded `aria-label` reaches the DOM and is
inert inside an aria-hidden subtree -- forwarding aria props is fine,
which was the point.

The test that documents the TS exemption carries NO @ts-expect-error on
purpose: it compiles today, and it starts failing the day TypeScript
changes its mind, which is when the ordering can go.

Verified: 3 runtime tests + the `role` type test go red against
origin/main; 384 pass across the subject and every consumer; core tsc,
typecheck:docs and strict lint clean.
@github-actions
github-actions Bot deleted the fix-4893 branch August 13, 2026 06:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

1 participant