Loading...
Loading...
Expert-level Ember.js development. Use when asked to (1) write Ember.js applications with components, services, routes, or controllers, (2) implement Ember Data models and adapters, (3) work with Ember Octane patterns (Glimmer components, tracked properties, modifiers), (4) optimize Ember application performance, (5) write Ember tests with QUnit or testing-library, or when phrases like "Ember component", "Ember route", "Glimmer", "tracked property", "Ember addon" appear.
npx skill4agent add jaredlander/freshbooks-speed javascript-ember// Example: Check for Ember component patterns before creating a component
// Use the context7 MCP to search for relevant documentation
// Query examples:
// - "ember component patterns"
// - "ember routing conventions"
// - "ember data models"
// - "ember testing standards"
// - "glimmer component lifecycle"
// Apply the documentation from context7 to your implementation- "ember component patterns"
- "ember component architecture"
- "glimmer component conventions"
- "[specific component type]" (e.g., "button component", "form component")
- "component props" or "component arguments"
- "component lifecycle"
- "component testing"- "ember routing patterns"
- "ember route conventions"
- "route data loading"
- "route guards" or "route authentication"
- "nested routes"
- "query parameters"- "ember data models"
- "ember data adapters"
- "ember data serializers"
- "api integration"
- "data relationships"
- "data layer patterns"- "ember services"
- "service patterns"
- "shared state management"
- "[specific service]" (e.g., "authentication service", "api service")- "ember testing"
- "component testing"
- "integration tests"
- "acceptance tests"
- "test patterns"
- "test data" or "fixtures"- "ember styling"
- "css conventions"
- "component styles"
- "tailwind" or "[css framework]"- Search for the specific feature: "user profile", "login form", etc.
- Search for the pattern you're implementing: "form validation", "dropdown menu"
- Search for utilities you might need: "validation utilities", "date helpers"// app/components/user-profile.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class UserProfileComponent extends Component {
@service currentUser;
@service router;
@tracked isEditing = false;
@tracked formData = null;
constructor(owner, args) {
super(owner, args);
// Use constructor for one-time setup
this.formData = { ...this.args.user };
}
@action
toggleEdit() {
this.isEditing = !this.isEditing;
if (this.isEditing) {
this.formData = { ...this.args.user };
}
}
@action
async saveUser(event) {
event.preventDefault();
try {
await this.args.onSave(this.formData);
this.isEditing = false;
} catch (error) {
// Handle error
console.error('Save failed:', error);
}
}
@action
updateField(field, event) {
this.formData = {
...this.formData,
[field]: event.target.value
};
}
}
<div class="user-profile">
<form >
<label>
Name:
<input
type="text"
value=
/>
</label>
<label>
Email:
<input
type="email"
value=
/>
</label>
<button type="submit">Save</button>
<button type="button" >Cancel</button>
</form>
<div class="profile-display">
<h2></h2>
<p></p>
<button >Edit Profile</button>
</div>
</div>import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { cached } from '@glimmer/tracking';
export default class DataGridComponent extends Component {
@tracked sortColumn = 'name';
@tracked sortDirection = 'asc';
@tracked filterText = '';
// Use @cached for expensive computations that depend on tracked properties
@cached
get filteredData() {
const { filterText } = this;
if (!filterText) return this.args.data;
const lower = filterText.toLowerCase();
return this.args.data.filter(item =>
item.name.toLowerCase().includes(lower) ||
item.email.toLowerCase().includes(lower)
);
}
@cached
get sortedData() {
const data = [...this.filteredData];
const { sortColumn, sortDirection } = this;
return data.sort((a, b) => {
const aVal = a[sortColumn];
const bVal = b[sortColumn];
const result = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
return sortDirection === 'asc' ? result : -result;
});
}
}// app/services/notification.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class NotificationService extends Service {
@tracked notifications = [];
@action
add(message, type = 'info', duration = 5000) {
const id = Date.now() + Math.random();
const notification = { id, message, type };
this.notifications = [...this.notifications, notification];
if (duration > 0) {
setTimeout(() => this.remove(id), duration);
}
return id;
}
@action
remove(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
}
@action
success(message, duration) {
return this.add(message, 'success', duration);
}
@action
error(message, duration = 10000) {
return this.add(message, 'error', duration);
}
@action
clear() {
this.notifications = [];
}
}// app/modifiers/click-outside.js
import { modifier } from 'ember-modifier';
export default modifier((element, [callback]) => {
function handleClick(event) {
if (!element.contains(event.target)) {
callback(event);
}
}
document.addEventListener('click', handleClick, true);
return () => {
document.removeEventListener('click', handleClick, true);
};
});
<div class="dropdown">
</div>// app/router.js
import EmberRouter from '@ember/routing/router';
import config from 'my-app/config/environment';
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
Router.map(function () {
this.route('dashboard', { path: '/' });
this.route('users', function () {
this.route('index', { path: '/' });
this.route('new');
this.route('user', { path: '/:user_id' }, function () {
this.route('edit');
this.route('settings');
});
});
this.route('not-found', { path: '/*path' });
});// app/routes/users/user.js
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class UsersUserRoute extends Route {
@service store;
@service router;
async model(params) {
try {
return await this.store.findRecord('user', params.user_id, {
include: 'profile,settings'
});
} catch (error) {
if (error.errors?.[0]?.status === '404') {
this.router.transitionTo('not-found');
}
throw error;
}
}
// Redirect if user doesn't have permission
afterModel(model) {
if (!this.currentUser.canViewUser(model)) {
this.router.transitionTo('dashboard');
}
}
// Reset controller state on exit
resetController(controller, isExiting) {
if (isExiting) {
controller.setProperties({
queryParams: {},
isEditing: false
});
}
}
}// app/routes/users/user/loading.js
import Route from '@ember/routing/route';
export default class UsersUserLoadingRoute extends Route {}
<div class="loading-spinner">
<p>Loading user...</p>
</div>// app/models/user.js
import Model, { attr, hasMany, belongsTo } from '@ember-data/model';
export default class UserModel extends Model {
@attr('string') name;
@attr('string') email;
@attr('date') createdAt;
@attr('boolean', { defaultValue: true }) isActive;
@attr('number') loginCount;
@belongsTo('profile', { async: true, inverse: 'user' }) profile;
@hasMany('post', { async: true, inverse: 'author' }) posts;
// Computed properties still work but use native getters
get displayName() {
return this.name || this.email?.split('@')[0] || 'Anonymous';
}
get isNewUser() {
const daysSinceCreation = (Date.now() - this.createdAt) / (1000 * 60 * 60 * 24);
return daysSinceCreation < 7;
}
}// app/adapters/application.js
import JSONAPIAdapter from '@ember-data/adapter/json-api';
import { inject as service } from '@ember/service';
export default class ApplicationAdapter extends JSONAPIAdapter {
@service session;
host = 'https://api.example.com';
namespace = 'v1';
get headers() {
const headers = {};
if (this.session.isAuthenticated) {
headers['Authorization'] = `Bearer ${this.session.data.authenticated.token}`;
}
return headers;
}
handleResponse(status, headers, payload, requestData) {
if (status === 401) {
this.session.invalidate();
}
return super.handleResponse(status, headers, payload, requestData);
}
}// app/serializers/application.js
import JSONAPISerializer from '@ember-data/serializer/json-api';
export default class ApplicationSerializer extends JSONAPISerializer {
// Normalize date strings to Date objects
normalizeDateFields(hash) {
const dateFields = ['createdAt', 'updatedAt', 'publishedAt'];
dateFields.forEach(field => {
if (hash[field]) {
hash[field] = new Date(hash[field]);
}
});
return hash;
}
normalize(modelClass, resourceHash) {
this.normalizeDateFields(resourceHash.attributes || {});
return super.normalize(modelClass, resourceHash);
}
}// tests/integration/components/user-profile-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | user-profile', function (hooks) {
setupRenderingTest(hooks);
test('it displays user information', async function (assert) {
this.set('user', {
name: 'Jane Doe',
email: 'jane@example.com'
});
await render(hbs`<UserProfile @user={{this.user}} />`);
assert.dom('h2').hasText('Jane Doe');
assert.dom('p').hasText('jane@example.com');
});
test('it allows editing when canEdit is true', async function (assert) {
this.owner.lookup('service:current-user').canEdit = true;
this.set('user', {
name: 'Jane Doe',
email: 'jane@example.com'
});
this.set('onSave', () => {});
await render(hbs`
<UserProfile @user={{this.user}} @onSave={{this.onSave}} />
`);
await click('button:contains("Edit Profile")');
assert.dom('form').exists();
assert.dom('input[type="text"]').hasValue('Jane Doe');
await fillIn('input[type="text"]', 'Jane Smith');
await click('button[type="submit"]');
assert.dom('form').doesNotExist();
});
});// tests/acceptance/user-flow-test.js
import { module, test } from 'qunit';
import { visit, currentURL, click, fillIn } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Acceptance | user flow', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
test('visiting /users and creating a new user', async function (assert) {
await visit('/users');
assert.strictEqual(currentURL(), '/users');
assert.dom('h1').hasText('Users');
await click('a:contains("New User")');
assert.strictEqual(currentURL(), '/users/new');
await fillIn('[data-test-name-input]', 'John Doe');
await fillIn('[data-test-email-input]', 'john@example.com');
await click('[data-test-submit]');
assert.strictEqual(currentURL(), '/users/1');
assert.dom('[data-test-user-name]').hasText('John Doe');
});
});| Problem | Solution |
|---|---|
| Unnecessary component re-renders | Use |
| Large lists | Use |
| Slow Ember Data queries | Optimize includes, use custom serializers |
| Bundle size | Use route-based code splitting, lazy engines |
| Memory leaks | Properly clean up in willDestroy, cancel timers |
// ❌ Mutating tracked properties directly
this.items.push(newItem); // Won't trigger reactivity
// ✅ Replace the entire array
this.items = [...this.items, newItem];
// ❌ Creating new functions in templates
{{on "click" (fn this.handleClick item)}}
// ✅ Use actions or stable references
@action handleItemClick(item) { /* ... */ }
{{on "click" (fn this.handleItemClick item)}}
// ❌ Not using @cached for expensive computations
get expensiveComputation() {
return this.data.filter(/* complex logic */);
}
// ✅ Use @cached
@cached
get expensiveComputation() {
return this.data.filter(/* complex logic */);
}app/
├── components/ # Glimmer components
│ └── user-profile/
│ ├── component.js
│ ├── index.hbs
│ └── styles.css
├── controllers/ # Controllers (use sparingly in Octane)
├── helpers/ # Template helpers
├── modifiers/ # Custom modifiers
├── models/ # Ember Data models
├── routes/ # Route classes
├── services/ # Services
├── templates/ # Route templates
├── adapters/ # Ember Data adapters
├── serializers/ # Ember Data serializers
├── styles/ # Global styles
└── app.js
tests/
├── integration/ # Component tests
├── unit/ # Unit tests (models, services, etc.)
└── acceptance/ # Full application tests| Category | Tool | Notes |
|---|---|---|
| CLI | ember-cli | Official tooling |
| Testing | QUnit + ember-qunit | Built-in, well integrated |
| Linting | ESLint + ember-template-lint | Catch template issues |
| Formatting | Prettier | Use with ember-template-lint |
| Mocking | ember-cli-mirage | API mocking for tests |
| State management | Services + tracked | Built-in, no extra deps |
| HTTP | fetch or ember-fetch | Native or polyfilled |
// app/components/registration-form.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class RegistrationFormComponent extends Component {
@service notification;
@tracked email = '';
@tracked password = '';
@tracked confirmPassword = '';
@tracked errors = {};
@tracked isSubmitting = false;
get isValid() {
return (
this.email &&
this.password.length >= 8 &&
this.password === this.confirmPassword &&
Object.keys(this.errors).length === 0
);
}
@action
updateEmail(event) {
this.email = event.target.value;
this.validateEmail();
}
@action
updatePassword(event) {
this.password = event.target.value;
this.validatePassword();
}
@action
updateConfirmPassword(event) {
this.confirmPassword = event.target.value;
this.validateConfirmPassword();
}
validateEmail() {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!this.email) {
this.errors = { ...this.errors, email: 'Email is required' };
} else if (!emailRegex.test(this.email)) {
this.errors = { ...this.errors, email: 'Invalid email format' };
} else {
const { email, ...rest } = this.errors;
this.errors = rest;
}
}
validatePassword() {
if (this.password.length < 8) {
this.errors = { ...this.errors, password: 'Password must be at least 8 characters' };
} else {
const { password, ...rest } = this.errors;
this.errors = rest;
}
// Re-validate confirm password if it's filled
if (this.confirmPassword) {
this.validateConfirmPassword();
}
}
validateConfirmPassword() {
if (this.password !== this.confirmPassword) {
this.errors = { ...this.errors, confirmPassword: 'Passwords do not match' };
} else {
const { confirmPassword, ...rest } = this.errors;
this.errors = rest;
}
}
@action
async submit(event) {
event.preventDefault();
if (!this.isValid) return;
this.isSubmitting = true;
try {
await this.args.onSubmit({
email: this.email,
password: this.password
});
this.notification.success('Registration successful!');
} catch (error) {
this.notification.error(error.message || 'Registration failed');
} finally {
this.isSubmitting = false;
}
}
}// app/modifiers/infinite-scroll.js
import { modifier } from 'ember-modifier';
export default modifier((element, [callback], { threshold = 200 }) => {
let isLoading = false;
function handleScroll() {
if (isLoading) return;
const { scrollTop, scrollHeight, clientHeight } = element;
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
if (distanceFromBottom < threshold) {
isLoading = true;
callback().finally(() => {
isLoading = false;
});
}
}
element.addEventListener('scroll', handleScroll, { passive: true });
return () => {
element.removeEventListener('scroll', handleScroll);
};
});ember install ember-cli-typescript// app/components/user-profile.ts
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import type { TOC } from '@ember/component/template-only';
interface UserProfileArgs {
user: {
name: string;
email: string;
avatarUrl?: string;
};
onSave: (data: UserData) => Promise<void>;
canEdit?: boolean;
}
interface UserData {
name: string;
email: string;
}
export default class UserProfileComponent extends Component<UserProfileArgs> {
@tracked isEditing = false;
@tracked formData: UserData | null = null;
@action
async saveUser(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!this.formData) return;
await this.args.onSave(this.formData);
this.isEditing = false;
}
}
// Template-only component signature
export interface GreetingSignature {
Element: HTMLDivElement;
Args: {
name: string;
};
}
const Greeting: TOC<GreetingSignature> = <template>
<div ...attributes>Hello {{@name}}!</div>
</template>;
export default Greeting;