Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-obsolete-elements](docs/rules/template-no-obsolete-elements.md) | disallow obsolete HTML elements | | | |
| [template-no-outlet-outside-routes](docs/rules/template-no-outlet-outside-routes.md) | disallow {{outlet}} outside of route templates | | | |
| [template-no-page-title-component](docs/rules/template-no-page-title-component.md) | disallow usage of ember-page-title component | | | |
| [template-require-each-key](docs/rules/template-require-each-key.md) | require key attribute in {{#each}} loops | | 🔧 | |
| [template-require-form-method](docs/rules/template-require-form-method.md) | require form method attribute | | 🔧 | |
| [template-require-has-block-helper](docs/rules/template-require-has-block-helper.md) | require (has-block) helper usage instead of hasBlock property | | 🔧 | |
| [template-require-iframe-src-attribute](docs/rules/template-require-iframe-src-attribute.md) | require iframe elements to have src attribute | | 🔧 | |
Expand Down
102 changes: 102 additions & 0 deletions docs/rules/template-require-each-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# ember/template-require-each-key

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

In order to improve rendering speed, Ember will try to reuse the DOM elements where possible. Specifically, if the same item is present in the array both before and after the change, its DOM output will be reused.

The key option is used to tell Ember how to determine if the items in the array being iterated over with `{{#each}}` has changed between renders. By default the item's object identity is used.

This is usually sufficient, so in most cases, the key option is simply not needed. However, in some rare cases, the objects' identities may change even though they represent the same underlying data.

For example:

```js
people.map((person) => {
return { ...person, type: 'developer' };
});
```

In this case, each time the people array is map-ed over, it will produce an new array with completely different objects between renders. In these cases, you can help Ember determine how these objects related to each other with the key option:

```gjs
<template>
<ul>
{{#each @developers key='name' as |person|}}
<li>Hello, {{person.name}}!</li>
{{/each}}
</ul>
</template>
```

By doing so, Ember will use the value of the property specified (`person.name` in the example) to find a "match" from the previous render. That is, if Ember has previously seen an object from the `@developers` array with a matching name, its DOM elements will be re-used.

This rule will require to always use `key` with `{{#each}}`.

## Examples

This rule **forbids** the following:

```gjs
<template>
{{#each this.items as |item|}}
<div>{{item.name}}</div>
{{/each}}
</template>
```

```gjs
<template>
{{#each this.items key='@invalid' as |item|}}
<div>{{item.name}}</div>
{{/each}}
</template>
```

```gjs
<template>
{{#each this.items key='' as |item|}}
<div>{{item.name}}</div>
{{/each}}
</template>
```

This rule **allows** the following:

```gjs
<template>
{{#each this.items key='id' as |item|}}
<div>{{item.name}}</div>
{{/each}}
</template>
```

```gjs
<template>
{{#each this.items key='deeply.nested.id' as |item|}}
<div>{{item.name}}</div>
{{/each}}
</template>
```

```gjs
<template>
{{#each this.items key='@index' as |item|}}
<div>{{item.name}}</div>
{{/each}}
</template>
```

```gjs
<template>
{{#each this.items key='@identity' as |item|}}
<div>{{item.name}}</div>
{{/each}}
</template>
```

## References

- [Specifying Keys](https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/each#specifying-keys)
- [The Immutable Pattern in Tracked Properties](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
70 changes: 70 additions & 0 deletions lib/rules/template-require-each-key.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
const FIXED_KEY = 'key="@identity"';

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require key attribute in {{#each}} loops',
category: 'Best Practices',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-each-key.md',
templateMode: 'both',
},
fixable: 'code',
schema: [],
messages: {
requireEachKey:
'{{eachHelper}} helper requires a valid key value to avoid performance issues',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/require-each-key.js',
docs: 'docs/rule/require-each-key.md',
tests: 'test/unit/rules/require-each-key-test.js',
},
},

create(context) {
const VALID_AT_KEYS = new Set(['@index', '@identity']);

function isValidKey(pair) {
if (!pair.value || pair.value.type !== 'GlimmerStringLiteral') {
return true; // dynamic values are OK
}
const value = pair.value.value;
if (!value || value.trim() === '') {
return false; // empty key
}
if (value.startsWith('@') && !VALID_AT_KEYS.has(value)) {
return false; // invalid @ key
}
return true;
}

return {
GlimmerBlockStatement(node) {
if (node.path.type === 'GlimmerPathExpression' && node.path.original === 'each') {
const keyPair = node.hash && node.hash.pairs.find((pair) => pair.key === 'key');
if (!keyPair || !isValidKey(keyPair)) {
context.report({
node,
messageId: 'requireEachKey',
data: {
eachHelper: '{{#each}}',
},
fix(fixer) {
if (!keyPair) {
const lastParam = node.params.at(-1) ?? node.path;

return fixer.insertTextAfterRange(lastParam.range, ` ${FIXED_KEY}`);
}

return fixer.replaceTextRange(keyPair.range, FIXED_KEY);
},
});
}
}
},
};
},
};
65 changes: 65 additions & 0 deletions tests/lib/rules/template-require-each-key.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const rule = require('../../../lib/rules/template-require-each-key');
const RuleTester = require('eslint').RuleTester;

const ERROR_MESSAGE = '{{#each}} helper requires a valid key value to avoid performance issues';

const validHbs = [
'{{#each this.items key="id" as |item|}} {{item.name}} {{/each}}',
'{{#each this.items key="deeply.nested.id" as |item|}} {{item.name}} {{/each}}',
'{{#each this.items key="@index" as |item|}} {{item.name}} {{/each}}',
'{{#each this.items key="@identity" as |item|}} {{item.name}} {{/each}}',
'{{#if foo}}{{/if}}',
];

const invalidHbs = [
{
code: '{{#each this.items as |item|}} {{item.name}} {{/each}}',
output: '{{#each this.items key="@identity" as |item|}} {{item.name}} {{/each}}',
errors: [{ message: ERROR_MESSAGE }],
},
{
code: '{{#each this.items key="@invalid" as |item|}} {{item.name}} {{/each}}',
output: '{{#each this.items key="@identity" as |item|}} {{item.name}} {{/each}}',
errors: [{ message: ERROR_MESSAGE }],
},
{
code: '{{#each this.items key="" as |item|}} {{item.name}} {{/each}}',
output: '{{#each this.items key="@identity" as |item|}} {{item.name}} {{/each}}',
errors: [{ message: ERROR_MESSAGE }],
},
];

function wrapTemplate(entry) {
if (typeof entry === 'string') {
return `<template>${entry}</template>`;
}

return {
...entry,
code: `<template>${entry.code}</template>`,
output: entry.output ? `<template>${entry.output}</template>` : entry.output,
};
}

const gjsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

gjsRuleTester.run('template-require-each-key', rule, {
valid: validHbs.map(wrapTemplate),
invalid: invalidHbs.map(wrapTemplate),
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});

hbsRuleTester.run('template-require-each-key', rule, {
valid: validHbs,
invalid: invalidHbs,
});
Loading