From 3ba27aa0a7b4f02a704e3acccc13a065019ef899 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Wed, 18 Mar 2026 17:09:49 -0400
Subject: [PATCH 1/2] Extract rule: template-no-unbound
---
README.md | 1 +
docs/rules/template-no-unbound.md | 27 ++++++++++++++
lib/rules/template-no-unbound.js | 32 ++++++++++++++++
tests/lib/rules/template-no-unbound.js | 51 ++++++++++++++++++++++++++
4 files changed, 111 insertions(+)
create mode 100644 docs/rules/template-no-unbound.md
create mode 100644 lib/rules/template-no-unbound.js
create mode 100644 tests/lib/rules/template-no-unbound.js
diff --git a/README.md b/README.md
index a5a3d3070f..b2e4f6832e 100644
--- a/README.md
+++ b/README.md
@@ -340,6 +340,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-attrs-in-components](docs/rules/template-no-attrs-in-components.md) | disallow attrs in component templates | | | |
| [template-no-link-to-positional-params](docs/rules/template-no-link-to-positional-params.md) | disallow positional params in LinkTo component | | | |
| [template-no-link-to-tagname](docs/rules/template-no-link-to-tagname.md) | disallow tagName attribute on LinkTo component | | | |
+| [template-no-unbound](docs/rules/template-no-unbound.md) | disallow {{unbound}} helper | | | |
| [template-no-with](docs/rules/template-no-with.md) | disallow {{with}} helper | | | |
### Ember Data
diff --git a/docs/rules/template-no-unbound.md b/docs/rules/template-no-unbound.md
new file mode 100644
index 0000000000..5fe5e7e49e
--- /dev/null
+++ b/docs/rules/template-no-unbound.md
@@ -0,0 +1,27 @@
+# ember/template-no-unbound
+
+> **HBS Only**: This rule applies to classic `.hbs` template files only (loose mode). It is not relevant for `gjs`/`gts` files (strict mode), where these patterns cannot occur.
+
+
+
+`{{unbound}}` is a legacy hold over from the days in which Ember's template engine was less performant. Its use today
+is vestigial, and it no longer offers performance benefits.
+
+It is also a poor practice to use it for rendering only the initial value of a property that may later change.
+
+## Examples
+
+This rule **forbids** the following:
+
+```hbs
+{{unbound aVar}}
+```
+
+```hbs
+{{some-component foo=(unbound aVar)}}
+```
+
+## References
+
+- [deprecations/unbound block syntax](https://deprecations.emberjs.com/v1.x/#toc_block-and-multi-argument-unbound-helper)
+- [Ember api/unbound helper](https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/each?anchor=unbound)
diff --git a/lib/rules/template-no-unbound.js b/lib/rules/template-no-unbound.js
new file mode 100644
index 0000000000..9d15a18cfa
--- /dev/null
+++ b/lib/rules/template-no-unbound.js
@@ -0,0 +1,32 @@
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'disallow {{unbound}} helper',
+ category: 'Deprecations',
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unbound.md',
+ templateMode: 'loose',
+ },
+ schema: [],
+ messages: { unexpected: 'Unexpected unbound helper usage.' },
+ originallyFrom: {
+ name: 'ember-template-lint',
+ rule: 'lib/rules/no-unbound.js',
+ docs: 'docs/rule/no-unbound.md',
+ tests: 'test/unit/rules/no-unbound-test.js',
+ },
+ },
+ create(context) {
+ function check(node) {
+ if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unbound') {
+ context.report({ node, messageId: 'unexpected' });
+ }
+ }
+ return {
+ GlimmerMustacheStatement: check,
+ GlimmerBlockStatement: check,
+ GlimmerSubExpression: check,
+ };
+ },
+};
diff --git a/tests/lib/rules/template-no-unbound.js b/tests/lib/rules/template-no-unbound.js
new file mode 100644
index 0000000000..c2035cc066
--- /dev/null
+++ b/tests/lib/rules/template-no-unbound.js
@@ -0,0 +1,51 @@
+const rule = require('../../../lib/rules/template-no-unbound');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+ruleTester.run('template-no-unbound', rule, {
+ valid: [
+ '{{this.value}}',
+ '{{foo}}',
+ '{{button}}',
+ ],
+ invalid: [
+ {
+ code: '{{unbound foo}}',
+ output: null,
+ errors: [{ messageId: 'unexpected' }],
+ },
+
+ {
+ code: '{{my-thing foo=(unbound foo)}}',
+ output: null,
+ errors: [{ messageId: 'unexpected' }],
+ },
+ ],
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ },
+});
+
+hbsRuleTester.run('template-no-unbound', rule, {
+ valid: ['{{foo}}', '{{button}}'],
+ invalid: [
+ {
+ code: '{{unbound foo}}',
+ output: null,
+ errors: [{ message: 'Unexpected unbound helper usage.' }],
+ },
+ {
+ code: '{{my-thing foo=(unbound foo)}}',
+ output: null,
+ errors: [{ message: 'Unexpected unbound helper usage.' }],
+ },
+ ],
+});
From 1afad8d12610662d80ee0af7b49ad0de8de988a7 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Sat, 21 Mar 2026 00:02:03 -0400
Subject: [PATCH 2/2] Sync with template-lint
---
docs/rules/template-no-unbound.md | 12 +++--
lib/rules/template-no-unbound.js | 8 ++-
tests/lib/rules/template-no-unbound.js | 68 +++++++++++++-------------
3 files changed, 49 insertions(+), 39 deletions(-)
diff --git a/docs/rules/template-no-unbound.md b/docs/rules/template-no-unbound.md
index 5fe5e7e49e..a9044f146e 100644
--- a/docs/rules/template-no-unbound.md
+++ b/docs/rules/template-no-unbound.md
@@ -13,12 +13,16 @@ It is also a poor practice to use it for rendering only the initial value of a p
This rule **forbids** the following:
-```hbs
-{{unbound aVar}}
+```gjs
+
+ {{unbound aVar}}
+
```
-```hbs
-{{some-component foo=(unbound aVar)}}
+```gjs
+
+ {{some-component foo=(unbound aVar)}}
+
```
## References
diff --git a/lib/rules/template-no-unbound.js b/lib/rules/template-no-unbound.js
index 9d15a18cfa..29eb9d2c07 100644
--- a/lib/rules/template-no-unbound.js
+++ b/lib/rules/template-no-unbound.js
@@ -9,7 +9,7 @@ module.exports = {
templateMode: 'loose',
},
schema: [],
- messages: { unexpected: 'Unexpected unbound helper usage.' },
+ messages: { unexpected: 'Unexpected {{unboundHelper}} usage.' },
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-unbound.js',
@@ -20,7 +20,11 @@ module.exports = {
create(context) {
function check(node) {
if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unbound') {
- context.report({ node, messageId: 'unexpected' });
+ context.report({
+ node,
+ messageId: 'unexpected',
+ data: { unboundHelper: '{{unbound}}' },
+ });
}
}
return {
diff --git a/tests/lib/rules/template-no-unbound.js b/tests/lib/rules/template-no-unbound.js
index c2035cc066..5a7013f477 100644
--- a/tests/lib/rules/template-no-unbound.js
+++ b/tests/lib/rules/template-no-unbound.js
@@ -1,29 +1,42 @@
const rule = require('../../../lib/rules/template-no-unbound');
const RuleTester = require('eslint').RuleTester;
-const ruleTester = new RuleTester({
+const validHbs = ['{{foo}}', '{{button}}'];
+
+const invalidHbs = [
+ {
+ code: '{{unbound foo}}',
+ output: null,
+ errors: [{ message: 'Unexpected {{unbound}} usage.' }],
+ },
+ {
+ code: '{{my-thing foo=(unbound foo)}}',
+ output: null,
+ errors: [{ message: 'Unexpected {{unbound}} usage.' }],
+ },
+];
+
+function wrapTemplate(entry) {
+ if (typeof entry === 'string') {
+ return `${entry}`;
+ }
+
+ return {
+ ...entry,
+ code: `${entry.code}`,
+ output: entry.output ? `${entry.output}` : entry.output,
+ errors: entry.errors.map(() => ({ messageId: 'unexpected' })),
+ };
+}
+
+const gjsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});
-ruleTester.run('template-no-unbound', rule, {
- valid: [
- '{{this.value}}',
- '{{foo}}',
- '{{button}}',
- ],
- invalid: [
- {
- code: '{{unbound foo}}',
- output: null,
- errors: [{ messageId: 'unexpected' }],
- },
-
- {
- code: '{{my-thing foo=(unbound foo)}}',
- output: null,
- errors: [{ messageId: 'unexpected' }],
- },
- ],
+
+gjsRuleTester.run('template-no-unbound', rule, {
+ valid: validHbs.map(wrapTemplate),
+ invalid: invalidHbs.map(wrapTemplate),
});
const hbsRuleTester = new RuleTester({
@@ -35,17 +48,6 @@ const hbsRuleTester = new RuleTester({
});
hbsRuleTester.run('template-no-unbound', rule, {
- valid: ['{{foo}}', '{{button}}'],
- invalid: [
- {
- code: '{{unbound foo}}',
- output: null,
- errors: [{ message: 'Unexpected unbound helper usage.' }],
- },
- {
- code: '{{my-thing foo=(unbound foo)}}',
- output: null,
- errors: [{ message: 'Unexpected unbound helper usage.' }],
- },
- ],
+ valid: validHbs,
+ invalid: invalidHbs,
});