feat(kernel): add <If>/<ElseIf>/<Else> conditionals + createAnimatedIf - #143
feat(kernel): add <If>/<ElseIf>/<Else> conditionals + createAnimatedIf#143scottmessinger wants to merge 5 commits into
Conversation
Add an if/else template helper to @supergrain/kernel/react, in the
spirit of Ember's {{#if}}/{{else}}. A ternary in a tracked parent
subscribes the parent to the condition's inputs, re-rendering it on
every change. <Show> takes the condition as a function and evaluates
it behind a computed, so it subscribes only to the truthiness: input
churn re-renders nothing until the result actually flips, and only
Show swaps the branch — the parent never re-renders.
- fallback prop renders the else branch (nothing when omitted)
- Function children (value) => ... receive the type-narrowed,
non-null value and are only called while the condition holds
- Plain (non-function) when values still work, evaluated by the parent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wJGC4YWphyUQ3iWkBHqLG
Swap the Solid-style <Show when fallback> API for Ember-flavored
compound markers:
<If when={() => store.todos.length > 0}>
<TodoList />
<Else><EmptyState /></Else>
</If>
Same firewall underneath: the condition is a function evaluated behind
a computed, so If subscribes only to its truthiness — input churn
re-renders nothing until the result flips, and the parent never
subscribes. Function children still receive the type-narrowed value.
The Else scanner matches a Symbol.for marker (not component identity,
surviving module duplication) and descends through fragments and
arrays, but not through other components.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wJGC4YWphyUQ3iWkBHqLG
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #143 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 65 66 +1
Lines 2321 2388 +67
Branches 571 589 +18
=========================================
+ Hits 2321 2388 +67 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
📊 Interleaved benchmark: PR vs base (main)No benchmark moved beyond what coin-flip noise would produce. 10 interleaved pairs. Negative Δ = faster on this PR. Bold + coloured rows are the ones where a two-sided sign test clears p ≤ 0.05.
How to read thisEach arm is a complete prebuilt app bundle (kernel + app), so this measures the full difference between the two builds — app-side and kernel-side changes alike. The two builds alternate within one time window, with the order flipped each pair, so machine drift hits both arms equally and the paired delta cancels it. CI runners are shared and noisy, so absolute milliseconds here are meaningless — do not compare them against numbers from your laptop or against a previous run of this job. The paired delta and the win count are the signal. With 10 pairs a sign test needs roughly 9/10 wins to clear p ≤ 0.05, so this job is good at catching large regressions and weak at resolving effects under a few percent. A borderline result means "run it properly", not "no effect". GC aliasing. A change that removes allocations can shift when V8's scavenge lands relative to the measured window and show up as a reproducible regression on churn-heavy benchmarks with both script and paint time inflating together. That fingerprint is an artifact, not added work. To check, re-run the suspect benchmark with a forced collection before tracing: ( Measured head |
Chain conditions like Ember's {{else if}}:
<If when={() => store.status === "loading"}>
<Spinner />
<ElseIf when={() => store.status === "error"}>
<ErrorPane />
</ElseIf>
<Else><Content /></Else>
</If>
The firewall generalizes from a boolean to the active branch index: one
computed derives which branch is live, so input churn re-renders
nothing unless a different branch takes over. The computed stops at the
first truthy condition, giving real if/else-if short-circuit semantics
— later conditions aren't evaluated, or even subscribed to, while an
earlier one holds (lazy dependency tracking drops and re-adds their
subscriptions automatically as the chain re-runs).
ElseIf branches support the same function children as If, receiving
their own condition's narrowed value.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wJGC4YWphyUQ3iWkBHqLG
Codecov flagged two partial branches: ElseIf with multiple children (the Array.isArray arm of child normalization) and a plain function component as a direct If child (the non-marker arm of the marker check — tracked() children are memo objects, so they never hit it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wJGC4YWphyUQ3iWkBHqLG
Presence wrappers (e.g. Motion's AnimatePresence) detect swaps by diffing their direct children across their own re-renders — and the If firewall means the parent never re-renders on a flip, so wrapping <If> in a presence component from outside can never animate an exit. createAnimatedIf(wrap) inverts the nesting: the returned <AnimatedIf> renders the wrapper itself, and since it is exactly the component that re-renders on branch changes, the wrapper sees every swap as a keyed direct-child change. Branches are handed over in fragments keyed per branch (sg-then / sg-elseif-N / sg-else) so branch roots need no keys of their own, and the wrapper stays mounted with empty children when no branch matches so the last branch can still animate out. Module-scope factory, same API as <If> (ElseIf/Else/function children compose unchanged), zero animation-library dependency in the kernel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wJGC4YWphyUQ3iWkBHqLG
What
Adds an if/else-if/else template helper to
@supergrain/kernel/react, in the spirit of Ember's{{#if}}/{{else if}}/{{else}}, plus acreateAnimatedIffactory that runs branch swaps through a presence wrapper (e.g. Motion'sAnimatePresence) so exits animate.Why
A ternary in a tracked parent (
{store.todos.length > 0 ? <List /> : <Empty />}) subscribes the parent tolength, re-rendering it on every push and remove.<If>takes conditions as functions and evaluates them behind a computed, so it subscribes only to which branch is active: input churn (3 todos → 4) re-renders nothing; only a change of active branch swaps content, and the parent never re-renders. Same justification as<For>— subscription scoping a ternary can't replicate.createAnimatedIfexists because presence wrappers detect swaps by diffing their direct children across their own re-renders — and the firewall means the parent of a plain<If>never re-renders on a flip, so wrapping<If>in<AnimatePresence>from outside can never animate. The factory inverts the nesting: the returned component renders the wrapper itself, and since it is exactly the component that re-renders on branch changes, the wrapper sees every swap as a keyed direct-child change.API
when— the condition, preferably a thunk (() => cond). A plain value works but is evaluated by the parent, which subscribes the parent to the condition's inputs.<ElseIf when={...}>— chained branches; first truthy condition wins, in document order. Chains short-circuit like realif / else if: while an earlier condition holds, later conditions aren't evaluated — or even subscribed to (lazy dependency tracking drops and re-adds their subscriptions as the chain re-runs).<Else>— the else branch; omit it to render nothing when every condition is falsy.<If>; the scanner descends through fragments and arrays, but not through other components. Matching usesSymbol.formarkers rather than component identity, so it survives module duplication in bundles.(value) => ...— receives its branch's type-narrowed, non-null condition value and is only called while that branch is active (dodges React's eager-children footgun for nullable values). Read inIf's scope, so replacing the value while staying truthy re-renders the branch.createAnimatedIf(wrap)— module-scope factory; branches reach the wrapper in fragments keyed per branch (sg-then/sg-elseif-N/sg-else, so branch roots need no keys of their own), and the wrapper stays mounted with empty children when no branch matches so the last branch can animate out.ElseIf/Else/function children compose unchanged.Changes
packages/kernel/src/react/if-else.ts—If/ElseIf/Else+createAnimatedIf(new)packages/kernel/src/react/index.ts— exportspackages/kernel/tests/react/if-else.test.tsx— 24 browser tests: render-count assertions proving the firewall, short-circuit subscription behavior, and the AnimatedIf wrapper contract (branch keys, wrapper stays mounted, wrapper re-invoked only on branch changes)packages/kernel/README.md— API entries + "Conditionals" and "Animating between branches" sections.changeset/if-else-conditional-components.md— minor bump for@supergrain/kernelNote: the first commit on this branch shipped this as a Solid-style
<Show when fallback>; later commits replace it with the compoundIf/ElseIf/Elseform after API discussion.Checks
pnpm run coverage(full suite) — 1116 tests passed, coverage 100% statements/branches/functions/linespnpm test:react(kernel, browser) — 118 passed, incl. 24 for If/ElseIf/Else/AnimatedIfpnpm run test:validate— passedpnpm run typecheck— cleanpnpm lint— cleanpnpm format— applied🤖 Generated with Claude Code
https://claude.ai/code/session_015wJGC4YWphyUQ3iWkBHqLG