diff --git a/.agents/skills/ember-best-practices/AGENTS.md b/.agents/skills/ember-best-practices/AGENTS.md new file mode 100644 index 0000000..7895aed --- /dev/null +++ b/.agents/skills/ember-best-practices/AGENTS.md @@ -0,0 +1,8550 @@ +# Ember Best Practices + +**Version 1.0.0** +Ember.js Community +January 2026 + +> **Note:** +> This document is mainly for agents and LLMs to follow when maintaining, +> generating, or refactoring Ember.js codebases. Humans +> may also find it useful, but guidance here is optimized for automation +> and consistency by AI-assisted workflows. + +--- + +## Abstract + +Comprehensive performance optimization and accessibility guide for Ember.js applications, designed for AI agents and LLMs. Contains 42 rules across 7 categories, prioritized by impact from critical (route loading optimization, build performance) to advanced patterns (Resources, ember-concurrency, modern testing, composition patterns, owner/linkage management). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation. Uses WarpDrive for modern data management, includes accessibility best practices leveraging ember-a11y-testing and other OSS tools, and comprehensive coverage of reactive composition, data derivation, controlled forms, conditional rendering, data requesting patterns, built-in helpers, and service architecture patterns. + +--- + +## Table of Contents + +1. [Route Loading and Data Fetching](#1-route-loading-and-data-fetching) — **CRITICAL** + - 1.1 [Implement Smart Route Model Caching](#11-implement-smart-route-model-caching) + - 1.2 [Parallel Data Loading in Model Hooks](#12-parallel-data-loading-in-model-hooks) + - 1.3 [Use Loading Substates for Better UX](#13-use-loading-substates-for-better-ux) + - 1.4 [Use Route Templates with Co-located Syntax](#14-use-route-templates-with-co-located-syntax) + - 1.5 [Use Route-Based Code Splitting](#15-use-route-based-code-splitting) +2. [Build and Bundle Optimization](#2-build-and-bundle-optimization) — **CRITICAL** + - 2.1 [Avoid Importing Entire Addon Namespaces](#21-avoid-importing-entire-addon-namespaces) + - 2.2 [Lazy Load Heavy Dependencies](#22-lazy-load-heavy-dependencies) + - 2.3 [Use Embroider Build Pipeline](#23-use-embroider-build-pipeline) +3. [Component and Reactivity Optimization](#3-component-and-reactivity-optimization) — **HIGH** + - 3.1 [Avoid Constructors in Components](#31-avoid-constructors-in-components) + - 3.2 [Avoid CSS Classes in Learning Examples](#32-avoid-css-classes-in-learning-examples) + - 3.3 [Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)](#33-avoid-legacy-lifecycle-hooks-did-insert-will-destroy-did-update) + - 3.4 [Avoid Unnecessary Tracking](#34-avoid-unnecessary-tracking) + - 3.5 [Build Reactive Chains with Dependent Getters](#35-build-reactive-chains-with-dependent-getters) + - 3.6 [Component File Naming and Export Conventions](#36-component-file-naming-and-export-conventions) + - 3.7 [Prefer Named Exports, Fallback to Default for Implicit Template Lookup](#37-prefer-named-exports-fallback-to-default-for-implicit-template-lookup) + - 3.8 [Prevent Memory Leaks in Components](#38-prevent-memory-leaks-in-components) + - 3.9 [Use {{on}} Modifier for Event Handling](#39-use-on-modifier-for-event-handling) + - 3.10 [Use @cached for Expensive Getters](#310-use-cached-for-expensive-getters) + - 3.11 [Use Class Fields for Component Composition](#311-use-class-fields-for-component-composition) + - 3.12 [Use Component Composition Patterns](#312-use-component-composition-patterns) + - 3.13 [Use Glimmer Components Over Classic Components](#313-use-glimmer-components-over-classic-components) + - 3.14 [Use Native Forms with Platform Validation](#314-use-native-forms-with-platform-validation) + - 3.15 [Use Strict Mode and Template-Only Components](#315-use-strict-mode-and-template-only-components) + - 3.16 [Use Tracked Toolbox for Complex State](#316-use-tracked-toolbox-for-complex-state) + - 3.17 [Validate Component Arguments](#317-validate-component-arguments) +4. [Accessibility Best Practices](#4-accessibility-best-practices) — **HIGH** + - 4.1 [Announce Route Transitions to Screen Readers](#41-announce-route-transitions-to-screen-readers) + - 4.2 [Form Labels and Error Announcements](#42-form-labels-and-error-announcements) + - 4.3 [Keyboard Navigation Support](#43-keyboard-navigation-support) + - 4.4 [Semantic HTML and ARIA Attributes](#44-semantic-html-and-aria-attributes) + - 4.5 [Use ember-a11y-testing for Automated Checks](#45-use-ember-a11y-testing-for-automated-checks) +5. [Service and State Management](#5-service-and-state-management) — **MEDIUM-HIGH** + - 5.1 [Cache API Responses in Services](#51-cache-api-responses-in-services) + - 5.2 [Implement Robust Data Requesting Patterns](#52-implement-robust-data-requesting-patterns) + - 5.3 [Manage Service Owner and Linkage Patterns](#53-manage-service-owner-and-linkage-patterns) + - 5.4 [Optimize WarpDrive Queries](#54-optimize-warpdrive-queries) + - 5.5 [Use Services for Shared State](#55-use-services-for-shared-state) +6. [Template Optimization](#6-template-optimization) — **MEDIUM** + - 6.1 [Avoid Heavy Computation in Templates](#61-avoid-heavy-computation-in-templates) + - 6.2 [Compose Helpers for Reusable Logic](#62-compose-helpers-for-reusable-logic) + - 6.3 [Import Helpers Directly in Templates](#63-import-helpers-directly-in-templates) + - 6.4 [No helper() Wrapper for Plain Functions](#64-no-helper-wrapper-for-plain-functions) + - 6.5 [Optimize Conditional Rendering](#65-optimize-conditional-rendering) + - 6.6 [Template-Only Components with In-Scope Functions](#66-template-only-components-with-in-scope-functions) + - 6.7 [Use {{#each}} with @key for Lists](#67-use-each-with-key-for-lists) + - 6.8 [Use {{#let}} to Avoid Recomputation](#68-use-let-to-avoid-recomputation) + - 6.9 [Use {{fn}} for Partial Application Only](#69-use-fn-for-partial-application-only) + - 6.10 [Use Helper Libraries Effectively](#610-use-helper-libraries-effectively) +7. [Performance Optimization](#7-performance-optimization) — **MEDIUM** + - 7.1 [Use {{on}} Modifier Instead of Event Handler Properties](#71-use-on-modifier-instead-of-event-handler-properties) +8. [Testing Best Practices](#8-testing-best-practices) — **MEDIUM** + - 8.1 [MSW (Mock Service Worker) Setup for Testing](#81-msw-mock-service-worker-setup-for-testing) + - 8.2 [Provide DOM-Abstracted Test Utilities for Library Components](#82-provide-dom-abstracted-test-utilities-for-library-components) + - 8.3 [Use Appropriate Render Patterns in Tests](#83-use-appropriate-render-patterns-in-tests) + - 8.4 [Use Modern Testing Patterns](#84-use-modern-testing-patterns) + - 8.5 [Use qunit-dom for Better Test Assertions](#85-use-qunit-dom-for-better-test-assertions) + - 8.6 [Use Test Waiters for Async Operations](#86-use-test-waiters-for-async-operations) +9. [Tooling and Configuration](#9-tooling-and-configuration) — **MEDIUM** + - 9.1 [VSCode Extensions and MCP Configuration for Ember Projects](#91-vscode-extensions-and-mcp-configuration-for-ember-projects) +10. [Advanced Patterns](#10-advanced-patterns) — **MEDIUM-HIGH** + - 10.1 [Use Ember Concurrency Correctly - User Concurrency Not Data Loading](#101-use-ember-concurrency-correctly---user-concurrency-not-data-loading) + - 10.2 [Use Ember Concurrency for User Input Concurrency](#102-use-ember-concurrency-for-user-input-concurrency) + - 10.3 [Use Helper Functions for Reusable Logic](#103-use-helper-functions-for-reusable-logic) + - 10.4 [Use Modifiers for DOM Side Effects](#104-use-modifiers-for-dom-side-effects) + - 10.5 [Use Reactive Collections from @ember/reactive/collections](#105-use-reactive-collections-from-emberreactivecollections) + +--- + +## 1. Route Loading and Data Fetching + +**Impact: CRITICAL** + +Efficient route loading and parallel data fetching eliminate waterfalls. Using route model hooks effectively and loading data in parallel yields the largest performance gains. + +### 1.1 Implement Smart Route Model Caching + +**Impact: MEDIUM-HIGH (Reduce redundant API calls and improve UX)** + +Implement intelligent model caching strategies to reduce redundant API calls and improve user experience. + +**Incorrect: always fetches fresh data** + +```glimmer-js +// app/routes/post.gjs +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class PostRoute extends Route { + @service store; + + model(params) { + // Always makes API call, even if we just loaded this post + return this.store.request({ url: `/posts/${params.post_id}` }); + } + + +} +``` + +**Correct: with smart caching** + +```glimmer-js +// app/routes/post.gjs +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class PostRoute extends Route { + @service store; + + model(params) { + // Check cache first + const cached = this.store.cache.peek({ + type: 'post', + id: params.post_id, + }); + + // Return cached if fresh (less than 5 minutes old) + if (cached && this.isCacheFresh(cached)) { + return cached; + } + + // Fetch fresh data + return this.store.request({ + url: `/posts/${params.post_id}`, + options: { reload: true }, + }); + } + + isCacheFresh(record) { + const cacheTime = record.meta?.cachedAt || 0; + const fiveMinutes = 5 * 60 * 1000; + return Date.now() - cacheTime < fiveMinutes; + } + + +} +``` + +**Service-based caching layer:** + +```glimmer-js +// app/routes/post.gjs +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class PostRoute extends Route { + @service postCache; + + model(params) { + return this.postCache.getPost(params.post_id); + } + + // Refresh data when returning to route + async activate() { + super.activate(...arguments); + const params = this.paramsFor('post'); + await this.postCache.getPost(params.post_id, { forceRefresh: true }); + } + + +} +``` + +**Using query params for cache control:** + +```glimmer-js +// app/routes/posts.gjs +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class PostsRoute extends Route { + @service store; + + queryParams = { + refresh: { refreshModel: true }, + }; + + model(params) { + const options = params.refresh ? { reload: true } : { backgroundReload: true }; + + return this.store.request({ + url: '/posts', + options, + }); + } + + +} +``` + +**Background refresh pattern:** + +```glimmer-js +// app/routes/dashboard.gjs +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class DashboardRoute extends Route { + @service store; + + async model() { + // Return cached data immediately + const cached = this.store.cache.peek({ type: 'dashboard' }); + + // Refresh in background + this.store.request({ + url: '/dashboard', + options: { backgroundReload: true }, + }); + + return cached || this.store.request({ url: '/dashboard' }); + } + + +} +``` + +Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh. + +Reference: [https://warp-drive.io/](https://warp-drive.io/) + +### 1.2 Parallel Data Loading in Model Hooks + +**Impact: CRITICAL (2-10× improvement)** + +When fetching multiple independent data sources in a route's model hook, use `Promise.all()` or RSVP.hash() to load them in parallel instead of sequentially. + +`export default` in these route examples is intentional because route modules are discovered through resolver lookup. In hybrid `.gjs`/`.hbs` codebases, keep route defaults and add named exports only when you need explicit imports elsewhere. + +**Incorrect: sequential loading, 3 round trips** + +```javascript +// app/routes/dashboard.js +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class DashboardRoute extends Route { + @service store; + + async model() { + const user = await this.store.request({ url: '/users/me' }); + const posts = await this.store.request({ url: '/posts?recent=true' }); + const notifications = await this.store.request({ url: '/notifications?unread=true' }); + + return { user, posts, notifications }; + } +} +``` + +**Correct: parallel loading, 1 round trip** + +```javascript +// app/routes/dashboard.js +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { hash } from 'rsvp'; + +export default class DashboardRoute extends Route { + @service store; + + model() { + return hash({ + user: this.store.request({ url: '/users/me' }), + posts: this.store.request({ url: '/posts?recent=true' }), + notifications: this.store.request({ url: '/notifications?unread=true' }), + }); + } +} +``` + +Using `hash()` from RSVP allows Ember to resolve all promises concurrently, significantly reducing load time. + +### 1.3 Use Loading Substates for Better UX + +**Impact: CRITICAL (Perceived performance improvement)** + +Implement loading substates to show immediate feedback while data loads, preventing blank screens and improving perceived performance. + +**Incorrect: no loading state** + +```javascript +// app/routes/posts.js +export default class PostsRoute extends Route { + async model() { + return this.store.request({ url: '/posts' }); + } +} +``` + +**Correct: with loading substate** + +```javascript +// app/routes/posts.js +export default class PostsRoute extends Route { + model() { + // Return promise directly - Ember will show posts-loading template + return this.store.request({ url: '/posts' }); + } +} +``` + +Ember automatically renders `{route-name}-loading` route templates while the model promise resolves, providing better UX without extra code. + +### 1.4 Use Route Templates with Co-located Syntax + +**Impact: MEDIUM-HIGH (Better code organization and maintainability)** + +Use co-located route templates with modern gjs syntax for better organization and maintainability. + +**Incorrect: separate template file - old pattern** + +```glimmer-js +// app/routes/posts.js (separate file) +import Route from '@ember/routing/route'; + +export default class PostsRoute extends Route { + model() { + return this.store.request({ url: '/posts' }); + } +} + +// app/templates/posts.gjs (separate template file) + +``` + +**Correct: co-located route template** + +```glimmer-js +// app/routes/posts.gjs +import Route from '@ember/routing/route'; + +export default class PostsRoute extends Route { + model() { + return this.store.request({ url: '/posts' }); + } + + +} +``` + +**With loading and error states:** + +```glimmer-js +// app/routes/posts.gjs +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class PostsRoute extends Route { + @service store; + + model() { + return this.store.request({ url: '/posts' }); + } + + +} +``` + +**Template-only routes:** + +```glimmer-js +// app/routes/about.gjs + +``` + +Co-located route templates keep route logic and presentation together, making the codebase easier to navigate and maintain. + +Reference: [https://guides.emberjs.com/release/routing/](https://guides.emberjs.com/release/routing/) + +### 1.5 Use Route-Based Code Splitting + +**Impact: CRITICAL (30-70% initial bundle reduction)** + +With Embroider's route-based code splitting, routes and their components are automatically split into separate chunks, loaded only when needed. + +**Incorrect: everything in main bundle** + +```javascript +// ember-cli-build.js +const EmberApp = require('ember-cli/lib/broccoli/ember-app'); + +module.exports = function (defaults) { + const app = new EmberApp(defaults, { + // No optimization + }); + + return app.toTree(); +}; +``` + +**Correct: Embroider with Vite and route splitting** + +```javascript +// ember-cli-build.js +const { Vite } = require('@embroider/vite'); + +module.exports = require('@embroider/compat').compatBuild(app, Vite, { + staticAddonTestSupportTrees: true, + staticAddonTrees: true, + staticHelpers: true, + staticModifiers: true, + staticComponents: true, + splitAtRoutes: ['admin', 'reports', 'settings'], // Routes to split +}); +``` + +Embroider with `splitAtRoutes` creates separate bundles for specified routes, reducing initial load time by 30-70%. + +Reference: [https://github.com/embroider-build/embroider](https://github.com/embroider-build/embroider) + +--- + +## 2. Build and Bundle Optimization + +**Impact: CRITICAL** + +Using Embroider with static build optimizations, route-based code splitting, and proper imports reduces bundle size and improves Time to Interactive. + +### 2.1 Avoid Importing Entire Addon Namespaces + +**Impact: CRITICAL (200-500ms import cost reduction)** + +Import specific utilities and components directly rather than entire addon namespaces to enable better tree-shaking and reduce bundle size. + +**Incorrect: imports entire namespace** + +```javascript +import { tracked } from '@glimmer/tracking'; +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +// OK - these are already optimized + +// But avoid this pattern with utility libraries: +import * as lodash from 'lodash'; +import * as moment from 'moment'; + +class My extends Component { + someMethod() { + return lodash.debounce(this.handler, 300); + } +} +``` + +**Correct: direct imports** + +```javascript +import { tracked } from '@glimmer/tracking'; +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import debounce from 'lodash/debounce'; +import dayjs from 'dayjs'; // moment alternative, smaller + +class My extends Component { + someMethod() { + return debounce(this.handler, 300); + } +} +``` + +**Even better: use Ember utilities when available** + +```javascript +import { tracked } from '@glimmer/tracking'; +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { debounce } from '@ember/runloop'; + +class My extends Component { + someMethod() { + return debounce(this, this.handler, 300); + } +} +``` + +Direct imports and using built-in Ember utilities reduce bundle size by avoiding unused code. + +### 2.2 Lazy Load Heavy Dependencies + +**Impact: CRITICAL (30-50% initial bundle reduction)** + +Use dynamic imports to load heavy libraries only when needed, reducing initial bundle size. + +**Incorrect: loaded upfront** + +```javascript +import Component from '@glimmer/component'; +import Chart from 'chart.js/auto'; // 300KB library loaded immediately +import hljs from 'highlight.js'; // 500KB library loaded immediately + +class Dashboard extends Component { + get showChart() { + return this.args.hasData; + } +} +``` + +**Correct: lazy loaded with error/loading state handling** + +```glimmer-js +import Component from '@glimmer/component'; +import { getPromiseState } from 'reactiveweb/promise'; + +class Dashboard extends Component { + // Use getPromiseState to model promise state for error/loading handling + chartLoader = getPromiseState(async () => { + const { default: Chart } = await import('chart.js/auto'); + return Chart; + }); + + highlighterLoader = getPromiseState(async () => { + const { default: hljs } = await import('highlight.js'); + return hljs; + }); + + loadChart = () => { + // Triggers lazy load, handles loading/error states automatically + return this.chartLoader.value; + }; + + highlightCode = (code) => { + const hljs = this.highlighterLoader.value; + if (hljs) { + return hljs.highlightAuto(code); + } + return code; + }; + + +} +``` + +**Note**: Always model promise state (loading/error/resolved) using `getPromiseState` from `reactiveweb/promise` to handle slow networks and errors properly. + +Dynamic imports reduce initial bundle size by 30-50%, improving Time to Interactive. + +### 2.3 Use Embroider Build Pipeline + +**Impact: CRITICAL (Modern build system with better performance)** + +Use Embroider, Ember's modern build pipeline, with Vite for faster builds, better tree-shaking, and smaller bundles. + +**Incorrect: classic build pipeline** + +```javascript +// ember-cli-build.js +const EmberApp = require('ember-cli/lib/broccoli/ember-app'); + +module.exports = function (defaults) { + const app = new EmberApp(defaults, {}); + return app.toTree(); +}; +``` + +**Correct: Embroider with Vite** + +```javascript +// ember-cli-build.js +const EmberApp = require('ember-cli/lib/broccoli/ember-app'); +const { compatBuild } = require('@embroider/compat'); + +module.exports = async function (defaults) { + const { buildOnce } = await import('@embroider/vite'); + + let app = new EmberApp(defaults, { + // Add options here + }); + + return compatBuild(app, buildOnce); +}; +``` + +**For stricter static analysis: optimized mode** + +```javascript +// ember-cli-build.js +const EmberApp = require('ember-cli/lib/broccoli/ember-app'); +const { compatBuild } = require('@embroider/compat'); + +module.exports = async function (defaults) { + const { buildOnce } = await import('@embroider/vite'); + + let app = new EmberApp(defaults, { + // Add options here + }); + + return compatBuild(app, buildOnce, { + // Enable static analysis for better tree-shaking + staticAddonTestSupportTrees: true, + staticAddonTrees: true, + staticHelpers: true, + staticModifiers: true, + staticComponents: true, + }); +}; +``` + +Embroider provides a modern build pipeline with Vite that offers faster builds and better optimization compared to the classic Ember CLI build system. + +Reference: [https://github.com/embroider-build/embroider](https://github.com/embroider-build/embroider) + +--- + +## 3. Component and Reactivity Optimization + +**Impact: HIGH** + +Proper use of Glimmer components, modern file conventions, tracked properties, and avoiding unnecessary recomputation improves rendering performance. + +### 3.1 Avoid Constructors in Components + +**Impact: HIGH (Prevents infinite render loops and simplifies code)** + +**Strongly discourage constructor usage.** Modern Ember components rarely need constructors. Use class fields, @service decorators, and getPromiseState for initialization instead. Constructors with function calls that set tracked state can cause infinite render loops. + +- [Ember Octane Guide](https://guides.emberjs.com/release/upgrading/current-edition/) + +- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb) + +**Incorrect: using constructor** + +```glimmer-js +// app/components/user-profile.gjs +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { service } from '@ember/service'; + +class UserProfile extends Component { + constructor() { + super(...arguments); + + // Anti-pattern: Manual service lookup + this.store = this.owner.lookup('service:store'); + this.router = this.owner.lookup('service:router'); + + // Anti-pattern: Imperative initialization + this.data = null; + this.isLoading = false; + this.error = null; + + // Anti-pattern: Side effects in constructor + this.loadUserData(); + } + + async loadUserData() { + this.isLoading = true; + try { + this.data = await this.store.request({ + url: `/users/${this.args.userId}`, + }); + } catch (e) { + this.error = e; + } finally { + this.isLoading = false; + } + } + + +} +``` + +**Correct: use class fields and declarative async state** + +```glimmer-js +// app/components/user-profile.gjs +import Component from '@glimmer/component'; +import { cached } from '@glimmer/tracking'; +import { service } from '@ember/service'; +import { getRequestState } from '@warp-drive/ember'; + +class UserProfile extends Component { + @service store; + + @cached + get userRequest() { + return this.store.request({ + url: `/users/${this.args.userId}`, + }); + } + + +} +``` + +**When You Might Need a Constructor: Very Rare** + +```glimmer-js +// app/components/complex-setup.gjs +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +class ComplexSetup extends Component { + @service store; + + @tracked state = null; + + constructor(owner, args) { + super(owner, args); + + // Only if you absolutely must do something that can't be done with class fields + // Even then, prefer resources or modifiers + if (this.args.legacyInitMode) { + this.initializeLegacyMode(); + } + } + + initializeLegacyMode() { + // Rare edge case initialization + } + + +} +``` + +Very rarely, you might need a constructor for truly exceptional cases. Even then, use modern patterns: + +**Why Strongly Avoid Constructors:** + +1. **Infinite Render Loops**: Setting tracked state in constructor that's read during render causes infinite loops + +2. **Service Injection**: Use `@service` decorator instead of `owner.lookup()` + +3. **Testability**: Class fields are easier to mock and test + +4. **Clarity**: Declarative class fields show state at a glance + +5. **Side Effects**: getPromiseState and modifiers handle side effects better + +6. **Memory Leaks**: getPromiseState auto-cleanup; constructor code doesn't + +7. **Reactivity**: Class fields integrate better with tracking + +8. **Initialization Order**: No need to worry about super() call timing + +9. **Argument Validation**: Constructor validation runs only once; use getters to catch arg changes + +**Modern Alternatives:** + +| Old Pattern | Modern Alternative | + +| -------------------------------------------------------------- | -------------------------------------------------------- | + +| `constructor() { this.store = owner.lookup('service:store') }` | `@service store;` | + +| `constructor() { this.data = null; }` | `@tracked data = null;` | + +| `constructor() { this.loadData(); }` | Use `@cached get` with getPromiseState | + +| `constructor() { this.interval = setInterval(...) }` | Use modifier with registerDestructor | + +| `constructor() { this.subscription = ... }` | Use modifier or constructor with registerDestructor ONLY | + +**Performance Impact:** + +- **Before**: Constructor runs on every instantiation, manual cleanup risk, infinite loop danger + +- **After**: Class fields initialize efficiently, getPromiseState auto-cleanup, no render loops + +**Strongly discourage constructors** - they add complexity and infinite render loop risks. Use declarative class fields and getPromiseState instead. + +### 3.2 Avoid CSS Classes in Learning Examples + +**Impact: LOW-MEDIUM (Cleaner, more focused learning materials)** + +Don't add CSS classes to learning content and examples unless they provide actual value above the surrounding context. Classes add visual noise and distract from the concepts being taught. + +**Incorrect: unnecessary classes in learning example** + +```glimmer-js +// app/components/user-card.gjs +import Component from '@glimmer/component'; + +export class UserCard extends Component { + +} +``` + +**Why This Is Wrong:** + +- Classes add visual clutter that obscures the actual concepts + +- Learners focus on naming conventions instead of the pattern being taught + +- Makes copy-paste more work (need to remove or change class names) + +- Implies these specific class names are required or best practice + +- Distracts from structural HTML and component logic + +**Correct: focused on concepts** + +```glimmer-js +// app/components/user-card.gjs +import Component from '@glimmer/component'; + +export class UserCard extends Component { + +} +``` + +**Benefits:** + +- **Clarity**: Easier to understand the component structure + +- **Focus**: Reader attention stays on the concepts being taught + +- **Simplicity**: Less code to process mentally + +- **Flexibility**: Reader can add their own classes without conflict + +- **Reusability**: Examples are easier to adapt to real code + +**When Classes ARE Appropriate in Examples:** + +```glimmer-js +// Example: Teaching about ...attributes for styling flexibility +export class Card extends Component { + +} + +{{! Usage: ... }} +``` + +**When to Include Classes:** + +1. **Teaching class binding** - Example explicitly about conditional classes or class composition + +2. **Demonstrating ...attributes** - Showing how callers add classes + +3. **Accessibility** - Using classes for semantic meaning (e.g., `aria-*` helpers) + +4. **Critical to example** - Class name is essential to understanding (e.g., `selected`, `active`) + +**Examples Where Classes Add Value:** + +```glimmer-js +// Good: Teaching about class composition +import { cn } from 'ember-cn'; + +export class Button extends Component { + +} +``` + +**Default Stance:** + +When writing learning examples or documentation: + +1. **Start without classes** - Add them only if needed + +2. **Ask**: Does this class help explain the concept? + +3. **Remove** any decorative or structural classes that aren't essential + +4. **Use** `...attributes` to show styling flexibility + +**Real-World Context:** + +In production code, you'll have classes for styling. But in learning materials, strip them away unless they're teaching something specific about classes themselves. + +**Common Violations:** + +❌ BEM classes in examples (`user-card__header`) + +❌ Utility classes unless teaching utilities (`flex`, `mt-4`) + +❌ Semantic classes that don't teach anything (`container`, `wrapper`) + +❌ Design system classes unless teaching design system integration + +**Summary:** + +Keep learning examples focused on the concept being taught. CSS classes should appear only when they're essential to understanding the pattern or when demonstrating styling flexibility with `...attributes`. + +Reference: [https://guides.emberjs.com/release/components/](https://guides.emberjs.com/release/components/) + +### 3.3 Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update) + +**Impact: HIGH (Prevents memory leaks and enforces modern patterns)** + +**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.** These legacy helpers create coupling between templates and component lifecycle, making code harder to test and maintain. Modern Ember provides better alternatives through derived data and custom modifiers. + +1. **Memory Leaks**: Easy to forget cleanup, especially with `did-insert` + +2. **Tight Coupling**: Mixes template concerns with JavaScript logic + +3. **Poor Testability**: Lifecycle hooks are harder to unit test + +4. **Not Composable**: Can't be easily shared across components + +5. **Deprecated Pattern**: Not recommended in modern Ember + +For computed values or reactive transformations, use getters and `@cached`: + +- [Ember Modifiers](https://github.com/ember-modifier/ember-modifier) + +- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb) + +- [Glimmer Tracking](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/) + +**❌ Incorrect (did-update):** + +```glimmer-js +// app/components/user-greeting.gjs +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +class UserGreeting extends Component { + @tracked displayName = ''; + + @action + updateDisplayName() { + // Runs on every render - inefficient and error-prone + this.displayName = `${this.args.user.firstName} ${this.args.user.lastName}`; + } + + +} +``` + +**✅ Correct (derived data with getter):** + +```glimmer-js +// app/components/user-greeting.gjs +import Component from '@glimmer/component'; + +class UserGreeting extends Component { + // Automatically reactive - updates when args change + get displayName() { + return `${this.args.user.firstName} ${this.args.user.lastName}`; + } + + +} +``` + +**✅ Even better (use @cached for expensive computations):** + +```glimmer-js +// app/components/user-stats.gjs +import Component from '@glimmer/component'; +import { cached } from '@glimmer/tracking'; + +class UserStats extends Component { + @cached + get sortedPosts() { + // Expensive computation only runs when @posts changes + return [...this.args.posts].sort((a, b) => b.createdAt - a.createdAt); + } + + @cached + get statistics() { + return { + total: this.args.posts.length, + published: this.args.posts.filter((p) => p.published).length, + drafts: this.args.posts.filter((p) => !p.published).length, + }; + } + + +} +``` + +For DOM side effects, element setup, or cleanup, use custom modifiers: + +**❌ Incorrect (did-insert + will-destroy):** + +```glimmer-js +// app/components/chart.gjs +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +class Chart extends Component { + chartInstance = null; + + @action + setupChart(element) { + this.chartInstance = new Chart(element, this.args.config); + } + + willDestroy() { + super.willDestroy(); + // Easy to forget cleanup! + this.chartInstance?.destroy(); + } + + +} +``` + +**✅ Correct (custom modifier with automatic cleanup):** + +```glimmer-js +// app/components/chart.gjs +import chart from '../modifiers/chart'; + + +``` + +For complex state management with automatic cleanup, use `ember-resources`: + +**❌ Incorrect (did-insert for data fetching):** + +```glimmer-js +// app/components/user-profile.gjs +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +class UserProfile extends Component { + @tracked userData = null; + @tracked loading = true; + controller = new AbortController(); + + @action + async loadUser() { + this.loading = true; + try { + const response = await fetch(`/api/users/${this.args.userId}`, { + signal: this.controller.signal, + }); + this.userData = await response.json(); + } finally { + this.loading = false; + } + } + + willDestroy() { + super.willDestroy(); + this.controller.abort(); // Easy to forget! + } + + +} +``` + +**✅ Correct (Resource with automatic cleanup):** + +```glimmer-js +// app/components/user-profile.gjs +import Component from '@glimmer/component'; +import UserDataResource from '../resources/user-data'; + +class UserProfile extends Component { + userData = UserDataResource.from(this, () => [this.args.userId]); + + +} +``` + +| Use Case | Solution | Why | + +| ---------------- | ----------------------------------- | ----------------------------------------- | + +| Computed values | Getters + `@cached` | Reactive, efficient, no lifecycle needed | + +| DOM manipulation | Custom modifiers | Encapsulated, reusable, automatic cleanup | + +| Data fetching | getPromiseState from warp-drive | Declarative, automatic cleanup | + +| Event listeners | `{{on}}` modifier | Built-in, automatic cleanup | + +| Focus management | Custom modifier or ember-focus-trap | Proper lifecycle, accessibility | + +If you have existing code using these hooks: + +1. **Identify the purpose**: What is the hook doing? + +2. **Choose the right alternative**: + + - Deriving data? → Use getters/`@cached` + + - DOM setup/teardown? → Use a custom modifier + + - Async data loading? → Use getPromiseState from warp-drive + +3. **Test thoroughly**: Ensure cleanup happens correctly + +4. **Remove the legacy hook**: Delete `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` + +Modern alternatives provide better performance: + +- **Getters**: Only compute when dependencies change + +- **@cached**: Memoizes expensive computations + +- **Modifiers**: Scoped to specific elements, composable + +- **getPromiseState**: Declarative data loading, automatic cleanup + +❌ **Don't use `willDestroy()` for cleanup when a modifier would work** + +❌ **Don't use `@action` + `did-insert` when a getter would suffice** + +❌ **Don't manually track changes when `@cached` handles it automatically** + +❌ **Don't forget `registerDestructor` in custom modifiers** + +Modern Ember provides superior alternatives to legacy lifecycle hooks: + +- **Derived Data**: Use getters and `@cached` for reactive computations + +- **DOM Side Effects**: Use custom modifiers with `registerDestructor` + +- **Async Data Loading**: Use getPromiseState from warp-drive/reactiveweb + +- **Better Code**: More testable, composable, and maintainable + +**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.** + +### 3.4 Avoid Unnecessary Tracking + +**Impact: HIGH (20-40% fewer invalidations)** + +Only mark properties as `@tracked` if they need to trigger re-renders when changed. Overusing `@tracked` causes unnecessary invalidations and re-renders. + +**Incorrect: everything tracked** + +```javascript +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +class Form extends Component { + @tracked firstName = ''; // Used in template ✓ + @tracked lastName = ''; // Used in template ✓ + @tracked _formId = Date.now(); // Internal, never rendered ✗ + @tracked _validationCache = new Map(); // Internal state ✗ + + @action + validate() { + this._validationCache.set('firstName', this.firstName.length > 0); + // Unnecessary re-render triggered + } +} +``` + +**Correct: selective tracking** + +```javascript +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +class Form extends Component { + @tracked firstName = ''; // Rendered in template + @tracked lastName = ''; // Rendered in template + @tracked isValid = false; // Rendered status + + _formId = Date.now(); // Not tracked - internal only + _validationCache = new Map(); // Not tracked - internal state + + @action + validate() { + this._validationCache.set('firstName', this.firstName.length > 0); + this.isValid = this._validationCache.get('firstName'); + // Only re-renders when isValid changes + } +} +``` + +Only track properties that directly affect the template or other tracked getters to minimize unnecessary re-renders. + +### 3.5 Build Reactive Chains with Dependent Getters + +**Impact: HIGH (Clear data flow and automatic reactivity)** + +Create reactive chains where getters depend on other getters or tracked properties for clear, maintainable data derivation. + +**Incorrect: imperative updates** + +```glimmer-js +// app/components/shopping-cart.gjs +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +class ShoppingCart extends Component { + @tracked items = []; + @tracked subtotal = 0; + @tracked tax = 0; + @tracked shipping = 0; + @tracked total = 0; + + @action + addItem(item) { + this.items = [...this.items, item]; + this.recalculate(); + } + + @action + removeItem(index) { + this.items = this.items.filter((_, i) => i !== index); + this.recalculate(); + } + + recalculate() { + this.subtotal = this.items.reduce((sum, item) => sum + item.price, 0); + this.tax = this.subtotal * 0.08; + this.shipping = this.subtotal > 50 ? 0 : 5.99; + this.total = this.subtotal + this.tax + this.shipping; + } + + +} +``` + +**Correct: reactive getter chains** + +```glimmer-js +// app/components/shopping-cart.gjs +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { TrackedArray } from 'tracked-built-ins'; + +class ShoppingCart extends Component { + @tracked items = new TrackedArray([]); + + // Base calculation + get subtotal() { + return this.items.reduce((sum, item) => sum + item.price, 0); + } + + // Depends on subtotal + get tax() { + return this.subtotal * 0.08; + } + + // Depends on subtotal + get shipping() { + return this.subtotal > 50 ? 0 : 5.99; + } + + // Depends on subtotal, tax, and shipping + get total() { + return this.subtotal + this.tax + this.shipping; + } + + // Derived from total + get formattedTotal() { + return `$${this.total.toFixed(2)}`; + } + + // Multiple dependencies + get discount() { + if (this.items.length >= 5) return this.subtotal * 0.1; + if (this.subtotal > 100) return this.subtotal * 0.05; + return 0; + } + + // Depends on total and discount + get finalTotal() { + return this.total - this.discount; + } + + @action + addItem(item) { + this.items.push(item); + // All getters automatically update! + } + + @action + removeItem(index) { + this.items.splice(index, 1); + // All getters automatically update! + } + + +} +``` + +**Complex reactive chains with @cached:** + +```glimmer-js +// app/components/data-analysis.gjs +import Component from '@glimmer/component'; +import { cached } from '@glimmer/tracking'; + +class DataAnalysis extends Component { + // Base data + get rawData() { + return this.args.data || []; + } + + // Level 1: Filter + @cached + get validData() { + return this.rawData.filter((item) => item.value != null); + } + + // Level 2: Transform (depends on validData) + @cached + get normalizedData() { + const max = Math.max(...this.validData.map((d) => d.value)); + return this.validData.map((item) => ({ + ...item, + normalized: item.value / max, + })); + } + + // Level 2: Statistics (depends on validData) + @cached + get statistics() { + const values = this.validData.map((d) => d.value); + const sum = values.reduce((a, b) => a + b, 0); + const mean = sum / values.length; + const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / values.length; + + return { + count: values.length, + sum, + mean, + stdDev: Math.sqrt(variance), + min: Math.min(...values), + max: Math.max(...values), + }; + } + + // Level 3: Depends on normalizedData and statistics + @cached + get outliers() { + const threshold = this.statistics.mean + 2 * this.statistics.stdDev; + return this.normalizedData.filter((item) => item.value > threshold); + } + + // Level 3: Depends on statistics + get qualityScore() { + const validRatio = this.validData.length / this.rawData.length; + const outlierRatio = this.outliers.length / this.validData.length; + return validRatio * 0.7 + (1 - outlierRatio) * 0.3; + } + + +} +``` + +**Combining multiple tracked sources:** + +```glimmer-js +// app/components/filtered-list.gjs +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { cached } from '@glimmer/tracking'; + +class FilteredList extends Component { + @tracked searchTerm = ''; + @tracked selectedCategory = 'all'; + @tracked sortDirection = 'asc'; + + // Depends on args.items and searchTerm + @cached + get searchFiltered() { + if (!this.searchTerm) return this.args.items; + + const term = this.searchTerm.toLowerCase(); + return this.args.items.filter( + (item) => + item.name.toLowerCase().includes(term) || item.description?.toLowerCase().includes(term), + ); + } + + // Depends on searchFiltered and selectedCategory + @cached + get categoryFiltered() { + if (this.selectedCategory === 'all') return this.searchFiltered; + + return this.searchFiltered.filter((item) => item.category === this.selectedCategory); + } + + // Depends on categoryFiltered and sortDirection + @cached + get sorted() { + const items = [...this.categoryFiltered]; + const direction = this.sortDirection === 'asc' ? 1 : -1; + + return items.sort((a, b) => direction * a.name.localeCompare(b.name)); + } + + // Final result + get items() { + return this.sorted; + } + + // Metadata derived from chain + get resultsCount() { + return this.items.length; + } + + get hasFilters() { + return this.searchTerm || this.selectedCategory !== 'all'; + } + + +} +``` + +Reactive getter chains provide automatic updates, clear data dependencies, and better performance through intelligent caching with @cached. + +Reference: [https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/) + +### 3.6 Component File Naming and Export Conventions + +**Impact: HIGH (Enforces consistent component structure and predictable imports)** + +Follow modern Ember component file conventions: use `.gjs`/`.gts` files with `