Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ executable.md treats the root document like a component:

## Control flow

`<If>` expands one branch and only one. `condition` must be a boolean β€” there is no truthy or falsy coercion β€” and the branch that is not selected never expands, so nothing in it imports a component, runs a block, or creates a binding.
`<If>` expands one branch and only one. `condition` selects by ordinary JavaScript truthiness β€” `false`, `0`, `-0`, `0n`, `NaN`, `""`, `null`, and `undefined` take the false branch, and everything else takes the true one, including `"false"`, `[]`, and `{}` β€” and the branch that is not selected never expands, so nothing in it imports a component, runs a block, or creates a binding.

```md
<If condition={hasFailures}>
Expand Down
44 changes: 19 additions & 25 deletions packages/core/src/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,11 +1360,12 @@ const IF_PROPS = new Set(["condition"]);
* environment, so a `<Capture>` it creates behaves like inline content and
* stays available after `</If>`.
*
* It is not an observation boundary either. Errors it creates itself β€” an
* invalid condition, an unknown prop, a malformed `<Else>` β€” are reported here,
* exactly once. Everything the selected branch returns was already reported
* where it was produced and is handed back untouched, so a `<Broken />` inside
* a selected branch settles once, exactly as it would inline.
* It is not an observation boundary either. Errors it creates itself β€” a
* missing condition, a condition expression that fails to evaluate, an unknown
* prop, a malformed `<Else>` β€” are reported here, exactly once. Everything the
* selected branch returns was already reported where it was produced and is
* handed back untouched, so a `<Broken />` inside a selected branch settles
* once, exactly as it would inline.
*/
function* expandIf(
segment: ComponentElement,
Expand Down Expand Up @@ -1396,46 +1397,39 @@ function* expandIf(
return;
}

let condition: Json;
let condition: unknown;
if ("condition" in segment.props) {
condition = segment.props.condition;
} else if ("condition" in segment.expressions) {
try {
const resolved = yield* resolveExpressionProps(
{},
{ condition: segment.expressions.condition },
// Evaluated directly rather than through resolveExpressionProps: that
// helper normalizes its result through JSON, which rejects `undefined`,
// rewrites `NaN` as `null`, and throws on a BigInt. A condition is
// decided and discarded rather than passed on or recorded, so it takes
// any JavaScript value and crosses no serialization boundary.
condition = yield* evaluateExpression(
segment.expressions.condition,
"If",
"condition",
segment.projectedEnv,
);
condition = resolved.condition;
} catch (error) {
owner.push(
yield* raise(ifError(segment, error instanceof Error ? error.message : String(error))),
);
return;
}
} else {
owner.push(yield* raise(ifError(segment, '<If> requires a "condition" prop (a boolean).')));
owner.push(yield* raise(ifError(segment, '<If> requires a "condition" prop.')));
return;
}

if (typeof condition !== "boolean") {
owner.push(
yield* raise(
ifError(
segment,
`Prop "condition" on <If /> must be a boolean, not ${jsonKind(condition)}. ` +
"<If> does not coerce truthy or falsy values.",
),
),
);
return;
}
const selected = !!condition;

// The false arm belongs to `<Else>`, which is consumed above, so its frame is
// added here β€” otherwise both arms of one `<If>` expand under one path.
const branchPath =
condition || structure.elseElement === undefined
selected || structure.elseElement === undefined
? path
: extendPath(
path,
Expand All @@ -1446,7 +1440,7 @@ function* expandIf(
);

yield* expandSegments(
condition ? structure.whenTrue : structure.whenFalse,
selected ? structure.whenTrue : structure.whenFalse,
parentMeta,
parentProps,
hideSet,
Expand Down
77 changes: 52 additions & 25 deletions packages/core/tests/if.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,32 +175,59 @@ describe("Tier IF β€” condition validation", () => {
expect(run.output).not.toContain("body");
});

it("IF14: a non-boolean condition is rejected without coercion", function* () {
const cases: Array<[string, string]> = [
['<If condition="yes">x</If>', "a string"],
["<If condition={1}>x</If>", "a number"],
["<If condition={0}>x</If>", "a number"],
["<If condition={null}>x</If>", "null"],
["<If condition={[1]}>x</If>", "an array"],
["<If condition={{a: 1}}>x</If>", "an object"],
it("IF14: every falsy condition selects the <Else> branch", function* () {
const cases: Array<[string, unknown]> = [
["false", false],
["0", 0],
["-0", -0],
["0n", 0n],
["NaN", NaN],
['""', ""],
["null", null],
["undefined", undefined],
];
for (const [source, kind] of cases) {
const run = yield* runIf(source);
const message = errorMessages(run.segments)[0] ?? "";
expect(message).toContain("must be a boolean");
expect(message).toContain(kind);
expect(run.output).not.toContain("x");
const selected: string[] = [];
for (const [label, value] of cases) {
const run = yield* runIf("<If condition={condition}>then<Else>else</Else></If>", {
env: { condition: value },
});
selected.push(`${label}: ${run.output}`);
expect(errorMessages(run.segments)).toHaveLength(0);
}
expect(selected).toEqual(cases.map(([label]) => `${label}: else`));
});

it("IF15: a non-boolean expression result is rejected", function* () {
const run = yield* runIf("<If condition={count}>x</If>", { env: { count: 3 } });
expect(errorMessages(run.segments)[0]).toContain("must be a boolean, not a number");
it("IF15: every truthy condition selects the leading branch", function* () {
const cases: Array<[string, unknown]> = [
["true", true],
["1", 1],
['"false"', "false"],
['"text"', "text"],
["[]", []],
["{}", {}],
];
const selected: string[] = [];
for (const [label, value] of cases) {
const run = yield* runIf("<If condition={condition}>then<Else>else</Else></If>", {
env: { condition: value },
});
selected.push(`${label}: ${run.output}`);
expect(errorMessages(run.segments)).toHaveLength(0);
}
expect(selected).toEqual(cases.map(([label]) => `${label}: then`));
});

it("IF16: an unresolvable condition expression is rejected", function* () {
const run = yield* runIf("<If condition={missing}>x</If>");
expect(errorMessages(run.segments)[0]).toContain("condition={missing}");
it("IF16: an absent member is falsy, an undeclared identifier is rejected", function* () {
const misspelled = yield* runIf("<If condition={review.aproved}>yes<Else>no</Else></If>", {
env: { review: { approved: true } },
});
expect(misspelled.output).toBe("no");
expect(errorMessages(misspelled.segments)).toHaveLength(0);

const undeclared = yield* runIf("<If condition={missing}>then<Else>else</Else></If>");
expect(errorMessages(undeclared.segments)[0]).toContain("condition={missing}");
expect(undeclared.output).not.toContain("then");
expect(undeclared.output).not.toContain("else");
});

it("IF17: unknown props are rejected", function* () {
Expand Down Expand Up @@ -383,7 +410,7 @@ describe("Tier IF β€” printed errors carry source positions", () => {
});

it("IF36: an origin adds the file path", function* () {
const run = yield* runIf("\n<If condition={1}>body</If>", {
const run = yield* runIf("\n<If>body</If>", {
origin: { path: "Doc.md", baseOffset: 40, baseLine: 5 },
});
expect(errorMessages(run.segments)[0]).toContain("(Doc.md:6:1)");
Expand All @@ -410,7 +437,7 @@ describe("Tier IF β€” printed errors carry source positions", () => {
return yield* expandSegments([element], {}, {}, new Set());
});
const message = errorMessages(segments)[0] ?? "";
expect(message).toBe('<If> requires a "condition" prop (a boolean).');
expect(message).toBe('<If> requires a "condition" prop.');
});
});

Expand Down Expand Up @@ -773,9 +800,9 @@ describe("Tier IF β€” error observation", () => {
expect(missing.observed).toHaveLength(1);
expect(missing.observed[0]).toContain('requires a "condition" prop');

const nonBoolean = yield* runRaiseProbe("<If condition={1}>body</If>");
expect(nonBoolean.observed).toHaveLength(1);
expect(nonBoolean.observed[0]).toContain("must be a boolean");
const unresolvable = yield* runRaiseProbe("<If condition={absent}>body</If>");
expect(unresolvable.observed).toHaveLength(1);
expect(unresolvable.observed[0]).toContain("condition={absent}");

const structure = yield* runRaiseProbe('<If condition={true}>a<Else when="x">b</Else></If>');
expect(structure.observed).toHaveLength(1);
Expand Down
27 changes: 16 additions & 11 deletions packages/core/tests/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,11 @@ describe("Tier LOOP β€” bindings", () => {
'<Loop max={2}><If condition={seen}>again<Else>first</Else></If><Capture as="seen">x</Capture></Loop>',
{ env: { seen: false } },
);
// The capture rebinds `seen` to a non-boolean, so the second iteration's
// condition is rejected rather than coerced β€” bindings really do carry.
expect(run.output).toContain("first");
expect(errorMessages(run.segments)[0]).toContain("must be a boolean, not a string");
// The capture rebinds `seen` to a non-empty string, which the second
// iteration reads as truthy β€” bindings really do carry.
expect(run.output).toBe("firstagain");
expect(errorMessages(run.segments)).toHaveLength(0);
expect(run.env?.seen).toBe("x");
});
});

Expand Down Expand Up @@ -1255,26 +1256,30 @@ describe("Tier LOOP β€” replay validates the terminal record", () => {
"props:",
" type: object",
" properties:",
" condition: {}",
" required: [condition]",
" fail:",
" type: boolean",
" required: [fail]",
" additionalProperties: false",
"---",
"",
"<Loop max={1}>",
"<If condition={props.condition}>step</If>",
'<If condition={props.fail}><Each in="one" let="item">{item}</Each></If>',
"</Loop>",
"",
"<Output>",
"done",
"</Output>",
].join("\n");

const cut = yield* completeThenCut(FAILING, { condition: true });
const cut = yield* completeThenCut(FAILING, { fail: false });
expect(outcomeRecords(cut).map((entry) => entry.outcome)).toEqual(["exhausted"]);

// A non-boolean condition makes the body fail under the documentation
// error mode, so this run derives `error` where the journal holds `exhausted`.
const replayed = yield* resume(FAILING, cut, { condition: 1 });
// The branch this run selects holds an <Each> whose `in` is not an array,
// so the body fails under the documentation error mode and this run derives
// `error` where the journal holds `exhausted`. The failing content performs
// no journaled operation, so the two runs disagree about the terminal
// record and about nothing before it.
const replayed = yield* resume(FAILING, cut, { fail: true });

expect(replayed.failure).toBeInstanceOf(StaleInputError);
expect(outcomeRecords(replayed.events).map((entry) => entry.outcome)).toEqual(["exhausted"]);
Expand Down
1 change: 1 addition & 0 deletions packages/testing/tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const EMBEDDED_TESTS = [
"If selects the leading branch when the condition is true",
"If resolves its condition from an existing binding",
"If resolves a computed boolean expression",
"If branches on a captured string without converting it first",
"Content around the selected branch keeps its order",
"A capture from the selected branch stays available afterward",
"The unselected branch creates no binding",
Expand Down
21 changes: 18 additions & 3 deletions site/routes/docs/control-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,27 @@ export default define.page(function ControlFlow() {
<h2>Choosing a branch</h2>
<p>
<code>condition</code>{" "}
is the only prop, and it must be a boolean β€” there is no truthy or falsy
coercion, so a string, number, array, or <code>null</code>{" "}
is an error rather than a branch. The optional <code>&lt;Else&gt;</code>
is the only prop, and the value it resolves to selects a branch by
ordinary JavaScript truthiness. <code>false</code>, <code>0</code>,{" "}
<code>-0</code>, <code>0n</code>, <code>NaN</code>, <code>""</code>,
{" "}
<code>null</code>, and <code>undefined</code>{" "}
take the false branch; everything else takes the true one β€” including
{" "}
<code>"false"</code>, <code>"0"</code>, <code>[]</code>, and{" "}
<code>{"{}"}</code>, which are JavaScript's familiar edges rather than a
rule <code>&lt;If&gt;</code>{" "}
invents. So a document can branch on the value it already has, as in
{" "}
<code>{"<If condition={review.note}>"}</code>. The optional{" "}
<code>&lt;Else&gt;</code>{" "}
block holds the alternative and is written once, as a direct child.
</p>
<p>
A misspelled member resolves to <code>undefined</code>{" "}
and quietly takes the false branch; an undeclared identifier is still an
error.
</p>
<CodeBlock>{SIMPLE}</CodeBlock>

<p>
Expand Down
15 changes: 12 additions & 3 deletions smoke-test/Guide/If.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<Section title="Conditionals">

`<If>` chooses one branch of a document and expands only that branch. The
`condition` prop is a boolean β€” there is no truthy or falsy coercion β€” and an
optional `<Else>` block, written once as a direct child, holds the alternative.
Without `<Else>` a false condition renders nothing.
`condition` prop selects by ordinary JavaScript truthiness β€” `false`, `0`, `-0`,
`0n`, `NaN`, `""`, `null`, and `undefined` take the false branch, everything
else takes the true one, including `"false"`, `[]`, and `{}` β€” so a document
branches on the value it already has. An optional `<Else>` block, written once
as a direct child, holds the alternative; without `<Else>` a falsy condition
renders nothing.

The unselected branch is not hidden output: it never expands, so nothing in it
imports a component, runs a code block, reaches a provider, or creates a
Expand Down Expand Up @@ -44,6 +47,12 @@ selected branch behaves like inline content and stays available after `</If>`.
<AssertEquals actual={failingReport} expected={"Needs revision: 2 findings"} />
</Test>

<Test name="If branches on a captured string without converting it first">
<Capture as="note">needs a second look</Capture>
<Capture as="noteReport"><If condition={note}>Note: {note}<Else>No note</Else></If></Capture>
<AssertEquals actual={noteReport} expected={"Note: needs a second look"} />
</Test>

<Test name="Content around the selected branch keeps its order">
<Capture as="ifOrder">before|<If condition={true}>mid<Else>alt</Else></If>|after</Capture>
<AssertEquals actual={ifOrder} expected={"before|mid|after"} />
Expand Down
Loading
Loading