Skip to content

Commit d8426e4

Browse files
committed
Fix template-no-action false positive in GJS/GTS
`action` is an ambient strict-mode keyword in Ember (registered in STRICT_MODE_KEYWORDS at @ember/template-compiler/lib/plugins/index.ts), so `{{action this.x}}` works in .gjs/.gts templates without an import. The rule should still flag those uses — its purpose is to discourage the deprecated keyword everywhere — but skip cases where `action` resolves to a JS-scope binding or a template block param: - `import action from './my-action-helper'; {{action this.x}}` (user import) - `const action = (h) => () => h(); {{action this.x}}` (local declaration) - `{{#each items as |action|}}{{action this.x}}{{/each}}` (block-param shadow) - `<Foo as |action|>{{action this.x}}</Foo>` (element-block-param shadow) Add the same JS-scope-walk + block-param tracking pattern used by template-no-log, template-no-unbound, etc.: walk sourceCode.getScope().variables for the JS side, and push/pop GlimmerBlockStatement.program.blockParams and GlimmerElementNode.blockParams for template-side scopes. The `@action` and `this.action` head-type checks are unchanged (those are JS-side namespaces, not the template keyword). Tests: 22 (was 8) — adds 9 new valid cases (5 JS-scope, 4 block-param) and 5 new invalid cases (3 ambient-keyword in .gjs/.gts, 1 unrelated import does not mask, 1 ambient-after-block-exit). Docs updated to document the strict-mode behavior.
1 parent b705850 commit d8426e4

3 files changed

Lines changed: 171 additions & 13 deletions

File tree

