fix(Calendar,DropdownMenu,NavigationMenu): stop props leaking into the DOM as attributes - #545
Merged
Merged
Conversation
…root
`Calendar` forwards its props to the reka picker root through
`useForwardProps`, minus a hand-maintained `omittedProps` list. Four
button-config props were missing from it, so `reactiveOmit` kept them and Vue
rendered each as a fall-through attribute:
nextmonth="[object Object]" nextyear="[object Object]"
prevmonth="[object Object]" prevyear="[object Object]"
`viewControl` was already in the list, which is the tell: the class of bug was
known and one of five was covered. The other four are declared exactly the
same way — `Omit<ButtonProps, LinkPropsKeys>`, an object of button
configuration.
Adding them removes exactly those four attributes from eight snapshot entries
and moves nothing else: four changed lines per file, each dropping only the
leaked attribute. That was checked rather than asserted, because "nothing else
should move" is the kind of claim this repository has learned not to take on
trust.
**The existing cases were not vacuous, and now they prove it.** Before this,
`with nextYear` and its three siblings differed from a plain calendar partly
*because of the leaked attribute*. With the leak gone they still differ, and
for the right reason: each renders exactly one `ui-btn-lg` where a plain
calendar renders none, so `{ size: 'lg' }` genuinely reaches its button. The
#454 collision guard reports no new collision either.
**Nothing could have caught this**, which is the more interesting half. The
props worked, the calendar rendered correctly, and the only symptom was junk
in the markup — which the snapshot dutifully recorded as expected output. So
this adds `test/utils/stringified-props.spec.ts`: a scan of the whole snapshot
corpus for `="[object Object]"`, in the shape of the collision guard beside it
and reusing its traversal. It asserts an empty list rather than a baseline,
because the corpus held exactly these four.
Verified by restoring the leak: the guard goes red in both projects and names
the file and the attribute.
**Upstream has the identical defect** — same four props, same `omittedProps`
list without them (only `ui` vs `b24ui` differs), and the same four attributes
sitting in its own committed snapshots. Fixing it here is nonetheless free of
divergence cost: that line already differs from upstream, so it is a merge
point on every sync either way.
Not done, deliberately: the issue suggests deriving the list from the props
that are b24ui's own instead of hand-maintaining it. Measured first — 19
components declare `Omit<ButtonProps, LinkPropsKeys>` props, but only
`Calendar` pairs `omittedProps` with `useForwardProps`, so the class has
exactly one site. A derivation mechanism would be machinery for a single case.
Closes #477
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
`arrow` is a b24ui addition, not reka's -- nothing in the `DropdownMenuContent` -> `MenuContentProps` -> `PopperContentProps` chain declares it (only `arrowPadding` and `hideShiftedArrow`). Upstream keeps its arrow in `DropdownMenu.vue`; we moved it into `DropdownMenuContent` so submenus could carry one, and the `reactiveOmit` list was never updated, so `arrow` reached the content root as an attribute: `arrow="true"` for the boolean form, `arrow="[object Object]"` for the object one. The arrow element reads `props.arrow` directly, so omitting it forwards nothing away. Only the boolean form was in the corpus, because no render case passed an object. `with arrow object` adds one, and it pins the other branch of `arrowProps`: width 12 / height 8 against the 20 / 10 default. Drop `NavigationMenu`'s `with arrow` case, inherited from upstream, which still passed `arrow: true` although neither the prop nor its indicator were ported. Vue put the undeclared prop on `<nav>`, and that stray attribute was the only thing distinguishing the case from `with modelValue` -- with it removed the collision guard reports the two as identical. Correct the claim in `stringified-props.spec.ts` that `Calendar` was the only site of this bug: there were three. Record what the guard cannot see -- a leak with a scalar value is indistinguishable from an intentional attribute by any text pattern, and this one found neither `arrow` leak. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
This was referenced Sep 7, 2026
IgorShevchik
added a commit
that referenced
this pull request
Sep 9, 2026
…#561) The guard added with #545 read all 218 snapshot files inside its `it()`, so 28 MB of I/O sat under vitest's 5s per-test default. Measured: ~0.5s in plain node, 1.9s in the `nuxt` project on its own — 38% of the budget with no contention — and past 5s under a full run sharing forks with 346 other files. It timed out that way twice. The first time was reported as an unexplained single failure that three clean re-runs could not reproduce and that I would not call flaky without a name; this is the name. The scan now happens in the describe body, which is how the collision guard beside it has always done the same read. Describe-body work is not subject to the per-test timeout, so the assertions are free and the corpus is read once instead of once per assertion. No change to what the guard catches: verified by appending an `[object Object]` attribute to a snapshot, which reds it in both projects and names the file and the attribute. Worth recording that the first attempt at that mutation stayed green and the guard was briefly suspect — `printf` had escaped the quotes, so the injected text did not match a pattern that was working correctly. A mutation that fails to go red is a claim about the mutation before it is a claim about the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
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.
Linked issue
Closes #477
Type of change
revert(Scope): ...)Description
Three components were putting a prop into the DOM as a fall-through attribute. #477 named one of them; review found the second, and a sweep of the corpus found the third.
Each forwards its props to a reka root through
useForwardPropsminus a hand-maintained exclusion list, and each list was missing an entry.Calendarnextmonthnextyearprevmonthprevyear, all="[object Object]"DropdownMenuContentarrow="true", or="[object Object]"for the object formNavigationMenu(test only)arrow="true"on<nav>Calendar— #477Four button-config props were missing from
omittedProps, so Vue rendered each as junk on the picker root:viewControlwas already in the list — the tell that the class of bug was known and one of five was covered. The other four are declared identically, asOmit<ButtonProps, LinkPropsKeys>.Nothing else moved: four changed lines per snapshot file, each dropping only the leaked attribute;
[object Object]count 4 → 0.Worth checking that the existing cases were not vacuous, because before this change
with nextYearand its three siblings differed from a plain calendar partly because of the leaked attribute. They did not collapse —ui-btn-lgappears once in each of the four and zero times in the plain calendar, so{ size: 'lg' }genuinely reaches its button.Upstream has the identical defect — same four props, same list without them (only
uivsb24uidiffers), same four attributes in its own committed snapshots. Fixing it costs no divergence: that line already differs from upstream, so it is a merge point on every sync either way.DropdownMenuContent— found in reviewarrowis ours, not reka's. Nothing in theDropdownMenuContent→MenuContentProps→PopperContentPropschain declares it — onlyarrowPaddingandhideShiftedArrow. Upstream keeps its arrow inDropdownMenu.vue; we moved it down intoDropdownMenuContentso submenus could carry one, and never updated thereactiveOmitlist. So it reached the content root as an attribute. The arrow element readsprops.arrowdirectly, so omitting it forwards nothing away.Only the boolean form was in the corpus, because no render case passed an object.
with arrow objectadds one, and it pins the other branch ofarrowProps:width="12" height="8"against the20/10default.The other six components declaring
arrowbuild their forwarded props withreactivePick— a whitelist, safe by construction.ContextMenuContentusesreactiveOmitbut has noarrow.NavigationMenu— a vacuous case, removedInherited from upstream, whose
NavigationMenudoes have anarrowprop and aNavigationMenuIndicator. We did not port either (the@memoon the props interface listsarrowamong the things to remove), but the test case came across anyway, still passingarrow: true. Vue put the undeclared prop on<nav>.That stray attribute was the only thing the case asserted. Measured rather than assumed: strip it and the #454 collision guard immediately reports the snapshot as byte-identical to both
with modelValueandwith defaultValue. Removed, with a comment in its place so the next sync does not bring it back.The guard, and what it cannot see
The props all worked, every component rendered correctly, and the only symptom was junk in the markup — which the snapshots dutifully recorded as expected output, for as long as the files have existed.
New
test/utils/stringified-props.spec.tsscans the whole corpus for="[object Object]", built in the shape of the collision guard beside it and reusing its traversal so the two cannot disagree about what the corpus is. It asserts an empty list, not a baseline. Verified by restoring each leak: it goes red in both projects and names the file and the attribute.Its blind spot is stated in the file, because it is not theoretical. A leak with a scalar value —
arrow="true"— is indistinguishable from an intentional attribute by any text pattern. Botharrowleaks were in the corpus in exactly that form when the guard was written, and it saw neither; they were found by reading the components. A green run here means no object-valued leak, not no leak.Correction to this PR's earlier description
It claimed, as a measured fact, that
Calendarwas the only site of this class, and rejected the issue's suggested general fix on that basis. That was wrong — there were three.The conclusion survives the correction, for a different reason: of the components that forward props to a reka root, only these use a blacklist; the rest use
reactivePick, which cannot leak an undeclared prop. Deriving the exclusion list automatically would still be machinery, but the honest argument is "two blacklists left", not "one site".Honest note on value
#477 rates itself P3 and that is right: the props work, users see nothing, the fix is cosmetic in the DOM. What it buys is a few bytes less nonsense per server-rendered component, a snapshot corpus that no longer certifies a defect as correct output, one genuinely vacuous test case gone, and a guard that turns half of this bug class from invisible into a failing test.
Checks
pnpm lint,pnpm typecheckgreenindistinguishable-snapshotsguard green — no new collision, and the removedNavigationMenucase is not silently rejoining an existing onestringified-propsguard verified red under a restored leak, in both projectsChecklist
🤖 Generated with Claude Code
https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
Generated by Claude Code