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';
+ *
+ *
+ * {{if (eq @status "active") "Active" "Inactive"}}
+ *
+ * ```
+ *
+ * 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';
+ *
+ *
+ * {{if (neq @status "active") "Not active" "Active"}}
+ *
+ * ```
+ *
+ * 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(
+
+ {{eq a b}}
+ {{neq a b}}
+
+ );
+
+ 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(
+
+ {{eq a b}}
+ {{neq a b}}
+
+ );
+
+ 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',