docs/rules/template-no-action.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,25 @@ Examples of **correct** code for this rule:
4242
</template>
4343
```
4444

45+
```gjs
46+
import action from './my-action-helper';
47+
<template>
48+
{{action this.handleClick}}
49+
</template>
50+
```
51+
52+
```gjs
53+
<template>
54+
{{#each items as |action|}}
55+
<button {{action this.handleClick}}>x</button>
56+
{{/each}}
57+
</template>
58+
```
59+
60+
## Strict-mode behavior
61+
62+
`action` is an ambient strict-mode keyword in Ember (registered in `STRICT_MODE_KEYWORDS`), so `{{action this.x}}` works in `.gjs`/`.gts` templates without an explicit import. The rule still flags those uses to discourage the deprecated keyword — but skips reports when `action` resolves to a JS-scope binding (an import or local declaration) or a template block param.
63+
4564
## Migration
4665

4766
- Replace `(action "methodName")` with method references or `(fn this.methodName)`

lib/rules/template-no-action.js

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
1-
function isActionHelper(node) {
1+
function isActionHelperPath(node) {
22
if (!node.path || node.path.type !== 'GlimmerPathExpression') {
33
return false;
44
}
55

66
// Check if it's the action helper (not this.action or @action)
77
const path = node.path;
8-
if (path.original === 'action') {
9-
// Check head.type to avoid deprecated data/this properties
10-
const head = path.head;
11-
if (head && (head.type === 'AtHead' || head.type === 'ThisHead')) {
12-
return false;
13-
}
14-
return true;
8+
if (path.original !== 'action') {
9+
return false;
1510
}
16-
17-
return false;
11+
// Avoid deprecated data/this properties — those are not the helper.
12+
const head = path.head;
13+
if (head && (head.type === 'AtHead' || head.type === 'ThisHead')) {
14+
return false;
15+
}
16+
return true;
1817
}
1918

2019
/** @type {import('eslint').Rule.RuleModule} */
@@ -39,9 +38,79 @@ module.exports = {
3938
},
4039

4140
create(context) {
41+
// `action` is an ambient strict-mode keyword in Ember (registered in
42+
// STRICT_MODE_KEYWORDS), so `{{action this.x}}` works in .gjs/.gts without
43+
// an import. We still want to flag the ambient keyword everywhere — but
44+
// skip the report when `action` resolves to a JS binding (e.g.
45+
// `import action from './my-action'`) or a template block param
46+
// (e.g. `{{#each items as |action|}}{{action this.x}}{{/each}}`).
47+
const sourceCode = context.sourceCode;
48+
const localScopes = [];
49+
50+
function isJsScopeVariable(node) {
51+
if (!sourceCode) {
52+
return false;
53+
}
54+
try {
55+
let scope = sourceCode.getScope(node);
56+
while (scope) {
57+
if (scope.variables.some((v) => v.name === 'action')) {
58+
return true;
59+
}
60+
scope = scope.upper;
61+
}
62+
} catch {
63+
// sourceCode.getScope may not be available in .hbs-only mode; ignore.
64+
}
65+
return false;
66+
}
67+
68+
function pushLocals(params) {
69+
localScopes.push(new Set(params || []));
70+
}
71+
72+
function popLocals() {
73+
localScopes.pop();
74+
}
75+
76+
function isLocalActionBinding() {
77+
for (const scope of localScopes) {
78+
if (scope.has('action')) {
79+
return true;
80+
}
81+
}
82+
return false;
83+
}
84+
85+
function shouldFlag(node) {
86+
return isActionHelperPath(node) && !isLocalActionBinding() && !isJsScopeVariable(node);
87+
}
88+
4289
return {
90+
GlimmerBlockStatement(node) {
91+
if (node.program?.blockParams?.length > 0) {
92+
pushLocals(node.program.blockParams);
93+
}
94+
},
95+
'GlimmerBlockStatement:exit'(node) {
96+
if (node.program?.blockParams?.length > 0) {
97+
popLocals();
98+
}
99+
},
100+
101+
GlimmerElementNode(node) {
102+
if (node.blockParams?.length > 0) {
103+
pushLocals(node.blockParams);
104+
}
105+
},
106+
'GlimmerElementNode:exit'(node) {
107+
if (node.blockParams?.length > 0) {
108+
popLocals();
109+
}
110+
},
111+
43112
GlimmerSubExpression(node) {
44-
if (isActionHelper(node)) {
113+
if (shouldFlag(node)) {
45114
context.report({
46115
node,
47116
messageId: 'subExpression',
@@ -50,7 +119,7 @@ module.exports = {
50119
},
51120

52121
GlimmerMustacheStatement(node) {
53-
if (isActionHelper(node)) {
122+
if (shouldFlag(node)) {
54123
context.report({
55124
node,
56125
messageId: 'mustache',
@@ -59,7 +128,7 @@ module.exports = {
59128
},
60129

61130
GlimmerElementModifierStatement(node) {
62-
if (isActionHelper(node)) {
131+
if (shouldFlag(node)) {
63132
context.report({
64133
node,
65134
messageId: 'modifier',

tests/lib/rules/template-no-action.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,37 @@ ruleTester.run('template-no-action', rule, {
2828
`<template>
2929
{{@action}}
3030
</template>`,
31+
32+
// GJS/GTS: a JS-scope binding shadows the ambient `action` keyword. The
33+
// user has imported (or declared) their own `action`, so flagging would
34+
// corrupt their reference.
35+
{
36+
filename: 'test.gjs',
37+
code: "import action from './my-action';\n<template>{{action this.handleClick}}</template>",
38+
},
39+
{
40+
filename: 'test.gjs',
41+
code: "import action from './my-action';\n<template>{{(action this.handleClick)}}</template>",
42+
},
43+
{
44+
filename: 'test.gjs',
45+
code: "import action from './my-action';\n<template><button {{action this.handleClick}}>x</button></template>",
46+
},
47+
{
48+
filename: 'test.gts',
49+
code: "import action from './my-action';\n<template>{{action this.handleClick}}</template>",
50+
},
51+
{
52+
filename: 'test.gjs',
53+
code: 'const action = (h) => () => h();\n<template>{{action this.handleClick}}</template>',
54+
},
55+
56+
// Template block-param shadowing — `action` is the iterator/let-bound
57+
// value, not the ambient keyword.
58+
'<template>{{#each items as |action|}}{{action this.x}}{{/each}}</template>',
59+
'<template>{{#let (component "x") as |action|}}{{action this.x}}{{/let}}</template>',
60+
'<template>{{#each items as |action|}}<button {{action this.x}}>x</button>{{/each}}</template>',
61+
'<template><Foo as |action|>{{action this.x}}</Foo></template>',
3162
],
3263

3364
invalid: [
@@ -83,5 +114,44 @@ ruleTester.run('template-no-action', rule, {
83114
},
84115
],
85116
},
117+
118+
// GJS/GTS: ambient `action` keyword usage with no shadowing import or
119+
// block param. Still flagged.
120+
{
121+
filename: 'test.gjs',
122+
code: '<template>{{action "save"}}</template>',
123+
output: null,
124+
errors: [{ messageId: 'mustache', type: 'GlimmerMustacheStatement' }],
125+
},
126+
{
127+
filename: 'test.gjs',
128+
code: '<template><button {{on "click" (action "save")}}>x</button></template>',
129+
output: null,
130+
errors: [{ messageId: 'subExpression', type: 'GlimmerSubExpression' }],
131+
},
132+
{
133+
filename: 'test.gts',
134+
code: '<template><button {{action "submit"}}>x</button></template>',
135+
output: null,
136+
errors: [{ messageId: 'modifier', type: 'GlimmerElementModifierStatement' }],
137+
},
138+
139+
// Unrelated JS bindings do NOT mask the rule. Only a binding named
140+
// `action` should be respected.
141+
{
142+
filename: 'test.gjs',
143+
code: "import handler from './handler';\n<template>{{action this.x}}</template>",
144+
output: null,
145+
errors: [{ messageId: 'mustache' }],
146+
},
147+
148+
// Ambient `action` outside a block-param scope is still flagged, even
149+
// when an inner block legitimately shadows it.
150+
{
151+
filename: 'test.gjs',
152+
code: '<template>{{#each items as |action|}}{{action this.x}}{{/each}}{{action this.y}}</template>',
153+
output: null,
154+
errors: [{ messageId: 'mustache' }],
155+
},
86156
],
87157
});

0 commit comments

Comments
 (0)