diff --git a/packages/@ember/helper/index.ts b/packages/@ember/helper/index.ts index 579aa49daf2..bdf745a9974 100644 --- a/packages/@ember/helper/index.ts +++ b/packages/@ember/helper/index.ts @@ -8,8 +8,10 @@ import { hash as glimmerHash, array as glimmerArray, concat as glimmerConcat, + eq as glimmerEq, get as glimmerGet, fn as glimmerFn, + neq as glimmerNeq, } from '@glimmer/runtime'; import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer'; import { type Opaque } from '@ember/-internals/utility-types'; @@ -511,4 +513,52 @@ export interface ElementHelper extends Opaque<'helper:element'> {} export const uniqueId = glimmerUniqueId; export type UniqueIdHelper = typeof uniqueId; +/** + * The `{{eq}}` helper returns `true` if its two arguments are strictly equal + * (`===`). Takes exactly two arguments. + * + * ```js + * import { eq } from '@ember/helper'; + * + * + * ``` + * + * In strict-mode (gjs/gts) templates, `eq` is available as a keyword and + * does not need to be imported. + * + * @method eq + * @param {unknown} left + * @param {unknown} right + * @return {boolean} + * @public + */ +export const eq = glimmerEq as unknown as EqHelper; +export interface EqHelper extends Opaque<'helper:eq'> {} + +/** + * The `{{neq}}` helper returns `true` if its two arguments are strictly + * not equal (`!==`). Takes exactly two arguments. + * + * ```js + * import { neq } from '@ember/helper'; + * + * + * ``` + * + * In strict-mode (gjs/gts) templates, `neq` is available as a keyword and + * does not need to be imported. + * + * @method neq + * @param {unknown} left + * @param {unknown} right + * @return {boolean} + * @public + */ +export const neq = glimmerNeq as unknown as NeqHelper; +export interface NeqHelper extends Opaque<'helper:neq'> {} + /* 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 097786e9353..10fb8e0bf1a 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, fn, hash } from '@ember/helper'; +import { array, eq, fn, hash, neq } from '@ember/helper'; import { on } from '@ember/modifier'; import { assert } from '@ember/debug'; import { @@ -26,8 +26,10 @@ export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__'; export const keywords: Record = { array, + eq, fn, hash, + neq, on, }; 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 e7c9a6bd1bc..bf3ee5b993d 100644 --- a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts +++ b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts @@ -36,6 +36,12 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP if (isHash(node, hasLocal)) { rewriteKeyword(env, node, 'hash', '@ember/helper'); } + if (isEq(node, hasLocal)) { + rewriteKeyword(env, node, 'eq', '@ember/helper'); + } + if (isNeq(node, hasLocal)) { + rewriteKeyword(env, node, 'neq', '@ember/helper'); + } }, MustacheStatement(node: AST.MustacheStatement) { if (isArray(node, hasLocal)) { @@ -47,6 +53,12 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP if (isHash(node, hasLocal)) { rewriteKeyword(env, node, 'hash', '@ember/helper'); } + if (isEq(node, hasLocal)) { + rewriteKeyword(env, node, 'eq', '@ember/helper'); + } + if (isNeq(node, hasLocal)) { + rewriteKeyword(env, node, 'neq', '@ember/helper'); + } }, }, }; @@ -94,3 +106,17 @@ function isHash( ): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { return isPath(node.path) && node.path.original === 'hash' && !hasLocal('hash'); } + +function isEq( + 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 === 'eq' && !hasLocal('eq'); +} + +function isNeq( + 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 === 'neq' && !hasLocal('neq'); +} diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/eq-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/eq-runtime-test.ts new file mode 100644 index 00000000000..18af7e094d4 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/eq-runtime-test.ts @@ -0,0 +1,70 @@ +import { + GlimmerishComponent, + jitSuite, + RenderTest, + test, +} from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordEqRuntime extends RenderTest { + static suiteName = 'keyword helper: eq (runtime)'; + + @test + 'explicit scope'() { + const compiled = template('{{if (eq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a: 1, b: 1 }), + }); + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'explicit scope (shadowed)'() { + const compiled = template('{{if (eq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ eq: () => false, a: 1, b: 1 }), + }); + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'implicit scope (eval)'() { + let a = 1; + let b = 1; + hide(a); + hide(b); + const compiled = template('{{if (eq a b) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'no eval and no scope'() { + class Foo extends GlimmerishComponent { + static { + template('{{if (eq this.a this.b) "yes" "no"}}', { + strictMode: true, + component: this, + }); + } + a = 1; + b = 1; + } + this.renderComponent(Foo); + this.assertHTML('yes'); + } +} + +jitSuite(KeywordEqRuntime); + +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/eq-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/eq-test.ts new file mode 100644 index 00000000000..05fe07bddff --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/eq-test.ts @@ -0,0 +1,110 @@ +import { DEBUG } from '@glimmer/env'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler'; + +class KeywordEq extends RenderTest { + static suiteName = 'keyword helper: eq'; + + @test + 'explicit scope'() { + let a = 1; + let b = 1; + + const compiled = template('{{eq a b}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('true'); + } + + @test + 'explicit scope (shadowed)'() { + let a = 1; + let b = 2; + let eq = () => 'surprise'; + const compiled = template('{{eq a b}}', { + strictMode: true, + scope: () => ({ eq, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('surprise'); + } + + @test + 'implicit scope (eval)'() { + let a = 1; + let b = 1; + + hide(a); + hide(b); + + const compiled = template('{{if (eq a b) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns true for equal numbers'() { + let a = 1; + let b = 1; + const compiled = template('{{if (eq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns false for unequal numbers'() { + let a = 1; + let b = 2; + const compiled = template('{{if (eq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'returns true for equal strings'() { + let a = 'hello'; + let b = 'hello'; + const compiled = template('{{if (eq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test({ skip: !DEBUG }) + 'throws if not called with exactly two arguments'(assert: Assert) { + let a = 1; + const compiled = template('{{eq a}}', { + strictMode: true, + scope: () => ({ a }), + }); + + assert.throws(() => { + this.renderComponent(compiled); + }, /`eq` expects exactly two arguments/); + } +} + +jitSuite(KeywordEq); + +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/neq-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/neq-runtime-test.ts new file mode 100644 index 00000000000..f217f1702f8 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/neq-runtime-test.ts @@ -0,0 +1,70 @@ +import { + GlimmerishComponent, + jitSuite, + RenderTest, + test, +} from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordNeqRuntime extends RenderTest { + static suiteName = 'keyword helper: neq (runtime)'; + + @test + 'explicit scope'() { + const compiled = template('{{if (neq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a: 1, b: 2 }), + }); + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'explicit scope (shadowed)'() { + const compiled = template('{{if (neq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ neq: () => false, a: 1, b: 2 }), + }); + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'implicit scope (eval)'() { + let a = 1; + let b = 2; + hide(a); + hide(b); + const compiled = template('{{if (neq a b) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'no eval and no scope'() { + class Foo extends GlimmerishComponent { + static { + template('{{if (neq this.a this.b) "yes" "no"}}', { + strictMode: true, + component: this, + }); + } + a = 1; + b = 2; + } + this.renderComponent(Foo); + this.assertHTML('yes'); + } +} + +jitSuite(KeywordNeqRuntime); + +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/neq-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/neq-test.ts new file mode 100644 index 00000000000..478e1f229f8 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/neq-test.ts @@ -0,0 +1,98 @@ +import { DEBUG } from '@glimmer/env'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler'; + +class KeywordNeq extends RenderTest { + static suiteName = 'keyword helper: neq'; + + @test + 'explicit scope'() { + let a = 1; + let b = 2; + + const compiled = template('{{neq a b}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('true'); + } + + @test + 'explicit scope (shadowed)'() { + let a = 1; + let b = 1; + let neq = () => 'surprise'; + const compiled = template('{{neq a b}}', { + strictMode: true, + scope: () => ({ neq, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('surprise'); + } + + @test + 'implicit scope (eval)'() { + let a = 1; + let b = 2; + + hide(a); + hide(b); + + const compiled = template('{{if (neq a b) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns true for unequal numbers'() { + let a = 1; + let b = 2; + const compiled = template('{{if (neq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns false for equal numbers'() { + let a = 1; + let b = 1; + const compiled = template('{{if (neq a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a, b }), + }); + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test({ skip: !DEBUG }) + 'throws if not called with exactly two arguments'(assert: Assert) { + let a = 1; + const compiled = template('{{neq a}}', { + strictMode: true, + scope: () => ({ a }), + }); + + assert.throws(() => { + this.renderComponent(compiled); + }, /`neq` expects exactly two arguments/); + } +} + +jitSuite(KeywordNeq); + +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 9ec4eb2b603..617edd8ae30 100644 --- a/packages/@glimmer/runtime/index.ts +++ b/packages/@glimmer/runtime/index.ts @@ -33,10 +33,12 @@ export { } from './lib/environment'; export { array } from './lib/helpers/array'; export { concat } from './lib/helpers/concat'; +export { eq } from './lib/helpers/eq'; export { fn } from './lib/helpers/fn'; export { get } from './lib/helpers/get'; export { hash } from './lib/helpers/hash'; export { invokeHelper } from './lib/helpers/invoke'; +export { neq } from './lib/helpers/neq'; 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/eq.ts b/packages/@glimmer/runtime/lib/helpers/eq.ts new file mode 100644 index 00000000000..d50e8760ba6 --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/eq.ts @@ -0,0 +1,14 @@ +import { DEBUG } from '@glimmer/env'; + +/** + * Performs a strict equality comparison. + * + * left === right + */ +export function eq(left: unknown, right: unknown) { + if (DEBUG && arguments.length !== 2) { + throw new Error(`\`eq\` expects exactly two arguments, but received ${arguments.length}.`); + } + + return left === right; +} diff --git a/packages/@glimmer/runtime/lib/helpers/neq.ts b/packages/@glimmer/runtime/lib/helpers/neq.ts new file mode 100644 index 00000000000..8605b835787 --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/neq.ts @@ -0,0 +1,14 @@ +import { DEBUG } from '@glimmer/env'; + +/** + * Performs a strict inequality comparison. + * + * left !== right + */ +export function neq(left: unknown, right: unknown) { + if (DEBUG && arguments.length !== 2) { + throw new Error(`\`neq\` expects exactly two arguments, but received ${arguments.length}.`); + } + + return left !== right; +} diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index 6e5d6424bf4..e1c1afc3cc3 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -373,6 +373,47 @@ function basicTest(scenarios: Scenarios, appName: string) { }); }); `, + 'eq-neq-as-keyword-test.gjs': ` + import { module, test } from 'qunit'; + import { setupRenderingTest } from 'ember-qunit'; + import { render } from '@ember/test-helpers'; + + module('{{eq}} / {{neq}} as keywords', function(hooks) { + setupRenderingTest(hooks); + + test('it works', async function(assert) { + let a = 1; + let b = 1; + + await render( + + ); + + assert.dom('[data-eq]').hasText('true'); + assert.dom('[data-neq]').hasText('false'); + }); + + test('can be shadowed', async function (assert) { + let a = 1; + let b = 1; + let eq = () => 'surprise:eq'; + let neq = () => 'surprise:neq'; + + await render( + + ); + + assert.dom('[data-eq]').hasText('surprise:eq'); + assert.dom('[data-neq]').hasText('surprise:neq'); + }); + }); + `, 'fn-as-keyword-test.gjs': ` import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; diff --git a/tests/docs/expected.js b/tests/docs/expected.js index 512b71666a1..275e5cbd6d3 100644 --- a/tests/docs/expected.js +++ b/tests/docs/expected.js @@ -180,6 +180,7 @@ module.exports = { 'engine', 'ensureInitializers', 'enter', + 'eq', 'equal', 'error', 'eventDispatcher', @@ -335,6 +336,7 @@ module.exports = { 'name', 'nearestOfType', 'nearestWithProperty', + 'neq', 'next', 'none', 'normalize',