v1.16.1 — three components cached a slot check Vue never invalidates - #25
Merged
Conversation
…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>
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.
A
Cardwith notitleand nodescriptionrendered 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 withcreateInternalObject(), andupdateSlotsmutates it in place — so acomputedoverslots.*is evaluated at mount and cached forever.The trap in the obvious fix
Switching the computed from
slotsto$slotswould have shipped the same bug.Vue does track
$slots— there's atrack(instance, 'get', '$slots')in the public instance proxy. But grep the matching trigger in@vue/runtime-core3.5.40 and there is exactly one, insideupdateSlots, behind: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 noslotsidentifier in setup scope for a futurecomputedto close over. This is also the house pattern: 14 other components already read$slotsin their templates, so the fix deletes an outlier rather than inventing a rule.Two alternatives rejected: mirroring slot names into a
reffromonBeforeUpdateduplicates 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.fillerCountwas a computed overslots.default().length— vnodes, not tiles. Measured:v-forover 3 metricsv-if'd 4thBoth 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.hasRowClickwas a computed overinstance.vnode.props.onRowClick, andinstance.vnodeis replaced on every parent render. Same defect, different dependency. Scope is genuinely small —@row-clickin an SFC compiles to a cached wrapper, so the key is always present — but probing the reachable case turned up something worth knowing: Vue'shasPropsChangeddeliberately skips declared emit listeners, so bindingrow-clickmakes 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,Tablealready read slots in their templates.Worth recording:
useAttrs()looks identical but Vue backs attrs with a realtrack/triggerpair 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:
DataTableunwrapscomparators.valueintouseSort()at setup, so a table that swapscolumnsafter 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:externalsandverify:dev-warnings·build-storybook✓ ·test:ci✓ 242 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'sEnter-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