Skip to content

💥 Select the <If> branch by JavaScript truthiness - #405

Merged
taras merged 2 commits into
mainfrom
agent/issue-258-if-truthiness
Aug 9, 2026
Merged

💥 Select the <If> branch by JavaScript truthiness#405
taras merged 2 commits into
mainfrom
agent/issue-258-if-truthiness

Conversation

@taras

@taras taras commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Why

<If> rejected every condition that was not literally a boolean, so a document
that branches on a form answer, a captured string, or a schema-provided value
had to convert it first — condition={review.note !== ""} instead of
condition={review.note}. Issue #258 accepts plain JavaScript truthiness with
its familiar edges.

What changes

Before:

<If condition={review.note}>…</If>

Prop "condition" on <If /> must be a boolean, not a string.
<If> does not coerce truthy or falsy values.

After: the resolved value selects a branch with !!value. false, 0, -0,
0n, NaN, "", null, and undefined select the false branch; every other
value selects the leading branch, including "false", "0", [], and {}. The
must be a boolean diagnostic no longer exists, and the missing-prop message
drops its type claim: <If> requires a "condition" prop.

An absent member of a declared object (review.aproved) resolves to undefined
and 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

expandIf() → unknown condition → const selected = !!condition → one branch expands

An expression condition calls evaluateExpression() directly instead of
resolveExpressionProps(), and the reason is general rather than a list of
exceptions. That helper normalizes its result through JSON, which rejects
undefined, rewrites NaN as null, and throws on a BigInt. An expression
prop must survive that trip because its value is handed to a component and
recorded; a condition is neither. <If> takes whatever the expression evaluates
to — 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 the
condition 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, IF16

Then review:

  1. specs/executable-mdx-spec.md — the <If> directive section and rows IF14,
    IF15, IF16, IF52, LOOP13
  2. expandIf() in packages/core/src/expand.ts
  3. packages/core/tests/loop.test.ts — LOOP13 and LOOP51
  4. README.md, site/routes/docs/control-flow.tsx, smoke-test/Guide/If.md

Look carefully at: LOOP51's replacement fixture, described under Risks.

What must stay true

  • Only the selected branch expands — enforced by the single selected boolean
    used for both the branch path and the expanded segments, checked by IF9–IF11,
    IF31–IF34, IF39–IF48, IF51, and IF54.
  • Structural validation precedes condition evaluation, and a missing condition,
    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 and jsonKind() — checked by
    LOOP15 and LOOP16.

How to verify it

  • IF14 binds each of false, 0, -0, 0n, NaN, "", null, undefined
    and asserts exact else output with zero errors. It fails if any type
    rejection is kept. Verified by mutation: routing the condition back through
    resolveExpressionProps() fails IF14 (on undefined and on 0n, which
    JSON.stringify throws on) and IF16, while leaving IF15 green — so the two
    new cases carry discriminating weight the truthy table cannot.
  • IF15 does the same for true, 1, "false", "text", [], {} and fails
    under condition === true or any bespoke parsing of "false". Verified by
    mutation: changing !!condition to condition === true fails IF15.
  • IF16 proves both sides of the resolution boundary — a misspelled member is
    silent false, an undeclared identifier is still a positioned error.
  • IF36/IF38/IF52 keep the position, message, and single-observation properties
    using errors that still exist.
  • LOOP13 now proves the captured "x" is truthy on the second iteration:
    output is exactly firstagain, no error, seen ends "x".
  • The smoke guide gains one executable scenario in which a captured non-empty
    string drives <If> directly, so the compiled-binary smoke job exercises the
    motivating case.

Run locally in this branch:

deno task test packages/core/tests/if.test.ts packages/core/tests/loop.test.ts
deno task xmd test smoke-test/README.md \
  --component-dir smoke-test --component-dir packages/core/components --raw
cd site && deno task check && deno task build

Results: focused suites 24 passed / 0 failed; smoke guide exit 0 with no failed
assertion; site check and build clean; deno task lint, check, and
check:jsr clean (Success Dry run complete); deno task test --changed=origin/main 243 passed / 0 failed; git diff --check clean.

Scope

Included

  • JavaScript truthiness for literal and expression conditions.
  • Regression coverage for every value named by the issue.
  • Spec section, conformance rows, README, site guide, and executable smoke
    guide, all in this PR so main never documents strict booleans while
    executing truthiness.

Intentionally unchanged

  • Condition resolution: syntax, evaluation scope, binding precedence, and the
    caller/projected environment layering.
  • <Else> placement and structure validation, expansion identity, projection,
    observation, and replay contracts.
  • <Loop max> strict validation and jsonKind().
  • The unrelated strict boolean contract for <Answers delegate>.

Risks and limitations

  • The deliberate compatibility break: values that previously produced a
    diagnostic now select a branch, and a misspelled member reads as false rather
    than as an error. The issue accepts this.
  • A raw expression result can be any JavaScript value, not only undefined and
    NaN: a BigInt, a Symbol, a function, or a class instance reaches
    expandIf() unnormalized. That follows from the required behavior and is
    contained to one immediate truthiness decision — the value is never
    interpolated, journaled, or forwarded, so nothing is persisted or exposed.
  • LOOP51's fixture differs from the plan. The plan proposed deriving the
    loop's error outcome from <If condition={props.fail}><Missing /></If>, but
    a 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. The
    failing content must perform no journaled operation, so the selected branch
    holds <Each in="one" let="item"> instead, whose non-array in is an
    unchanged contract independent of <If>. The test still asserts
    StaleInputError, the unrewritten stored exhausted record, and the
    DocumentationError cause. LOOP44 remains the control for a missing component
    under output mode.
  • Recovery is a single-PR revert: no persisted state, schema, dependency, or
    migration.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

Closes #258

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
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

PR #405: 💥 Select the branch by JavaScript truthiness

8 files, +155 / -83

Scope

🟡 Changes span 6 directories.

Structural

Oxlint structural signals:

  • no-unused-vars ×4: packages/core/src/expand.ts
  • no-unnecessary-type-assertion ×1: packages/core/src/expand.ts

Slop

Oxlint slop signals:

  • no-inferrable-types ×6: packages/core/src/expand.ts

Static Analysis

Oxlint: 22 diagnostics across 1 file (9 rules)
Density: 0.142 violations/added-line

no-inferrable-types (6): packages/core/src/expand.ts
no-unused-vars (4): packages/core/src/expand.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (3): packages/core/src/expand.ts
no-shadow (1): packages/core/src/expand.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-unnecessary-type-assertion (1): packages/core/src/expand.ts
unbound-method (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

packages/core/src/expand.ts, no-inferrable-types, excessive type annotations, is the type annotation necessary?
packages/core/src/expand.ts, no-unused-vars, unused variable 'condition', is this variable required?
packages/core/src/expand.ts, no-unsafe-type-assertion, unsafe type assertion, can this be refactored?
packages/core/src/expand.ts, no-unnecessary-type-assertion, unnecessary type assertion, can this be removed?
No extraneous code patterns detected.

`-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.
@taras
taras marked this pull request as ready for review August 9, 2026 10:49
@taras
taras merged commit 0f11a5e into main Aug 9, 2026
11 checks passed
@taras
taras deleted the agent/issue-258-if-truthiness branch August 9, 2026 10:49
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.

Coerce the <If> condition with JS truthiness

1 participant