💥 Select the <If> branch by JavaScript truthiness - #405
Conversation
The resolved condition selects a branch with `!!value` instead of being
rejected unless it is a boolean, so a document branches on the value it
already has — `<If condition={review.note}>` — without converting an
optional string first.
An expression condition is evaluated directly rather than through
`resolveExpressionProps()`, which rejects `undefined` and rewrites `NaN`
as `null`; the value selects a branch and is never journaled or forwarded
as a prop. An absent member is therefore silently falsy, while an
undeclared identifier still fails evaluation.
Closes #258
PR #405: 💥 Select the branch by JavaScript truthiness8 files, +155 / -83 Scope🟡 Changes span 6 directories. StructuralOxlint structural signals:
SlopOxlint slop signals:
Static AnalysisOxlint: 22 diagnostics across 1 file (9 rules) no-inferrable-types (6): packages/core/src/expand.ts Correctnesspackages/core/src/expand.ts, no-inferrable-types, excessive type annotations, is the type annotation necessary? |
`-0` and `0n` are falsy too, and IF14 now binds both: `0n` is what discriminates raw evaluation from the prop path, because JSON.stringify throws on a BigInt rather than rewriting it. The reason for evaluating a condition directly is general, not a list of two exceptions. A condition is decided and discarded rather than passed on or recorded, so it takes any JavaScript value.
Why
<If>rejected every condition that was not literally a boolean, so a documentthat branches on a form answer, a captured string, or a schema-provided value
had to convert it first —
condition={review.note !== ""}instead ofcondition={review.note}. Issue #258 accepts plain JavaScript truthiness withits familiar edges.
What changes
Before:
After: the resolved value selects a branch with
!!value.false,0,-0,0n,NaN,"",null, andundefinedselect the false branch; every othervalue selects the leading branch, including
"false","0",[], and{}. Themust be a booleandiagnostic no longer exists, and the missing-prop messagedrops its type claim:
<If> requires a "condition" prop.An absent member of a declared object (
review.aproved) resolves toundefinedand selects false without an error — accepted deliberately by the issue. An
undeclared root identifier still fails evaluation and is reported as before.
How it works
An expression condition calls
evaluateExpression()directly instead ofresolveExpressionProps(), and the reason is general rather than a list ofexceptions. That helper normalizes its result through JSON, which rejects
undefined, rewritesNaNasnull, and throws on aBigInt. An expressionprop must survive that trip because its value is handed to a component and
recorded; a condition is neither.
<If>takes whatever the expression evaluatesto — any JavaScript value,
Symbol, function, and class instance included —decides one branch with it, and discards it, so no serialization rule constrains
it. Prop validation and
<Else>structure validation still run before thecondition is evaluated, and one derived boolean drives both the false-arm
expansion path and the segment list that expands.
Review guide
Start with:
packages/core/tests/if.test.ts— IF14, IF15, IF16Then review:
specs/executable-mdx-spec.md— the<If>directive section and rows IF14,IF15, IF16, IF52, LOOP13
expandIf()inpackages/core/src/expand.tspackages/core/tests/loop.test.ts— LOOP13 and LOOP51README.md,site/routes/docs/control-flow.tsx,smoke-test/Guide/If.mdLook carefully at: LOOP51's replacement fixture, described under Risks.
What must stay true
selectedbooleanused for both the branch path and the expanded segments, checked by IF9–IF11,
IF31–IF34, IF39–IF48, IF51, and IF54.
unknown prop, or malformed
<Else>expands neither branch — checked by IF13,IF17, and the
<Else>tier.<If>reports its own errors exactly once and adds no observation boundary —checked by IF49–IF53.
<Loop max>keeps its strict numeric contract andjsonKind()— checked byLOOP15 and LOOP16.
How to verify it
false,0,-0,0n,NaN,"",null,undefinedand asserts exact
elseoutput with zero errors. It fails if any typerejection is kept. Verified by mutation: routing the condition back through
resolveExpressionProps()fails IF14 (onundefinedand on0n, whichJSON.stringifythrows on) and IF16, while leaving IF15 green — so the twonew cases carry discriminating weight the truthy table cannot.
true,1,"false","text",[],{}and failsunder
condition === trueor any bespoke parsing of"false". Verified bymutation: changing
!!conditiontocondition === truefails IF15.silent false, an undeclared identifier is still a positioned error.
using errors that still exist.
"x"is truthy on the second iteration:output is exactly
firstagain, no error,seenends"x".string drives
<If>directly, so the compiled-binary smoke job exercises themotivating case.
Run locally in this branch:
Results: focused suites 24 passed / 0 failed; smoke guide exit 0 with no failed
assertion; site check and build clean;
deno task lint,check, andcheck:jsrclean (Success Dry run complete);deno task test --changed=origin/main243 passed / 0 failed;git diff --checkclean.Scope
Included
guide, all in this PR so
mainnever documents strict booleans whileexecuting truthiness.
Intentionally unchanged
caller/projected environment layering.
<Else>placement and structure validation, expansion identity, projection,observation, and replay contracts.
<Loop max>strict validation andjsonKind().<Answers delegate>.Risks and limitations
diagnostic now select a branch, and a misspelled member reads as false rather
than as an error. The issue accepts this.
undefinedandNaN: aBigInt, aSymbol, a function, or a class instance reachesexpandIf()unnormalized. That follows from the required behavior and iscontained to one immediate truthiness decision — the value is never
interpolated, journaled, or forwarded, so nothing is persisted or exposed.
loop's
erroroutcome from<If condition={props.fail}><Missing /></If>, buta component import is a journaled operation, so the resume fails earlier with
DivergenceError: Divergence at root[2]: expected loop("loop:0"), got import_component("Missing")— before the terminal record is compared. Thefailing content must perform no journaled operation, so the selected branch
holds
<Each in="one" let="item">instead, whose non-arrayinis anunchanged contract independent of
<If>. The test still assertsStaleInputError, the unrewritten storedexhaustedrecord, and theDocumentationErrorcause. LOOP44 remains the control for a missing componentunder output mode.
migration.
Scope confirmation
Closes #258