Skip to content

Share one dynamic slot among props on one CSS property - #191

Merged
codecaaron merged 2 commits into
mainfrom
wc/shared-prop-slots
Oct 10, 2026
Merged

codecaaron merged 2 commits into
mainfrom
wc/shared-prop-slots

Conversation

@codecaaron

@codecaaron codecaaron commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Props that write the same CSS property the same way, such as h and height, now share one dynamic slot: one slot variable, one slot rule per condition, and one @property registration per variable. vite-app drops from 268 slot rules to 232.

This is board ticket props-on-one-css-property-share-one-slot, lever 2 of the user's three dynamic-slot reductions. It matches rank 3 of the dynamic-values survey: share aliases after the property is resolved, but keep conditions, transforms, multiple outputs and real shorthands apart.

Contract

  • Grouping. Value props share a slot when they agree on all three:

    • the properties the slot rule writes (properties, or else property);
    • the currentVar it also sets;
    • the transform (its name, registry id and inline source).

    So p and pt never share, h and height do, and size (width and height) shares only with a prop that writes the same pair. Declaration props are unchanged.

  • Naming. A group takes the slot of its first member by name: h and height both use animus-dyn-h_ and --animus-h_. This applies to system props, and to each component's custom props, which keep their component hash.

  • Runtime metadata. Each prop keeps its own entry, with its own scale, transform, strict flag and keywords. Only varName and slotClass point at the shared slot.

  • Precedence (root's answer, from the user: option a). When one element sets two props of a group, the later-defined prop wins, in systemPropNames order. JSX order does not matter. resolveClasses already walks props in that order. The later write replaces the earlier one's variable, and the earlier class that reads that variable leaves with it: the slot class or its --keep twin, at the base and at each breakpoint. So the element never carries a --keep class that disagrees with the value.

Scope

  • The static case is unchanged here. When two literals of a group meet on one element, the winner is still decided by the class hash. That is the board's next ticket, one-elements-props-on-one-css-property-resolve-in-prop-order, which follows the same rule.
  • A static literal still beats a runtime value on the same property, as on main: animus-u-* rules sort after animus-dyn-*.
  • ja's proven-usage change decides which props get slots. This change only names and groups the slots that exist, so the two compose. I'll rebase on whichever lands first.

The change

  • extract-v2/src/dynamic_meta.rs
    • ValuePropMeta::slot_key: destination, currentVar and transform.
    • share_slots: points each prop in a group at the first member's slot.
  • extract-v2/src/analyze_css.rs calls share_slots for the system props and for each component's custom props, before the slot rules are built.
  • extract-v2/src/css.rs: build_variable_slot_entries renders a shared slot once. Register dynamic slot variables as non-inheriting #189's registrations already dedupe by variable name, so each remaining variable gets exactly one.
  • system/src/runtime/resolveClasses.ts: when a later-defined prop writes an already-written slot variable, it replaces the earlier write and removes the earlier class that read it. It needs no allocation; the check is an in test on the staged style object.

Proof (run-and-discard, not committed)

Counts (built e2e output; main 9e35d18 vs this branch). A slot is a base slot class; slot rules include each breakpoint's rule; runtime metadata is the delivered dynamic-prop config.

App Slots Slot rules Registrations per sheet Runtime metadata entries Distinct slot variables in metadata Metadata bytes
vite-app 67 → 58 268 → 232 268 → 232 67 → 67 67 → 58 9,043 → 8,957
rollup-app 67 → 58 268 → 232 268 → 232 67 → 67 67 → 58 9,967 → 9,881
next-app 67 → 58 335 → 290 335 → 290 67 → 67 67 → 58 10,284 → 10,198
next16-app 66 → 57 330 → 285 330 → 285 66 → 66 66 → 57 10,081 → 9,995
vinext-app 58 → 50 116 → 100 116 → 100 58 → 58 58 → 50 7,852 → 7,776
react-router-app 58 → 50 116 → 100 116 → 100 58 → 58 58 → 50 7,852 → 7,776
svelte-app 1 → 1 1 → 1 1 → 1 1 → 1 1 → 1 88 → 88
  • Merged groups. vite-app and next-app merge nine groups: h/height, w/width, minH/minHeight, maxH/maxHeight, minW/minWidth, maxW/maxWidth, pos/position, flexDir/flexDirection and area/gridArea.
  • Registrations. In every sheet there is exactly one registration per remaining slot variable. No registration is left for a removed variable, and none is missing.
  • Metadata. Entries stay one per prop. Only their slot names change.

Unchanged computed styles.

  • Static CSS. Outside the slot rules and their registrations, every app's built CSS is byte-identical to main: all 7 apps and all 10 sheets.
  • No runtime values in the apps. No e2e app passes a runtime value to a merged prop. So their computed styles come only from those byte-identical rules.
  • Runtime values in a browser. A scratch project uses all 18 merged props with runtime values. It was built with each engine; each build's own runtime resolved 45 prop cases, and headless Chromium read the computed styles at 1024px:
    • Identical (27 of 45): every case that sets one prop, including a responsive { _, sm } value.
    • Changed (18): exactly the cases that set both props of a pair. Main gave the longhand (height, position, gridArea, …) because its slot rule sorted later. The branch gives the later-defined alias, in either JSX order, as decided.
  • --keep probe. A scratch probe with two shared props carrying a currentVar checked the --keep handling. The winning prop's --keep or plain class replaced the other's at the base and at sm.

Runtime cost. resolveClasses was run 10⁶ times per case, with medians of 8 interleaved rounds under bun:

Case main branch
one dynamic prop 3,378 ns 3,308 ns
three dynamic props 4,355 ns 4,397 ns
a shared pair 3,726 ns 3,815 ns

These are within noise. A first version that tracked writes in a per-call Map cost +2.5% to +6%, so it was replaced.

Retained case (one)

packages/_integration/__tests__/shared-prop-slots.test.ts, "props on one property with one transform share a slot; the later-defined prop wins". It pins the contract:

  • h and height (one transform) share a slot;
  • rawH (no transform) keeps its own;
  • the CSS has 4 slot rules;
  • --animus-h_-sm is registered once;
  • { height, h } and { h, height } both resolve to h's value with one slot class.

Against main's engine:

× props on one property with one transform share a slot; the later prop wins
AssertionError: expected { varName: '--animus-height_', …(4) } to deeply equal ObjectContaining{…}
-   "slotClass": "animus-dyn-h_",
+   "slotClass": "animus-dyn-height_",

That run predates the precedence correction, which renamed the test. The assertion that failed is unchanged. With this change it passes. The neighbouring slot tests (runtime-current-var, slot-variable-registration, and the system runtime tests) pass 59/59.

Checks

vp run verify:lint exited 0 locally before each push, and CI made the first run of the other rows.

Parity refresh (shared-dynamic-slots-20261009, second commit). CI's first run failed only the parity row, as expected. Locally, three of 66 units changed, the same in both modes: extract-all, extract/system-props.tsx and integration/system-props.tsx. In each:

  • CSS and the system sheet: 42 slot rules removed, for seven merged groups at the base and five breakpoints.
  • Global sheet: the same 42 @property registrations removed.
  • Order: nothing added, and every kept rule keeps its order.
  • dynamicPropsJson: seven props (height, width, minHeight, maxHeight, minWidth, maxWidth, gridArea) point at the shared slot. Every entry stays, and no other field changes.
  • Elsewhere: code, diagnostics and maps are unchanged.

The eight drifts were registered at their exact hashes as intentional-correctness for the refresh only, and the register is empty again. After the refresh, vp run verify:parity passes: 66/66 units in both modes, seam battery 29/29.

Oracle. The oracle's bySlotClass attributes a shared slot rule to one of its props, as it already does for a utility class two props share.

I'll report the CI result to root.

Independent review: not launched (root arranges review).

🤖 Generated with Claude Code

codecaaron and others added 2 commits October 9, 2026 22:32
Props alike in the properties they write, the current variable and the
transform, such as `h` and `height`, now share one slot variable, one
slot rule per condition and one registration. Each takes the slot of the
first by name; its runtime metadata keeps its own scale and transform.
When one element sets two of them, the later-defined prop's write wins
and the earlier one's class leaves with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three units in each mode lose the slots of seven merged groups: 42 slot
rules and their 42 registrations, with nothing added and every kept rule
in its order. In dynamicPropsJson the seven merged props point at the
shared slot, and no other field changes. The seam battery is unchanged.
The checked intent names the change, and each drift was registered at
its exact hashes for the refresh only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@codecaaron
codecaaron merged commit 47adfd1 into main Oct 10, 2026
14 checks passed
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