From cdd65b68023b01c3ddbf151e8a96237b3add623f Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Fri, 10 Apr 2026 11:36:06 -0400
Subject: [PATCH] RFC#562 - {{and}}, {{or}}, {{not}} as keywords
Add boolean logic helpers and register them as built-in keywords
so they no longer need to be imported in strict-mode (gjs/gts) templates.
---
packages/@ember/helper/index.ts | 74 ++++++++
.../template-compiler/lib/compile-options.ts | 5 +-
.../lib/plugins/auto-import-builtins.ts | 39 +++++
.../test/keywords/and-runtime-test.ts | 65 +++++++
.../test/keywords/and-test.ts | 147 ++++++++++++++++
.../test/keywords/not-runtime-test.ts | 63 +++++++
.../test/keywords/not-test.ts | 95 +++++++++++
.../test/keywords/or-runtime-test.ts | 65 +++++++
.../test/keywords/or-test.ts | 160 ++++++++++++++++++
packages/@glimmer/runtime/index.ts | 3 +
packages/@glimmer/runtime/lib/helpers/and.ts | 26 +++
packages/@glimmer/runtime/lib/helpers/not.ts | 10 ++
packages/@glimmer/runtime/lib/helpers/or.ts | 26 +++
13 files changed, 777 insertions(+), 1 deletion(-)
create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts
create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts
create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts
create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts
create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts
create mode 100644 packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts
create mode 100644 packages/@glimmer/runtime/lib/helpers/and.ts
create mode 100644 packages/@glimmer/runtime/lib/helpers/not.ts
create mode 100644 packages/@glimmer/runtime/lib/helpers/or.ts
diff --git a/packages/@ember/helper/index.ts b/packages/@ember/helper/index.ts
index 7037d128441..707331a2871 100644
--- a/packages/@ember/helper/index.ts
+++ b/packages/@ember/helper/index.ts
@@ -16,6 +16,9 @@ import {
gte as glimmerGte,
lt as glimmerLt,
lte as glimmerLte,
+ and as glimmerAnd,
+ or as glimmerOr,
+ not as glimmerNot,
} from '@glimmer/runtime';
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
import { type Opaque } from '@ember/-internals/utility-types';
@@ -665,4 +668,75 @@ export interface EqHelper extends Opaque<'helper:eq'> {}
export const neq = glimmerNeq as unknown as NeqHelper;
export interface NeqHelper extends Opaque<'helper:neq'> {}
+/**
+ * The `{{and}}` helper evaluates arguments left to right, returning the first
+ * falsy value (using Handlebars truthiness) or the right-most value if all
+ * are truthy. Requires at least two arguments.
+ *
+ * ```js
+ * import { and } from '@ember/helper';
+ *
+ *
+ * {{if (and @isAdmin @isLoggedIn) "Welcome, admin!" "Access denied"}}
+ *
+ * ```
+ *
+ * In strict-mode (gjs/gts) templates, `and` is available as a keyword and
+ * does not need to be imported.
+ *
+ * @method and
+ * @param {unknown} args Two or more values to evaluate
+ * @return {unknown} The first falsy value or the last value
+ * @private
+ */
+export const and = glimmerAnd as unknown as AndHelper;
+export interface AndHelper extends Opaque<'helper:and'> {}
+
+/**
+ * The `{{or}}` helper evaluates arguments left to right, returning the first
+ * truthy value (using Handlebars truthiness) or the right-most value if all
+ * are falsy. Requires at least two arguments.
+ *
+ * ```js
+ * import { or } from '@ember/helper';
+ *
+ *
+ * {{if (or @hasAccess @isAdmin) "Welcome!" "No access"}}
+ *
+ * ```
+ *
+ * In strict-mode (gjs/gts) templates, `or` is available as a keyword and
+ * does not need to be imported.
+ *
+ * @method or
+ * @param {unknown} args Two or more values to evaluate
+ * @return {unknown} The first truthy value or the last value
+ * @private
+ */
+export const or = glimmerOr as unknown as OrHelper;
+export interface OrHelper extends Opaque<'helper:or'> {}
+
+/**
+ * The `{{not}}` helper returns the logical negation of its argument using
+ * Handlebars truthiness. Takes exactly one argument.
+ *
+ * ```js
+ * import { not } from '@ember/helper';
+ *
+ *
+ * {{if (not @isDisabled) "Enabled" "Disabled"}}
+ *
+ * ```
+ *
+ * In strict-mode (gjs/gts) templates, `not` is available as a keyword and
+ * does not need to be imported.
+ *
+ * @method not
+ * @param {unknown} value The value to negate
+ * @return {boolean}
+ * @private
+ */
+export const not = glimmerNot as unknown as NotHelper;
+export interface NotHelper extends Opaque<'helper:not'> {}
+
/* eslint-enable @typescript-eslint/no-empty-object-type */
diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts
index 7b9cc74ac31..8aec6a18406 100644
--- a/packages/@ember/template-compiler/lib/compile-options.ts
+++ b/packages/@ember/template-compiler/lib/compile-options.ts
@@ -1,4 +1,4 @@
-import { array, element, eq, fn, hash, neq, lt, lte, gt, gte } from '@ember/helper';
+import { and, array, element, eq, fn, hash, neq, not, lt, lte, gt, gte, or } from '@ember/helper';
import { on } from '@ember/modifier';
import { assert } from '@ember/debug';
import {
@@ -28,6 +28,7 @@ export const keywords: Record = {
array,
eq,
element,
+ and,
fn,
hash,
neq,
@@ -35,7 +36,9 @@ export const keywords: Record = {
gte,
lt,
lte,
+ not,
on,
+ or,
};
function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {
diff --git a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts
index ccdcff5ecf3..c127248e1e5 100644
--- a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts
+++ b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts
@@ -57,6 +57,15 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
if (isLte(node, hasLocal)) {
rewriteKeyword(env, node, 'lte', '@ember/helper');
}
+ if (isAnd(node, hasLocal)) {
+ rewriteKeyword(env, node, 'and', '@ember/helper');
+ }
+ if (isOr(node, hasLocal)) {
+ rewriteKeyword(env, node, 'or', '@ember/helper');
+ }
+ if (isNot(node, hasLocal)) {
+ rewriteKeyword(env, node, 'not', '@ember/helper');
+ }
},
MustacheStatement(node: AST.MustacheStatement) {
if (isArray(node, hasLocal)) {
@@ -89,6 +98,15 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
if (isLte(node, hasLocal)) {
rewriteKeyword(env, node, 'lte', '@ember/helper');
}
+ if (isAnd(node, hasLocal)) {
+ rewriteKeyword(env, node, 'and', '@ember/helper');
+ }
+ if (isOr(node, hasLocal)) {
+ rewriteKeyword(env, node, 'or', '@ember/helper');
+ }
+ if (isNot(node, hasLocal)) {
+ rewriteKeyword(env, node, 'not', '@ember/helper');
+ }
},
},
};
@@ -185,3 +203,24 @@ function isElement(
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
return isPath(node.path) && node.path.original === 'element' && !hasLocal('element');
}
+
+function isAnd(
+ node: AST.MustacheStatement | AST.SubExpression,
+ hasLocal: (k: string) => boolean
+): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
+ return isPath(node.path) && node.path.original === 'and' && !hasLocal('and');
+}
+
+function isOr(
+ node: AST.MustacheStatement | AST.SubExpression,
+ hasLocal: (k: string) => boolean
+): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
+ return isPath(node.path) && node.path.original === 'or' && !hasLocal('or');
+}
+
+function isNot(
+ node: AST.MustacheStatement | AST.SubExpression,
+ hasLocal: (k: string) => boolean
+): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
+ return isPath(node.path) && node.path.original === 'not' && !hasLocal('not');
+}
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts
new file mode 100644
index 00000000000..d2b13ac0a51
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts
@@ -0,0 +1,65 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordAndRuntime extends RenderTest {
+ static suiteName = 'keyword helper: and (runtime)';
+
+ @test
+ 'explicit scope'() {
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: true, b: true }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'explicit scope (shadowed)'() {
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ and: () => false, a: true, b: true }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'implicit scope (eval)'() {
+ let a = true;
+ let b = 'hello';
+
+ hide(a);
+ hide(b);
+
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns falsy when one arg is falsy'() {
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: true, b: 0 }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+}
+
+jitSuite(KeywordAndRuntime);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts
new file mode 100644
index 00000000000..476a3cbf040
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts
@@ -0,0 +1,147 @@
+import { DEBUG } from '@glimmer/env';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler';
+
+class KeywordAnd extends RenderTest {
+ static suiteName = 'keyword helper: and';
+
+ @test
+ 'references are lazy'(assert: Assert) {
+ const obj = {
+ get a() {
+ assert.step('a');
+ return 1;
+ },
+ get b() {
+ assert.step('b');
+ return 89;
+ },
+ get c() {
+ assert.step('c');
+ return false;
+ },
+ get d() {
+ assert.step('d');
+ return 'unexpected!!!';
+ },
+ };
+
+ const compiled = template('{{and obj.a obj.b obj.c obj.d}}', {
+ strictMode: true,
+ scope: () => ({ obj }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('false');
+ assert.verifySteps(
+ ['a', 'b', 'c'],
+ 'd not evaluated because obj.c was the last to be evaluated and short-circuited'
+ );
+ }
+
+ @test
+ 'explicit scope'() {
+ let a = 'yes';
+ let b = 'second';
+
+ const compiled = template('{{and a b}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('second');
+ }
+
+ @test
+ 'explicit scope (shadowed)'() {
+ let a = 'yes';
+ let b = true;
+ let and = () => 'surprise';
+ const compiled = template('{{and a b}}', {
+ strictMode: true,
+ scope: () => ({ and, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('surprise');
+ }
+
+ @test
+ 'implicit scope (eval)'() {
+ let a = true;
+ let b = 'hello';
+
+ hide(a);
+ hide(b);
+
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns first falsy value'() {
+ let a = 0;
+ let b = 'hello';
+ const compiled = template('{{and a b}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('0');
+ }
+
+ @test
+ 'works as a SubExpression with if'() {
+ let a = true;
+ let b = true;
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'treats empty array as falsy'() {
+ let a = true;
+ let b: unknown[] = [];
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test({ skip: !DEBUG })
+ 'throws if called with less than two arguments'(assert: Assert) {
+ let a = true;
+ const compiled = template('{{and a}}', {
+ strictMode: true,
+ scope: () => ({ a }),
+ });
+
+ assert.throws(() => {
+ this.renderComponent(compiled);
+ }, /`and` expects at least two arguments/);
+ }
+}
+
+jitSuite(KeywordAnd);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts
new file mode 100644
index 00000000000..511a94df5ee
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts
@@ -0,0 +1,63 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordNotRuntime extends RenderTest {
+ static suiteName = 'keyword helper: not (runtime)';
+
+ @test
+ 'explicit scope'() {
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: false }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'explicit scope (shadowed)'() {
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ not: () => false, a: false }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'implicit scope (eval)'() {
+ let a = false;
+
+ hide(a);
+
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns no for truthy'() {
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: 'hello' }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+}
+
+jitSuite(KeywordNotRuntime);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts
new file mode 100644
index 00000000000..dfa8dec6e1a
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts
@@ -0,0 +1,95 @@
+import { DEBUG } from '@glimmer/env';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler';
+
+class KeywordNot extends RenderTest {
+ static suiteName = 'keyword helper: not';
+
+ @test
+ 'explicit scope'() {
+ let a = false;
+
+ const compiled = template('{{not a}}', {
+ strictMode: true,
+ scope: () => ({ a }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('true');
+ }
+
+ @test
+ 'explicit scope (shadowed)'() {
+ let a = false;
+ let not = () => 'surprise';
+ const compiled = template('{{not a}}', {
+ strictMode: true,
+ scope: () => ({ not, a }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('surprise');
+ }
+
+ @test
+ 'implicit scope (eval)'() {
+ let a = false;
+
+ hide(a);
+
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns true for falsy value'() {
+ let a = false;
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns false for truthy value'() {
+ let a = true;
+ const compiled = template('{{if (not a) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test({ skip: !DEBUG })
+ 'throws if called with more than one argument'(assert: Assert) {
+ let a = true;
+ let b = false;
+ const compiled = template('{{not a b}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ assert.throws(() => {
+ this.renderComponent(compiled);
+ }, /`not` expects exactly one argument/);
+ }
+}
+
+jitSuite(KeywordNot);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts
new file mode 100644
index 00000000000..936cf27cee0
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts
@@ -0,0 +1,65 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordOrRuntime extends RenderTest {
+ static suiteName = 'keyword helper: or (runtime)';
+
+ @test
+ 'explicit scope'() {
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: false, b: true }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'explicit scope (shadowed)'() {
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ or: () => false, a: true, b: true }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'implicit scope (eval)'() {
+ let a = false;
+ let b = 'hello';
+
+ hide(a);
+ hide(b);
+
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns no when all falsy'() {
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a: false, b: 0 }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+}
+
+jitSuite(KeywordOrRuntime);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts
new file mode 100644
index 00000000000..9400335ccd5
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts
@@ -0,0 +1,160 @@
+import { DEBUG } from '@glimmer/env';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler';
+
+class KeywordOr extends RenderTest {
+ static suiteName = 'keyword helper: or';
+
+ @test
+ 'references are lazy'(assert: Assert) {
+ const obj = {
+ get a() {
+ assert.step('a');
+ return false;
+ },
+ get b() {
+ assert.step('b');
+ return null;
+ },
+ get c() {
+ assert.step('c');
+ return 2;
+ },
+ get d() {
+ assert.step('d');
+ return 'unexpected!!!';
+ },
+ };
+
+ const compiled = template('{{or obj.a obj.b obj.c obj.d}}', {
+ strictMode: true,
+ scope: () => ({ obj }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('2');
+ assert.verifySteps(
+ ['a', 'b', 'c'],
+ 'd not evaluated because obj.c was the last to be evaluated and short-circuited'
+ );
+ }
+
+ @test
+ 'explicit scope'() {
+ let a = false;
+ let b = 'second';
+
+ const compiled = template('{{or a b}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('second');
+ }
+
+ @test
+ 'explicit scope (shadowed)'() {
+ let a = false;
+ let b = true;
+ let or = () => 'surprise';
+ const compiled = template('{{or a b}}', {
+ strictMode: true,
+ scope: () => ({ or, a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('surprise');
+ }
+
+ @test
+ 'implicit scope (eval)'() {
+ let a = false;
+ let b = 'hello';
+
+ hide(a);
+ hide(b);
+
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns first truthy value'() {
+ let a = false;
+ let b = 'hello';
+ const compiled = template('{{or a b}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('hello');
+ }
+
+ @test
+ 'returns right-most value when all are falsy'() {
+ let a = 0;
+ let b = '';
+ const compiled = template('{{or a b}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('');
+ }
+
+ @test
+ 'works as a SubExpression with if'() {
+ let a = false;
+ let b = true;
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'treats empty array as falsy'() {
+ let a: unknown[] = [];
+ let b = false;
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test({ skip: !DEBUG })
+ 'throws if called with less than two arguments'(assert: Assert) {
+ let a = true;
+ const compiled = template('{{or a}}', {
+ strictMode: true,
+ scope: () => ({ a }),
+ });
+
+ assert.throws(() => {
+ this.renderComponent(compiled);
+ }, /`or` expects at least two arguments/);
+ }
+}
+
+jitSuite(KeywordOr);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer/runtime/index.ts b/packages/@glimmer/runtime/index.ts
index 2012ced0f31..57d2204d884 100644
--- a/packages/@glimmer/runtime/index.ts
+++ b/packages/@glimmer/runtime/index.ts
@@ -31,6 +31,7 @@ export {
inTransaction,
runtimeOptions,
} from './lib/environment';
+export { and } from './lib/helpers/and';
export { array } from './lib/helpers/array';
export { concat } from './lib/helpers/concat';
export { eq } from './lib/helpers/eq';
@@ -43,6 +44,8 @@ export { invokeHelper } from './lib/helpers/invoke';
export { lt } from './lib/helpers/lt';
export { lte } from './lib/helpers/lte';
export { neq } from './lib/helpers/neq';
+export { not } from './lib/helpers/not';
+export { or } from './lib/helpers/or';
export { on } from './lib/modifiers/on';
export { renderComponent, renderMain, renderSync } from './lib/render';
export { DynamicScopeImpl, ScopeImpl } from './lib/scope';
diff --git a/packages/@glimmer/runtime/lib/helpers/and.ts b/packages/@glimmer/runtime/lib/helpers/and.ts
new file mode 100644
index 00000000000..d06fa0e1acc
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/and.ts
@@ -0,0 +1,26 @@
+import { DEBUG } from '@glimmer/env';
+import type { CapturedArguments } from '@glimmer/interfaces';
+import { toBool } from '@glimmer/global-context';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+export const and = internalHelper(({ positional }: CapturedArguments) => {
+ if (DEBUG && positional.length < 2) {
+ throw new Error(`\`and\` expects at least two arguments, but received ${positional.length}.`);
+ }
+
+ return createComputeRef(
+ () => {
+ let last: unknown;
+ for (let i = 0; i < positional.length; i++) {
+ let arg = positional[i];
+ last = arg ? valueForRef(arg) : arg;
+ if (!toBool(last)) return last;
+ }
+ return last;
+ },
+ null,
+ 'and'
+ );
+});
diff --git a/packages/@glimmer/runtime/lib/helpers/not.ts b/packages/@glimmer/runtime/lib/helpers/not.ts
new file mode 100644
index 00000000000..4b98c96d52e
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/not.ts
@@ -0,0 +1,10 @@
+import { DEBUG } from '@glimmer/env';
+import { toBool } from '@glimmer/global-context';
+
+export const not = (...args: unknown[]) => {
+ if (DEBUG && args.length !== 1) {
+ throw new Error(`\`not\` expects exactly one argument, but received ${args.length}.`);
+ }
+
+ return !toBool(args[0]);
+};
diff --git a/packages/@glimmer/runtime/lib/helpers/or.ts b/packages/@glimmer/runtime/lib/helpers/or.ts
new file mode 100644
index 00000000000..f10299fddb2
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/or.ts
@@ -0,0 +1,26 @@
+import { DEBUG } from '@glimmer/env';
+import type { CapturedArguments } from '@glimmer/interfaces';
+import { toBool } from '@glimmer/global-context';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+export const or = internalHelper(({ positional }: CapturedArguments) => {
+ if (DEBUG && positional.length < 2) {
+ throw new Error(`\`or\` expects at least two arguments, but received ${positional.length}.`);
+ }
+
+ return createComputeRef(
+ () => {
+ let last: unknown;
+ for (let i = 0; i < positional.length; i++) {
+ let arg = positional[i];
+ last = arg ? valueForRef(arg) : arg;
+ if (toBool(last)) return last;
+ }
+ return last;
+ },
+ null,
+ 'or'
+ );
+});