Skip to content

v1.16.1 — three components cached a slot check Vue never invalidates - #25

Merged
aicodebar merged 1 commit into
mainfrom
release/v1.16.1
Aug 8, 2026
Merged

v1.16.1 — three components cached a slot check Vue never invalidates#25
aicodebar merged 1 commit into
mainfrom
release/v1.16.1

Conversation

@aicodebar

Copy link
Copy Markdown
Collaborator

A Card with no title and no description rendered no header at all when its only slot arrived after mount. So a page gating its Save button behind <template v-if="…" #actions> had no Save button — ever.

Reproduced as a story before anything was changed: the card renders, the body button's label updates (proving the child did re-render), and there is no <header> element.

The cause

The object useSlots() returns is not reactive. Vue builds it once with createInternalObject(), and updateSlots mutates it in place — so a computed over slots.* is evaluated at mount and cached forever.

The trap in the obvious fix

Switching the computed from slots to $slots would have shipped the same bug.

Vue does track $slots — there's a track(instance, 'get', '$slots') in the public instance proxy. But grep the matching trigger in @vue/runtime-core 3.5.40 and there is exactly one, inside updateSlots, behind:

if (isHmrUpdating) { trigger(instance, 'set', '$slots') }

A computed over slots is invalidated by hot module replacement and by nothing else. It recomputes while you edit the file — precisely when you'd be watching it — and never in a built app.

So the condition moves into the template and useSlots() is dropped from the component entirely, leaving no slots identifier in setup scope for a future computed to close over. This is also the house pattern: 14 other components already read $slots in their templates, so the fix deletes an outlier rather than inventing a rule.

Two alternatives rejected: mirroring slot names into a ref from onBeforeUpdate duplicates state Vue owns and is correct only while the hook stays wired, to cache a four-term boolean. Rendering <header> unconditionally is the only genuinely breaking option — every plain card gains an empty padded strip and a border.

The sweep found two more

MetricGrid — live, and visibly wrong today. fillerCount was a computed over slots.default().lengthvnodes, not tiles. Measured:

Call site Reads as Result
v-for over 3 metrics 1 (the run collapses into one Fragment) row padded with 3 fillers, drawn quarter-full
3 metrics + a v-if'd 4th 4 (a Comment placeholder counts) the filler that was needed never drawn

Both are how a real application writes a metric row. It now flattens fragments and drops comment and whitespace placeholders, in the render. Literal-children call sites are byte-identical.

DataTable — latent. hasRowClick was a computed over instance.vnode.props.onRowClick, and instance.vnode is replaced on every parent render. Same defect, different dependency. Scope is genuinely small — @row-click in an SFC compiles to a cached wrapper, so the key is always present — but probing the reachable case turned up something worth knowing: Vue's hasPropsChanged deliberately skips declared emit listeners, so binding row-click makes the listener live (the row-click fires) while the component is never asked to redraw. The affordance now self-corrects at the next render for any reason; before, never.

Not instances, but converted anyway to remove the setup-scope binding: FormActions, PageHeading, Table already read slots in their templates.

Worth recording: useAttrs() looks identical but Vue backs attrs with a real track/trigger pair that fires on every props update. That asymmetry is most of why the slots version survived this long.

Found and deliberately left, because the fix changes a signature and is not a patch: DataTable unwraps comparators.value into useSort() at setup, so a table that swaps columns after mount sorts with the old comparators.

Why this is a patch

No prop, slot, token or class added; every call site rendering correctly today renders byte-identically. The closest thing to a behaviour change is MetricGrid's dynamic grids — wrong output becoming right.

Gates

typecheck ✓ · lint ✓ (0 errors; 7 pre-existing warnings, none in touched files) · build ✓ incl. verify:externals and verify:dev-warnings · build-storybook ✓ · test:ci242 passed (237 + 5)

Each of the three fixes has a story that fails on the previous code and passes on this one, verified by reverting each component individually and re-running.

Consuming-app follow-up

flows.codebar's data-source create page currently carries a characterisation test pinning the missing Save button. Once this is tagged and the dependency bumped, two assertions flip and a sibling test's Enter-key workaround becomes a real click.

One design note for that app rather than this package: a second page (Ai/Providers/Edit.vue) has the same title-less Card with a conditional #actions, and escapes today only because its condition is fixed at mount. Safe by luck, not by design — worth deciding both together.

🤖 Generated with Claude Code

…ates

A Card with no title and no description rendered no header at all when its only
slot arrived after mount — so a page gating its Save button behind
`<template v-if="..." #actions>` had no Save button, ever. Reproduced as a story
before anything was changed: the card renders, the body button's label updates
proving the child DID re-render, and there is no <header> element.

The cause is that the object useSlots() returns is not reactive. Vue builds it
once with createInternalObject() and updateSlots mutates it in place, so a
computed over slots.* is evaluated at mount and cached forever.

THE TRAP IN THE OBVIOUS FIX. Switching the computed from `slots` to `$slots`
would have shipped the same bug. Vue does track $slots — there is a
track(instance, 'get', '$slots') in the public instance proxy. But there is
exactly one matching trigger in @vue/runtime-core 3.5.40, inside updateSlots,
behind `if (isHmrUpdating)`. A computed over slots is invalidated by hot module
replacement and by nothing else: it recomputes while you edit the file, which is
precisely when you would be watching it, and never in a built app.

So the condition moves into the template and useSlots() is dropped from the
component entirely — there is no `slots` identifier left in setup scope for a
future computed to close over. This is also the house pattern: 14 other
components already read $slots in their templates, so the fix deletes an outlier
rather than inventing a rule.

THE SWEEP FOUND TWO MORE, ONE OF THEM LIVE.

MetricGrid was visibly wrong today. fillerCount was a computed over
slots.default().length — vnodes, not tiles. Measured: a v-for collapses its whole
run into one Fragment, so three metrics read as ONE and a nearly-full row was
padded with three fillers and drawn quarter-full; a v-if leaves a Comment
placeholder, so three metrics plus a hidden fourth read as FOUR and the filler
that was needed never appeared. Both are how a real application writes a metric
row. It now flattens fragments and drops comment and whitespace placeholders, in
the render. Literal-children call sites are byte-identical.

DataTable was latent: hasRowClick was a computed over instance.vnode.props, and
instance.vnode is replaced on every parent render. Probing the reachable case
turned up something worth knowing — Vue's hasPropsChanged deliberately skips
declared emit listeners, so binding row-click makes the listener live while the
component is never asked to redraw. The affordance now self-corrects at the next
render for any reason; before, never.

Not instances, but converted anyway to remove the setup-scope binding:
FormActions, PageHeading and Table already read slots in their templates.
useAttrs() looks identical but Vue backs attrs with a real track/trigger pair
that fires on every props update — that asymmetry is most of why the slots
version survived this long.

Found and deliberately left, because the fix changes a signature and is not a
patch: DataTable unwraps comparators.value into useSort() at setup, so a table
that swaps columns after mount sorts with the old comparators.

Patch, correctly: no prop, slot, token or class added, and every call site
rendering correctly today renders byte-identically. The closest thing to a
behaviour change is MetricGrid's dynamic grids, which is wrong output becoming
right.

Gates: typecheck clean, lint 0 errors (7 pre-existing warnings, none in touched
files), build incl. verify:externals and verify:dev-warnings, build-storybook,
test:ci 242 passed. Each of the three fixes has a story that fails on the
previous code and passes on this one, verified by reverting each component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aicodebar
aicodebar merged commit 9cb550c into main Aug 8, 2026
2 checks passed
@StanBarrows
StanBarrows deleted the release/v1.16.1 branch August 8, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant