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'; + * + * + * ``` + * + * 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'; + * + * + * ``` + * + * 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'; + * + * + * ``` + * + * 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' + ); +});