Skip to content

Conversation

@mathpirate
Copy link
Contributor

@mathpirate mathpirate commented Oct 27, 2025

CT-1005: Conditional expressions using && operator now generate ifElse calls instead of wrapping entire expression in derive. This optimization improves performance and readability.

Before:
{list.length > 0 &&

...
}
// Became: derive(list, list => list.length > 0 &&
...
)

After:
{list.length > 0 &&

...
}
// Becomes: ifElse(derive(list, list => list.length > 0),
...
, null)

Benefits:

  • Right-hand side (typically JSX) is not wrapped in derive callback
  • Clearer separation between predicate (wrapped) and consequent (unwrapped)
  • Aligns with existing ternary operator transformation pattern

Updates binary-expression emitter to detect && operator and apply ifElse optimization when left side contains opaque refs. Includes test fixture updates to reflect new expected behavior.

🤖 Generated with Claude Code


Summary by cubic

Optimized logical && and || in JSX to use when/unless helpers instead of deriving the whole expression. This preserves JavaScript short-circuit behavior, keeps JSX out of derive, and addresses CT-1005.

  • Refactors

    • Emits when(derive(predicate), value) for && and unless(derive(condition), value) for || when the left side references opaque refs.
    • Preserves false-branch semantics for && by returning the falsy left operand; falls back to deriving the full expression when the optimization doesn’t apply.
  • New Features

    • Adds when(condition, value) and unless(condition, value) built-ins, exported in the API and implemented in the runner.
    • Updates and adds test fixtures for && and ||, including non-JSX right-hand values.

Written for commit 6857e2b. Summary will update automatically on new commits.

@linear
Copy link

linear bot commented Oct 27, 2025

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 5 files

Prompt for AI agents (all 1 issues)

Understand the root cause of the following 1 issues and fix them.


<file name="packages/ts-transformers/src/transformers/opaque-ref/emitters/binary-expression.ts">

<violation number="1" location="packages/ts-transformers/src/transformers/opaque-ref/emitters/binary-expression.ts:64">
The synthesized `ifElse` false branch returns `null`, but `a &amp;&amp; b` evaluates to the left operand when it is falsy. This changes results (e.g., `0 &amp;&amp; &lt;div/&gt;` now yields `null` instead of `0`). Please return the original left value when the predicate is false to preserve semantics.</violation>
</file>

React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.

context.factory.createToken(ts.SyntaxKind.QuestionToken),
whenTrue,
context.factory.createToken(ts.SyntaxKind.ColonToken),
context.factory.createNull(),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The synthesized ifElse false branch returns null, but a && b evaluates to the left operand when it is falsy. This changes results (e.g., 0 && <div/> now yields null instead of 0). Please return the original left value when the predicate is false to preserve semantics.

Prompt for AI agents
Address the following comment on packages/ts-transformers/src/transformers/opaque-ref/emitters/binary-expression.ts at line 64:

<comment>The synthesized `ifElse` false branch returns `null`, but `a &amp;&amp; b` evaluates to the left operand when it is falsy. This changes results (e.g., `0 &amp;&amp; &lt;div/&gt;` now yields `null` instead of `0`). Please return the original left value when the predicate is false to preserve semantics.</comment>

<file context>
@@ -6,16 +6,74 @@ import {
+        context.factory.createToken(ts.SyntaxKind.QuestionToken),
+        whenTrue,
+        context.factory.createToken(ts.SyntaxKind.ColonToken),
+        context.factory.createNull(),
+      );
+
</file context>

✅ Addressed in 27e479f

@seefeldb
Copy link
Contributor

Ah, what you could do is:

ifElse is already a wrapper around the underlying built-in which takes a 3-tuple!

So we can just add another that takes two parameters and calls the underlying ifElse factory with [a, b, a]. You could even make the || variant, although I'm not sure it's used in practice.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 10 files

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed changes from recent commits (found 1 issue).

1 issue found across 12 files

Prompt for AI agents (all 1 issues)

Understand the root cause of the following 1 issues and fix them.


<file name="packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx">

<violation number="1" location="packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx:108">
The inner derive already yields an OpaqueRef, so wrapping it in an outer derive produces an OpaqueRef&lt;OpaqueRef&gt; instead of the expected string fallback; drop the outer derive so the JSX receives a single OpaqueRef result.</violation>
</file>

React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.

Go to Charm {__ctHelpers.derive(index, index => index + 1)}
</ct-button>
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(charm, charm => charm[NAME] || "Unnamed")}</span>
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(charm, charm => __ctHelpers.unless(__ctHelpers.derive(charm, charm => charm[NAME]), "Unnamed"))}</span>
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inner derive already yields an OpaqueRef, so wrapping it in an outer derive produces an OpaqueRef instead of the expected string fallback; drop the outer derive so the JSX receives a single OpaqueRef result.

Prompt for AI agents
Address the following comment on packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx at line 108:

<comment>The inner derive already yields an OpaqueRef, so wrapping it in an outer derive produces an OpaqueRef&lt;OpaqueRef&gt; instead of the expected string fallback; drop the outer derive so the JSX receives a single OpaqueRef result.</comment>

