Skip to content

feat(kernel): add <If>/<ElseIf>/<Else> conditionals + createAnimatedIf - #143

Draft
scottmessinger wants to merge 5 commits into
mainfrom
claude/supergrain-if-else-helper-12u2oa
Draft

feat(kernel): add <If>/<ElseIf>/<Else> conditionals + createAnimatedIf#143
scottmessinger wants to merge 5 commits into
mainfrom
claude/supergrain-if-else-helper-12u2oa

Conversation

@scottmessinger

@scottmessinger scottmessinger commented Aug 17, 2026

Copy link
Copy Markdown
Member

What

Adds an if/else-if/else template helper to @supergrain/kernel/react, in the spirit of Ember's {{#if}}/{{else if}}/{{else}}, plus a createAnimatedIf factory that runs branch swaps through a presence wrapper (e.g. Motion's AnimatePresence) so exits animate.

<If when={() => store.status === "loading"}>
  <Spinner />
  <ElseIf when={() => store.status === "error"}>
    <ErrorPane />
  </ElseIf>
  <Else>
    <Content />
  </Else>
</If>
// once at module scope — kernel has no animation-library dependency
export const AnimatedIf = createAnimatedIf((children) => (
  <AnimatePresence mode="wait">{children}</AnimatePresence>
));

// anywhere — same API as <If>, branches animate in and out
<AnimatedIf when={() => store.open}>
  <motion.div exit={{ opacity: 0 }}>panel</motion.div>
  <Else><motion.div exit={{ opacity: 0 }}>teaser</motion.div></Else>
</AnimatedIf>

Why

A ternary in a tracked parent ({store.todos.length > 0 ? <List /> : <Empty />}) subscribes the parent to length, 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.

createAnimatedIf exists 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 real if / 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.
  • Markers must sit directly under <If>; the scanner descends through fragments and arrays, but not through other components. Matching uses Symbol.for markers rather than component identity, so it survives module duplication in bundles.
  • Function child (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 in If'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.tsIf/ElseIf/Else + createAnimatedIf (new)
  • packages/kernel/src/react/index.ts — exports
  • packages/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/kernel

Note: the first commit on this branch shipped this as a Solid-style <Show when fallback>; later commits replace it with the compound If/ElseIf/Else form after API discussion.

Checks

  • pnpm run coverage (full suite) — 1116 tests passed, coverage 100% statements/branches/functions/lines
  • pnpm test:react (kernel, browser) — 118 passed, incl. 24 for If/ElseIf/Else/AnimatedIf
  • pnpm run test:validate — passed
  • pnpm run typecheck — clean
  • pnpm lint — clean
  • pnpm format — applied

🤖 Generated with Claude Code

https://claude.ai/code/session_015wJGC4YWphyUQ3iWkBHqLG

claude added 2 commits August 17, 2026 22:19
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

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (b3b0454) to head (a02a5c3).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

📊 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.

Benchmark base (main) PR Δ median pairs improved sign test p
create rows (1k) 61.7 ms 61.6 ms -0.1% 5/10 1.000
replace all rows 77.9 ms 78.0 ms +0.7% 4/10 0.754
partial update (10th) 48.3 ms 49.1 ms +3.2% 4/10 0.754
select row 12.7 ms 12.5 ms +1.8% 4/10 0.754
swap rows 45.2 ms 47.3 ms +3.7% 3/10 0.344
remove row 37.9 ms 38.8 ms +1.3% 2/10 0.109
create many rows (10k) 731.2 ms 728.9 ms -0.1% 5/10 1.000
append rows (1k to 1k) 76.0 ms 77.3 ms +1.4% 3/10 0.344
clear rows 41.3 ms 40.8 ms -1.9% 8/10 0.109
Weighted total 609.1 ms 608.9 ms +0.3% 4/10 0.754
How to read this

Each 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:

PROFILE=1 npx vitest run --config vitest.dist.config.ts src/perf.test.ts -t "create rows"

(-t is a regex — "create rows (1k)" matches nothing.) If the regression disappears, it was aliasing. See OPTIMIZATION-AGENT.md.


Measured head a02a5c3 vs base main — updated 2026-08-17 23:22 UTC — run

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
@scottmessinger scottmessinger changed the title feat(kernel): add <If>/<Else> firewalled conditional components feat(kernel): add <If>/<ElseIf>/<Else> firewalled conditional components Aug 17, 2026
claude added 2 commits August 17, 2026 22:49
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
@scottmessinger scottmessinger changed the title feat(kernel): add <If>/<ElseIf>/<Else> firewalled conditional components feat(kernel): add <If>/<ElseIf>/<Else> conditionals + createAnimatedIf Aug 17, 2026
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.

2 participants