<file context>
@@ -105,7 +105,7 @@ export default recipe(&quot;Charms Launcher&quot;, () =&gt; {
                   Go to Charm {__ctHelpers.derive(index, index =&gt; index + 1)}
                 &lt;/ct-button&gt;
-                &lt;span&gt;Charm {__ctHelpers.derive(index, index =&gt; index + 1)}: {__ctHelpers.derive(charm, charm =&gt; charm[NAME] || &quot;Unnamed&quot;)}&lt;/span&gt;
+                &lt;span&gt;Charm {__ctHelpers.derive(index, index =&gt; index + 1)}: {__ctHelpers.derive(charm, charm =&gt; __ctHelpers.unless(__ctHelpers.derive(charm, charm =&gt; charm[NAME]), &quot;Unnamed&quot;))}&lt;/span&gt;
               &lt;/li&gt;))}
           &lt;/ul&gt;)}
</file context>
Suggested change
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(charm, charm => __ctHelpers.unless(__ctHelpers.derive(charm, charm => charm[NAME]), "Unnamed"))}</span>
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.unless(__ctHelpers.derive(charm, charm => charm[NAME]), "Unnamed")}</span>
Fix with Cubic

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed changes from recent commits (found 4 issues).

4 issues found across 12 files

Prompt for AI agents (all 4 issues)

Understand the root cause of the following 4 issues and fix them.


<file name="packages/ts-transformers/test/fixtures/jsx-expressions/logical-or-unless.input.tsx">

<violation number="1" location="packages/ts-transformers/test/fixtures/jsx-expressions/logical-or-unless.input.tsx:11">
Because `!items.length` becomes `true` when the list is empty, this `||` expression resolves to the boolean `true`, so the fallback never displays in the empty state and instead shows when items exist. Replace the left operand with `items.length` (or invert via ternary) so the fallback renders only when the list is empty.</violation>
</file>

<file name="packages/ts-transformers/test/fixtures/jsx-expressions/logical-and-non-jsx.expected.tsx">

<violation number="1" location="packages/ts-transformers/test/fixtures/jsx-expressions/logical-and-non-jsx.expected.tsx:11">
Passing the dynamic string directly to when() evaluates `user.name` eagerly, so the greeting never updates when the cell changes and can even read `undefined`. Wrap the consequent in a derive (or use ifElse) so the string recomputes reactively.</violation>

<violation number="2" location="packages/ts-transformers/test/fixtures/jsx-expressions/logical-and-non-jsx.expected.tsx:14">
The numeric branch reads `user.age` eagerly before the predicate is true, so the value never updates and can access the cell while null. Keep the consequent lazy/reactive (e.g., wrap it in derive alongside the predicate) to preserve correct semantics.</violation>
</file>

<file name="packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx">

<violation number="1" location="packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx:108">
The derive callback now invokes __ctHelpers.derive on the already-resolved charm object, so we pass a plain object where an OpaqueRef is expected. This will throw or mis-wire subscriptions at runtime; please derive the property directly instead.</violation>
</file>

React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.

[UI]: (
<div>
{/* Pattern: falsy check || fallback */}
{!items.length || <span>List is empty</span>}
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because !items.length becomes true when the list is empty, this || expression resolves to the boolean true, so the fallback never displays in the empty state and instead shows when items exist. Replace the left operand with items.length (or invert via ternary) so the fallback renders only when the list is empty.

Prompt for AI agents
Address the following comment on packages/ts-transformers/test/fixtures/jsx-expressions/logical-or-unless.input.tsx at line 11:

<comment>Because `!items.length` becomes `true` when the list is empty, this `||` expression resolves to the boolean `true`, so the fallback never displays in the empty state and instead shows when items exist. Replace the left operand with `items.length` (or invert via ternary) so the fallback renders only when the list is empty.</comment>

<file context>
@@ -0,0 +1,15 @@
+    [UI]: (
+      &lt;div&gt;
+        {/* Pattern: falsy check || fallback */}
+        {!items.length || &lt;span&gt;List is empty&lt;/span&gt;}
+      &lt;/div&gt;
+    ),
</file context>

✅ Addressed in acd174a

return {
[UI]: (<div>
{/* Non-JSX right side: string template with complex expression */}
<p>{__ctHelpers.when(__ctHelpers.derive(user.name, _v1 => _v1.length > 0), `Hello, ${user.name}!`)}</p>
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing the dynamic string directly to when() evaluates user.name eagerly, so the greeting never updates when the cell changes and can even read undefined. Wrap the consequent in a derive (or use ifElse) so the string recomputes reactively.

Prompt for AI agents
Address the following comment on packages/ts-transformers/test/fixtures/jsx-expressions/logical-and-non-jsx.expected.tsx at line 11:

<comment>Passing the dynamic string directly to when() evaluates `user.name` eagerly, so the greeting never updates when the cell changes and can even read `undefined`. Wrap the consequent in a derive (or use ifElse) so the string recomputes reactively.</comment>

<file context>
@@ -0,0 +1,21 @@
+    return {
+        [UI]: (&lt;div&gt;
+        {/* Non-JSX right side: string template with complex expression */}
+        &lt;p&gt;{__ctHelpers.when(__ctHelpers.derive(user.name, _v1 =&gt; _v1.length &gt; 0), `Hello, ${user.name}!`)}&lt;/p&gt;
+
+        {/* Non-JSX right side: number expression */}
</file context>
Fix with Cubic

<p>{__ctHelpers.when(__ctHelpers.derive(user.name, _v1 => _v1.length > 0), `Hello, ${user.name}!`)}</p>

{/* Non-JSX right side: number expression */}
<p>Age: {__ctHelpers.when(__ctHelpers.derive(user.age, _v1 => _v1 > 18), user.age)}</p>
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The numeric branch reads user.age eagerly before the predicate is true, so the value never updates and can access the cell while null. Keep the consequent lazy/reactive (e.g., wrap it in derive alongside the predicate) to preserve correct semantics.

Prompt for AI agents
Address the following comment on packages/ts-transformers/test/fixtures/jsx-expressions/logical-and-non-jsx.expected.tsx at line 14:

<comment>The numeric branch reads `user.age` eagerly before the predicate is true, so the value never updates and can access the cell while null. Keep the consequent lazy/reactive (e.g., wrap it in derive alongside the predicate) to preserve correct semantics.</comment>

<file context>
@@ -0,0 +1,21 @@
+        &lt;p&gt;{__ctHelpers.when(__ctHelpers.derive(user.name, _v1 =&gt; _v1.length &gt; 0), `Hello, ${user.name}!`)}&lt;/p&gt;
+
+        {/* Non-JSX right side: number expression */}
+        &lt;p&gt;Age: {__ctHelpers.when(__ctHelpers.derive(user.age, _v1 =&gt; _v1 &gt; 18), user.age)}&lt;/p&gt;
+      &lt;/div&gt;),
+    };
</file context>
Fix with Cubic

Go to Charm {__ctHelpers.derive(index, index => index + 1)}
</ct-button>
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(charm, charm => charm[NAME] || "Unnamed")}</span>
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(charm, charm => __ctHelpers.unless(__ctHelpers.derive(charm, charm => charm[NAME]), "Unnamed"))}</span>
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The derive callback now invokes __ctHelpers.derive on the already-resolved charm object, so we pass a plain object where an OpaqueRef is expected. This will throw or mis-wire subscriptions at runtime; please derive the property directly instead.

Prompt for AI agents
Address the following comment on packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx at line 108:

<comment>The derive callback now invokes __ctHelpers.derive on the already-resolved charm object, so we pass a plain object where an OpaqueRef is expected. This will throw or mis-wire subscriptions at runtime; please derive the property directly instead.</comment>

<file context>
@@ -105,7 +105,7 @@ export default recipe(&quot;Charms Launcher&quot;, () =&gt; {
                   Go to Charm {__ctHelpers.derive(index, index =&gt; index + 1)}
                 &lt;/ct-button&gt;
-                &lt;span&gt;Charm {__ctHelpers.derive(index, index =&gt; index + 1)}: {__ctHelpers.derive(charm, charm =&gt; charm[NAME] || &quot;Unnamed&quot;)}&lt;/span&gt;
+                &lt;span&gt;Charm {__ctHelpers.derive(index, index =&gt; index + 1)}: {__ctHelpers.derive(charm, charm =&gt; __ctHelpers.unless(__ctHelpers.derive(charm, charm =&gt; charm[NAME]), &quot;Unnamed&quot;))}&lt;/span&gt;
               &lt;/li&gt;))}
           &lt;/ul&gt;)}
</file context>
Suggested change
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(charm, charm => __ctHelpers.unless(__ctHelpers.derive(charm, charm => charm[NAME]), "Unnamed"))}</span>
<span>Charm {__ctHelpers.derive(index, index => index + 1)}: {__ctHelpers.derive(charm, charm => __ctHelpers.unless(charm[NAME], "Unnamed"))}</span>
Fix with Cubic

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Copy link
Contributor

@seefeldb seefeldb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me. i'm realizing reading this that we might need to add schema information to ifElse/when/unless. not 100% let's discuss. either way not a blocker to merge.

mathpirate and others added 2 commits December 2, 2025 15:36
- Transform `condition && value` to `when(condition, value)`
- Transform `condition || fallback` to `unless(condition, fallback)`
- Add when() and unless() built-in functions to runner
- Add comprehensive test fixtures for logical operator transformations
- Add runtime unit tests for when/unless
- Add integration pattern tests for when/unless operators

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@mathpirate mathpirate force-pushed the gideon/ct-1005-ifelse-for-logical-and branch from dcbc3e3 to 6857e2b Compare December 2, 2025 23:52
@mathpirate mathpirate merged commit 03ee049 into main Dec 3, 2025
9 checks passed
@mathpirate mathpirate deleted the gideon/ct-1005-ifelse-for-logical-and branch December 3, 2025 00:18
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.

3 participants