Compare commits

...

400 Commits

Author SHA1 Message Date
raucao 14827fce3e Delete own photos
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Failing after 57s
2026-05-05 12:07:33 +02:00
raucao 7a109c9ba5 Fix portrait photos using thumb for full size variant
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 55s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-05-05 10:16:12 +02:00
raucao 10aae3c9b3 Add photo action for copying event ID 2026-05-05 09:56:12 +02:00
raucao b492e2aa89 Add dropdown component, photo actions menu 2026-05-05 09:49:20 +02:00
raucao 4c4a53ae42 1.21.2
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 55s
2026-05-05 07:18:37 +02:00
raucao a240a5d199 Serve dev on all IPs 2026-05-05 07:17:30 +02:00
raucao 0332cf4c3c Merge pull request 'Hide quick-search pills on low zoom levels' (#53) from ui/pills_on_zoom into master
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 54s
Reviewed-on: #53
2026-05-05 05:14:54 +00:00
raucao 59c447fe1f Hide quick-search pills on low zoom levels
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-05-05 07:10:28 +02:00
raucao 1140ecfe41 Add buttons for opening signer app, copying connect link
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 59s
2026-05-05 07:02:08 +02:00
raucao 60936ed2f5 1.21.1
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 56s
2026-04-27 21:51:55 +01:00
raucao ca82a029bc Fix app menu section layout 2026-04-27 21:50:47 +01:00
raucao 0630aed73d Fix modals 2026-04-27 21:50:36 +01:00
raucao f27a636529 1.21.0
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-27 21:26:23 +01:00
raucao 995ae95b09 Merge pull request 'Add full-size photo gallery' (#52) from feature/photo-gallery into master
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 58s
Reviewed-on: #52
2026-04-27 20:24:12 +00:00
raucao 0fb320d996 Fix test and linter error
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-27 21:20:39 +01:00
raucao 4f4ca827b1 Refactor carousel and gallery to share the carousel component
And make the gallery awesome
2026-04-27 21:12:46 +01:00
raucao c1d3f25d50 WIP Add basic photo gallery 2026-04-27 16:45:49 +01:00
raucao 2087cfc4f7 Merge pull request 'Various search UI improvements' (#51) from ui/search into master
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s
Reviewed-on: #51
2026-04-27 14:50:02 +00:00
raucao 8572032481 Eliminate race condition in tests
CI / Lint (pull_request) Successful in 33s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-27 15:47:23 +01:00
raucao b4c3f5c88d Improve map centering on mobile
CI / Lint (pull_request) Successful in 33s
CI / Test (pull_request) Failing after 1m1s
2026-04-27 15:37:13 +01:00
raucao cff19980d5 Refactor search route/loading
* Fetch results asynchronously after app launch
* Hide sidebar and search results when new search is issued
2026-04-27 15:18:17 +01:00
raucao cf251f702b Prevent zoom when opening place from search results 2026-04-27 14:44:35 +01:00
raucao d2eb888dcf Keep search results, only hide sidebar when closed 2026-04-27 14:37:19 +01:00
raucao a0b4a4b3f3 Show toast notification when adding RS account 2026-04-27 12:47:33 +01:00
raucao cb3ee48909 Update rs.js
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s
2026-04-27 10:32:51 +01:00
raucao 1d022b21bd 1.20.5
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
2026-04-25 11:10:44 +01:00
raucao 3e831a7686 Enable relay pings
This should re-establish relay connections when PWAs are being woken up
after background sleep
2026-04-25 11:08:55 +01:00
raucao 2943125dbd 1.20.4
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-25 10:55:20 +01:00
raucao a32ad7572b Add more marker icons
CI / Lint (push) Successful in 32s
CI / Test (push) Has been cancelled
2026-04-25 10:53:53 +01:00
raucao a1b3957c83 Fix photo icon in map markers 2026-04-25 10:42:05 +01:00
raucao 9f2f233c22 Adjust JPEG quality for large photos 2026-04-25 10:41:39 +01:00
raucao 1ba4afdf08 1.20.3
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-24 13:55:07 +01:00
raucao d764134513 Remove superfluous publishing status alert 2026-04-24 13:53:40 +01:00
raucao e38f540c79 1.20.2
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-24 12:28:08 +01:00
raucao 73ad5b4eb1 Disable closing modal during photo upload 2026-04-24 12:24:19 +01:00
raucao b4a70233cf Show detailed photo upload status
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
2026-04-24 11:56:37 +01:00
raucao cb4b9c6b40 Render portrait thumbnails as squares on mobile
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 56s
A bit too small otherwise
2026-04-24 11:01:58 +01:00
raucao 98dcb4f25b 1.20.1
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 57s
2026-04-23 09:23:41 +01:00
raucao 7709634a9a Merge pull request 'Clear Nostr event cache from Settings' (#47) from feature/clear_nostr_cache into master
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
Reviewed-on: #47
2026-04-23 08:22:02 +00:00
raucao 3ddc85669f Clear nostr event cache from Settings
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-04-23 09:19:25 +01:00
raucao 95961e680f Add rationale for kind numbers
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 58s
2026-04-22 15:37:45 +04:00
raucao 9468a6a0cc Revise photos NIP draft
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-22 15:31:24 +04:00
raucao c9465c8fa8 1.20.0
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-22 13:33:55 +04:00
raucao 6c5c1fea27 Merge pull request 'Connect Nostr, integrate place photos' (#45) from feature/nostr_place_reviews into master
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s
Reviewed-on: #45
2026-04-22 09:31:59 +00:00
raucao fe41369754 Reset scroll position when switching between places
CI / Lint (pull_request) Successful in 36s
CI / Test (pull_request) Successful in 1m3s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-04-22 13:10:11 +04:00
raucao 1498c5a713 Improve dropzone size 2026-04-22 13:02:12 +04:00
raucao b6e2964f8e Show placeholder on mobile when photos not filling space 2026-04-22 12:40:45 +04:00
raucao d1d179bb93 Lazy-load place photos
Only preload photos in view as well as the next one(s), not all of them
2026-04-22 12:02:44 +04:00
raucao b83a16bf13 Use button element for add-photo link 2026-04-22 11:32:57 +04:00
raucao c853418fbb Fix auto-scroll to new photo on mobile 2026-04-22 11:32:37 +04:00
raucao 4fed8c05c5 Change routing to always use OSM IDs except for custom places
Also implements a short term cache for OSM place data, so we can load it
multiple times without multiplying network requests where needed
2026-04-22 11:01:32 +04:00
raucao 670128cbda Immediately render newly uploaded photo and scroll to it 2026-04-22 10:38:06 +04:00
raucao d8fa30c74b Revert to single photo per upload and event
See NIP changes for reasoning. It also keeps the UI a bit cleaner and
we don't have to queue processing on mobile for mass uploads.
2026-04-22 10:18:47 +04:00
raucao 0f8d7046ac Improve blurhash decode warning, use valid hashes in tests 2026-04-22 09:19:31 +04:00
raucao 8ca7481a79 Mock nostr service globally in tests 2026-04-22 09:03:38 +04:00
raucao cd25c55bd7 Smaller gap between photos in carousel on mobile 2026-04-22 08:44:17 +04:00
raucao 32c4f7da57 Add integration tests for carousel 2026-04-22 08:34:03 +04:00
raucao 71939a30c3 Fix carousel chevron links
Correct color, hide while disabled
2026-04-22 08:30:55 +04:00
raucao 7285ace882 Move parsing of place photos to util
Can be used in gallery later
2026-04-22 07:40:18 +04:00
raucao 94ba33ecc1 Render all place photos in a carousel 2026-04-22 07:32:55 +04:00
raucao 85a8699b78 Render header photo in place details
Shows the blurhash and fades in the image once downloaded
2026-04-21 23:07:06 +04:00
raucao 99cfd96ca1 Fetch and cache photo events while browsing map and when opening place details 2026-04-21 21:28:57 +04:00
raucao 8d40b3bb35 Add and use relay list settings 2026-04-21 19:16:52 +04:00
raucao c5316bf336 Refactor settings, DRY up everything 2026-04-21 15:59:55 +04:00
raucao a384e83dd0 Cache own Nostr avatar image 2026-04-21 15:17:04 +04:00
raucao b23d54d74f Cache user profile/settings events in IndexedDB 2026-04-21 14:58:05 +04:00
raucao 5bd4dba907 Refactor settings menu, add Nostr settings
Adds a setting to control if photos should be uploaded only to the
main/default server, or all known servers of a user.

Only upload to the main server by default, to speed up adding photos.
2026-04-21 14:39:38 +04:00
raucao 54ba99673f Break up settings into sub sections 2026-04-21 14:17:14 +04:00
raucao 54445f249b Fix app menu header height
Wasn't the same between main menu and sub menus
2026-04-21 14:16:42 +04:00
raucao 9828ad2714 Close photo upload window, show toast when published 2026-04-21 14:16:05 +04:00
raucao a89ba904c8 Pluralize button text based on number of files 2026-04-21 09:38:25 +04:00
raucao 4c540bc713 Rename component, clean up CSS 2026-04-21 09:28:34 +04:00
raucao bb2411972f Improve upload item UI 2026-04-21 09:17:44 +04:00
raucao 5cd384cf3a Do sequential image processing/uploads on mobile
Uploading multiple large files at once can fail easily
2026-04-20 19:37:24 +04:00
raucao ec31d1a59b Harden image processing, improve image quality 2026-04-20 18:10:48 +04:00
raucao 4f55f26851 Move image processing to worker 2026-04-20 16:56:51 +04:00
raucao b7cce6eb7e Improve dropzone styles 2026-04-20 16:39:37 +04:00
raucao 79777fb51a Process images before upload, add thumbnails, blurhash 2026-04-20 16:24:28 +04:00
raucao 1ed66ca744 Fix deprecation warning 2026-04-20 15:26:28 +04:00
raucao a2a61b0fec Upload to multiple servers, delete from servers when removing in dialog
Introduces a dedicated blossom service to tie everything together
2026-04-20 15:22:17 +04:00
raucao d9ba73559e WIP Upload multiple photos 2026-04-20 14:25:15 +04:00
raucao f1ebafc1f0 Fix default blossom server, move to constant 2026-04-20 14:06:41 +04:00
raucao 10501b64bd Fix avatar placement for new avatar image 2026-04-20 14:06:13 +04:00
raucao 7607f27013 Upload photos to user's Blossom server 2026-04-20 13:55:13 +04:00
raucao 8cc579e271 Load user profile from Nostr, display name and avatar 2026-04-20 13:37:05 +04:00
raucao 3a56464926 Improve Nostr connect UI 2026-04-20 13:09:51 +04:00
raucao 1dc0c4119b Refactor Nostr auth service 2026-04-20 12:34:44 +04:00
raucao c57a665655 Add applesauce debug logs, fix aggressive connect timeout 2026-04-20 12:14:45 +04:00
raucao 6cfe2b40b9 Add bunker login for desktop via QR code 2026-04-19 16:01:45 +04:00
raucao 99d8ca9174 Create dedicated Nostr Connect component, use nsec.app relay 2026-04-19 15:15:55 +04:00
raucao 629a308b79 Connect Nostr via mobile app 2026-04-19 14:45:11 +04:00
raucao 798ed0c8dd Use camera icon from Feather 2026-04-19 14:44:37 +04:00
raucao 03583e5a52 Add generic modal component, refactor photo upload modal (WIP) 2026-04-19 11:09:41 +04:00
raucao b9f64f30e1 Cut off overlong account status lines with ellipsis 2026-04-19 11:09:23 +04:00
raucao 4bd5c4bf2a Load signer on launch, disconnect or switch pubkey if necessary 2026-04-19 11:08:55 +04:00
raucao f875fc1877 WIP Add Nostr auth 2026-04-18 18:36:35 +04:00
raucao 2268a607d5 Format docs 2026-04-18 18:35:45 +04:00
raucao f01b5f8faa Add place photos NIP, update reviews NIP 2026-04-18 17:30:25 +04:00
raucao 9075089221 Update notes 2026-04-18 12:47:33 +04:00
raucao 5ad702e6e6 Add planning docs for Nostr place reviews 2026-04-18 11:35:29 +04:00
raucao bae01a3c9b Add Nostr agent skills
CI / Lint (push) Successful in 29s
CI / Test (push) Successful in 45s
2026-04-14 10:34:43 +04:00
raucao 0efc8994e9 Add AGENTS.md
CI / Lint (push) Successful in 29s
CI / Test (push) Successful in 44s
2026-04-14 10:20:59 +04:00
raucao 5c71523d90 1.19.1
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 45s
2026-04-13 13:17:52 +04:00
raucao bea1b97fb7 Update remotestorage-widget 2026-04-13 13:16:54 +04:00
raucao 6ba1cf31cf Show RS widget when Unauthorized error received
Allows the user to re-auth via the widget
2026-04-13 13:16:49 +04:00
raucao 9cdd021cda 1.19.0
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
2026-04-12 16:17:22 +04:00
raucao ef53870b35 Merge pull request 'Add mobile numbers and WhatsApp links to place details' (#41) from feature/place_details into master
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 44s
Reviewed-on: #41
2026-04-12 12:15:25 +00:00
raucao 918a794784 Fix lint errors
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 43s
Release Drafter / Update release notes draft (pull_request) Successful in 8s
2026-04-12 16:10:05 +04:00
raucao 344a3067fa Add WhatsApp numbers/links
CI / Lint (pull_request) Failing after 32s
CI / Test (pull_request) Successful in 47s
2026-04-12 16:06:02 +04:00
raucao ad3e6ea402 Remove spaces and dashes from tel links 2026-04-12 15:47:22 +04:00
raucao 9e2545da7b Add mobile phone numbers 2026-04-12 15:42:36 +04:00
raucao 480c97fb9d 1.18.4
CI / Lint (push) Successful in 29s
CI / Test (push) Successful in 45s
2026-04-02 14:41:19 +04:00
raucao 179cf49370 Fix OSM auth not being loaded correctly on launch 2026-04-02 14:40:20 +04:00
raucao aea0388267 1.18.3
CI / Lint (push) Successful in 29s
CI / Test (push) Successful in 44s
2026-04-02 12:35:40 +04:00
raucao e4d02cda26 Tweak font weight 2026-04-02 12:34:53 +04:00
raucao 27ebbaca60 1.18.2
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 44s
2026-04-02 12:21:59 +04:00
raucao cbdd056dcb Optimize font-weight for account status 2026-04-02 12:20:53 +04:00
raucao 2423b67f94 Use SVG icon for locate-me button 2026-04-02 12:20:37 +04:00
raucao 2a3ad26eb9 Prevent OL from loading webfonts for our default font
* Fixes flash of bold text on app launch
* Fixes Noto Sans being used instead of system font where not available
2026-04-02 11:42:19 +04:00
raucao 9d06898b15 1.18.1
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 44s
2026-04-01 19:26:05 +04:00
raucao 6df43edbf9 Fix OSM client ID missing in release build 2026-04-01 19:25:16 +04:00
raucao e2fcdce154 1.18.0
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 46s
2026-04-01 19:14:03 +04:00
raucao 829dff9839 Merge pull request 'Connect OSM account' (#40) from feature/osm_auth into master
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 47s
Reviewed-on: #40
2026-04-01 15:12:46 +00:00
raucao e7dfed204e Connect OSM account
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 49s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-01 18:46:19 +04:00
raucao 2dfd411837 Merge pull request 'Improve user menu layout, icons' (#39) from feature/user_menu into master
CI / Lint (push) Successful in 29s
CI / Test (push) Failing after 44s
Reviewed-on: #39
2026-04-01 13:33:07 +00:00
raucao 5afece5f51 Add nostrich icons for future use
CI / Lint (pull_request) Successful in 45s
CI / Test (pull_request) Successful in 47s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-01 17:16:56 +04:00
raucao f1979282bd Use map icon for OSM in user menu 2026-04-01 17:16:36 +04:00
raucao 04181d5772 Use actual remoteStorage icon in user menu 2026-04-01 15:56:31 +04:00
raucao b4ab1e926d Keep icon imports sorted properly 2026-04-01 15:56:16 +04:00
raucao 9b83d35b40 User menu: Move connection status to respective account type 2026-04-01 15:36:29 +04:00
raucao beda03c2ac Update status doc 2026-04-01 14:42:07 +04:00
raucao ecbac12440 Add ember-best-practices skill 2026-04-01 12:59:41 +04:00
raucao 913d5c915c 1.17.2
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
2026-03-28 16:49:03 +04:00
raucao 89f667b17e Add more icons 2026-03-28 16:48:14 +04:00
raucao 22d4ef8d96 Update Pinhead 2026-03-28 16:47:53 +04:00
raucao b17793af9d 1.17.1
CI / Lint (push) Successful in 29s
CI / Test (push) Failing after 44s
2026-03-28 15:32:21 +04:00
raucao dc9e0f210a Hide search result markers when result is selected 2026-03-28 15:30:27 +04:00
raucao 2b219fe0cf 1.17.0
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
2026-03-27 15:17:16 +04:00
raucao 9fd6c4d64d Merge pull request 'When search requests fail, show error in toast notifications instead of empty search results' (#38) from feature/failed_requests into master
CI / Lint (push) Successful in 28s
CI / Test (push) Failing after 44s
Reviewed-on: #38
2026-03-27 11:12:58 +00:00
raucao 8e5b2c7439 Fix lint error
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 44s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-03-27 15:05:56 +04:00
raucao 0f29430e1a When request retries exhaust, show error in toast notification
CI / Lint (pull_request) Failing after 29s
CI / Test (pull_request) Failing after 44s
2026-03-27 15:01:04 +04:00
raucao 0059d89cc3 Add toast notifications 2026-03-27 15:00:36 +04:00
raucao 54e2766dc4 Merge pull request 'Add setting for hiding quick search buttons' (#36) from feature/settings_quick-search into master
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
Reviewed-on: #36
2026-03-27 10:09:18 +00:00
raucao 5978f67d48 Add setting for hiding quick search buttons
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 44s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-03-27 13:59:36 +04:00
raucao d72e5f3de2 Merge pull request 'Add category search, and search result markers with icons' (#35) from feature/poi_type_search into master
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 47s
Reviewed-on: #35
2026-03-27 09:12:28 +00:00
raucao 582ab4f8b3 Fix lint errors
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 47s
Release Drafter / Update release notes draft (pull_request) Successful in 38s
2026-03-23 18:32:18 +04:00
raucao 0ac6db65cb Add parking icon
CI / Lint (pull_request) Waiting to run
CI / Test (pull_request) Waiting to run
2026-03-23 18:21:41 +04:00
raucao 86b20fd474 Abort search requests when clearing search box
Also adds abort support for Photon queries
2026-03-23 18:07:29 +04:00
raucao 8478e00253 Add loading indicator for search queries 2026-03-23 17:50:21 +04:00
raucao 818ec35071 Ensure map marker clicks preserve search context
Fixes the back button just closing the sidebar and clearing the whole
search after having seleted a result via map marker
2026-03-23 16:42:32 +04:00
raucao 46605dbd32 Add more icons 2026-03-23 16:07:33 +04:00
raucao bcc51efecc Add catch-alls for place icons, staring with shop 2026-03-23 15:12:07 +04:00
raucao 8bec4b978e Ignore certain public transport results in nearby search 2026-03-23 15:00:39 +04:00
raucao cd9676047d Add more icons 2026-03-23 14:42:41 +04:00
raucao a92b44ec13 Ensure nearby search isn't doing category search 2026-03-23 13:51:11 +04:00
raucao 0c2d1f8419 Don't include coffee places in restaurant search 2026-03-22 19:23:16 +04:00
raucao bb77ed8337 Add more icons 2026-03-22 19:13:19 +04:00
raucao 438bf0c31c Add icons to search result markers 2026-03-22 14:05:49 +04:00
raucao af57e7fe57 Add map markers for search results 2026-03-22 10:59:11 +04:00
raucao 9183e3c366 Cache category search results
And abort ongoing searches when there's a new query
2026-03-20 19:27:26 +04:00
raucao 7e98b6796c Integrate category search with search box 2026-03-20 18:56:18 +04:00
raucao 8e9beb16de WIP Integrate category search with search box 2026-03-20 18:39:51 +04:00
raucao b083c1d001 feat(search): add category search support and sync with chips 2026-03-20 18:14:02 +04:00
raucao 4008a8c883 Use "Results" header for category search results 2026-03-20 17:59:11 +04:00
raucao eb7cff7ff5 Add tests for category quick search 2026-03-20 17:49:54 +04:00
raucao db6478e353 Clear category param when typing new search 2026-03-20 17:42:36 +04:00
raucao b39d92b7c4 Fix lint errors 2026-03-20 17:30:49 +04:00
raucao aa99e5d766 Add icons for all quick search categories 2026-03-20 17:26:03 +04:00
raucao 5fd4ebe184 Centrally define filled icons
So we don't have to manually pass the option everywhere
2026-03-20 16:55:19 +04:00
raucao f2a2d910a0 WIP Search places by category 2026-03-20 16:43:57 +04:00
raucao 6b37508f66 Merge pull request 'Disable edit button while editing' (#34) from ui/edit_button into master
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 1m0s
Reviewed-on: #34
2026-03-18 15:13:15 +00:00
raucao 8106009677 Disable edit button while editing
CI / Lint (pull_request) Successful in 51s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 19:09:59 +04:00
raucao 07489c43a4 Merge pull request 'Add Pinhead iconset, icon for cuisine' (#33) from feature/pinhead_icons into master
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 1m0s
Reviewed-on: #33
2026-03-18 14:57:22 +00:00
raucao a4e375cb51 Add Pinhead info to FOSS table in About section
CI / Lint (pull_request) Successful in 51s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 18:34:49 +04:00
raucao b680769eac Replace "cuisine" with icon in place details 2026-03-18 18:17:17 +04:00
raucao 4a609c8388 Add pinhead icons
https://pinhead.ink
2026-03-18 18:16:47 +04:00
raucao cfcaaea3ec Update status doc
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 57s
2026-03-18 17:42:50 +04:00
raucao 2f440d4971 1.16.0
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
2026-03-18 14:48:49 +04:00
raucao 1c6cbe6b0f Merge pull request 'Update OSM data when opening saved places' (#32) from feature/update_place_data into master
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #32
2026-03-18 10:46:33 +00:00
raucao bdd5db157c Update OSM data when opening saved places
CI / Lint (pull_request) Successful in 49s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 14:42:15 +04:00
raucao f7c40095d5 1.15.4
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 58s
2026-03-17 20:08:37 +04:00
raucao 579892067e Tweak sidebar width 2026-03-17 20:07:49 +04:00
raucao 48f87f98d6 Remove obsolete styles
CI / Lint (push) Successful in 51s
CI / Test (push) Successful in 57s
2026-03-17 16:59:54 +04:00
raucao 3cd1b32af9 1.15.3
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 57s
2026-03-17 16:55:31 +04:00
raucao 462404b53e Fix sidebar content padding, class name 2026-03-17 16:54:41 +04:00
raucao e3147caa90 1.15.2
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 59s
2026-03-17 16:35:10 +04:00
raucao 7c11fefdb7 Tweak whitespace 2026-03-17 16:34:19 +04:00
raucao 9af6636971 1.15.1
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 57s
2026-03-17 16:30:18 +04:00
raucao 8ae7fd1fd8 Tweak font sizes
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 56s
2026-03-17 16:25:55 +04:00
raucao cb3fbade39 Prevent unwanted zoom on iOS
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 56s
2026-03-17 16:17:16 +04:00
raucao 6dfd9765b4 1.15.0
CI / Lint (push) Successful in 53s
CI / Test (push) Successful in 56s
2026-03-17 15:55:15 +04:00
raucao 45eb8f087d Add linting to preversion script 2026-03-17 15:53:58 +04:00
raucao 3630fae133 Merge pull request 'Improve app menu sidebar, add separate subsections, more content' (#31) from feature/app_menu into master
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #31
2026-03-17 11:52:42 +00:00
raucao 1116161e93 Add info/links for preferred contributions
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 18s
2026-03-17 15:48:11 +04:00
raucao 88eb0ac0c1 WIP About section
* Add fold-out sections for additional details
* Use table for Open Source info, add licenses
* Move link styles to CSS variables
2026-03-17 15:12:29 +04:00
raucao 6da004e199 Merge pull request 'Increase sidebar width, improve auto-panning' (#30) from ui/autopan into feature/app_menu
Reviewed-on: #30
2026-03-17 09:20:46 +00:00
raucao 8877a9e1c8 Remove unnecessary whitespace
CI / Lint (pull_request) Successful in 49s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 18s
2026-03-17 13:18:17 +04:00
raucao 03d6a86569 Increase sidebar width, improve auto-panning
CI / Lint (pull_request) Failing after 54s
CI / Test (pull_request) Has been cancelled
* Increase sidebar with on desktop to fit more content
* Move sidebar width to CSS variable
* Auto-pan when a selected place would be obstructed by the desktop
  sidebar
* Consider the header obscuring selected places, too
* Only autopan when actually necessary
2026-03-17 13:14:21 +04:00
raucao 5baebbb846 Add details elements/sections to App Menu sidebar
Starting with some About details
2026-03-16 18:23:46 +04:00
raucao dca2991754 Style dropdown menu form controls 2026-03-16 17:07:38 +04:00
raucao aee7f9d181 Move Photon API URL to Settings 2026-03-16 17:07:24 +04:00
raucao 56a077cceb Change release drafter token config
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 57s
Another day, another try
2026-03-16 16:24:40 +04:00
raucao 7e5a034cac Merge pull request 'Prevent app icon loading when opening the app menu' (#29) from dev/app-icon into master
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 56s
Reviewed-on: #29
2026-03-16 12:06:49 +00:00
raucao 5892bd0cda Prevent app icon loading when opening the app menu
CI / Lint (pull_request) Successful in 50s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Has been cancelled
Compile into app code by importing the icon in JS.
2026-03-16 16:02:57 +04:00
raucao baaf027900 Fix missing token
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Hopefully
2026-03-14 16:57:52 +04:00
raucao beb3d12169 Merge pull request 'Refactor app menu sidebar' (#28) from feature/app_menu into master
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #28
2026-03-14 12:40:53 +00:00
raucao a5aa396411 Merge branch 'master' into feature/app_menu
CI / Lint (pull_request) Successful in 48s
CI / Test (pull_request) Successful in 58s
Release Drafter / Update release notes draft (pull_request) Failing after 17s
2026-03-14 16:31:42 +04:00
raucao 21cb8e6cc2 Add release drafter workflow
CI / Lint (push) Successful in 54s
CI / Test (push) Successful in 1m8s
2026-03-14 16:31:22 +04:00
raucao 12ec7fcbbf Add release drafter config
CI / Lint (push) Successful in 52s
CI / Test (push) Has been cancelled
2026-03-14 16:30:23 +04:00
raucao 78d7aeba2c Refactor app menu sidebar
* Rename to "app menu" across code
* Move content to dedicated sections/components, introduce app menu links
* Use CSS variable for hover background color across the app
2026-03-14 16:24:36 +04:00
raucao 3b71531de2 1.14.0
CI / Lint (push) Successful in 22s
CI / Test (push) Successful in 37s
2026-03-14 13:06:07 +04:00
raucao 6ef7549ea9 Merge pull request 'Add places to default lists' (#27) from feature/1-lists into master
CI / Lint (push) Successful in 25s
CI / Test (push) Successful in 38s
Reviewed-on: #27
2026-03-14 09:04:21 +00:00
raucao 9097c63a55 Upgrade places module to latest release
CI / Lint (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 42s
... with lists support
2026-03-14 12:36:49 +04:00
raucao ec0d5a30f9 Extract icon imports to separate util
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 43s
So icons can be used from anywhere, e.g. map component JS
2026-03-14 12:28:17 +04:00
raucao f1779131e8 Also load/init lists in anonymous mode
CI / Lint (pull_request) Failing after 22s
CI / Test (pull_request) Successful in 34s
2026-03-13 17:04:29 +04:00
raucao 37cf47b3dd Properly handle place removals
CI / Lint (pull_request) Failing after 23s
CI / Test (pull_request) Successful in 36s
* Transition to OSM route or index instead of staying on ghost route/ID
  (closes sidebar if it was a custom place)
* Ensure save button and lists are in the correct state
2026-03-13 15:33:29 +04:00
raucao ff68b5addc Move default yellow to var, add in list UI 2026-03-13 14:56:12 +04:00
raucao 990f3afa88 Fix lint errors
CI / Lint (pull_request) Successful in 21s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:51:49 +04:00
raucao b2220b8310 Close list dropdown when clicking outside of it
CI / Lint (pull_request) Failing after 25s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:40:28 +04:00
raucao a8613ab81a Remove confirmation dialog when deleting place bookmarks 2026-03-13 13:27:01 +04:00
raucao bcb9b20e85 WIP Add places to lists 2026-03-13 12:22:51 +04:00
raucao 466b1d5383 Comment dev config for remote access
CI / Lint (push) Successful in 20s
CI / Test (push) Successful in 34s
2026-03-11 18:26:45 +04:00
raucao ea7cb2f895 1.13.3
CI / Lint (push) Failing after 18s
CI / Test (push) Successful in 29s
2026-03-11 18:19:15 +04:00
raucao 7e94f335ac Prevent zooming when selecting saved places 2026-03-11 18:16:24 +04:00
raucao 066ddb240d 1.13.2
CI / Lint (push) Failing after 23s
CI / Test (push) Successful in 34s
2026-03-11 17:53:06 +04:00
raucao df336b87ac Smart auto zoom for search/select 2026-03-11 17:51:26 +04:00
raucao dbf71e366a Further improve scrolling 2026-03-11 17:19:48 +04:00
raucao 6a83003acb 1.13.1 2026-03-11 16:30:33 +04:00
raucao bcc7c2a011 Improve bottom card scrolling on Android 2026-03-11 16:29:31 +04:00
raucao 19f04efecb 1.13.0 2026-03-11 16:16:57 +04:00
raucao c79bbaa41a Merge pull request 'Add email, FB, Instagram to place details' (#26) from feature/social_links into master
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 34s
Reviewed-on: #26
2026-03-11 12:11:13 +00:00
raucao b07640375a Add some white space to place details bottom
CI / Lint (pull_request) Successful in 23s
CI / Test (pull_request) Successful in 39s
2026-03-11 16:07:37 +04:00
raucao ffcb8219b0 Add email links 2026-03-11 15:22:34 +04:00
raucao e01cb2ce6f Add Facebook and Instagram links 2026-03-11 15:02:47 +04:00
raucao 808c1ee37b 1.12.3
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 35s
2026-02-24 22:28:56 +04:00
raucao 34bc15cfa9 Only zoom out, not in, when fitting ways/relations 2026-02-24 22:27:52 +04:00
raucao ee5e56910d 1.12.2
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 37s
2026-02-24 21:51:21 +04:00
raucao e019fc2d6b Don't include roads and similar in nearby search
Meant to include bus stops, but have to be more specific when re-adding
2026-02-24 21:49:59 +04:00
raucao 9e03426b2e 1.12.1
CI / Lint (push) Successful in 22s
CI / Test (push) Successful in 33s
2026-02-24 20:49:35 +04:00
raucao ecbf77c573 Add OpenGraph and Twitter Card metadata 2026-02-24 20:48:37 +04:00
raucao 703a5e8de0 1.12.0 2026-02-24 18:53:27 +04:00
raucao b3c733769c Add app icon before app name in Settings header
CI / Lint (push) Successful in 26s
CI / Test (push) Successful in 39s
2026-02-24 18:52:07 +04:00
raucao 60b2548efd Set up Gitea Actions/CI (#23)
CI / Lint (push) Successful in 19s
CI / Test (push) Successful in 33s
Reviewed-on: #23
2026-02-24 14:23:01 +00:00
raucao 2e632658ad Fix warning 2026-02-24 16:43:06 +04:00
raucao 845be96b71 Fix linting errors, improve lint scripts 2026-02-24 16:31:22 +04:00
raucao 9ac4273fae Don't use outdated Overpass providers 2026-02-24 15:13:44 +04:00
raucao 3a825c3d6c Merge pull request 'Add full-text search' (#20) from feature/10-fulltext_search into master
Reviewed-on: #20
2026-02-24 11:03:57 +00:00
raucao a6ca362876 Multi-line rendering for multi-value tags
E.g. opening hours, multiple phone numbers, ...
2026-02-24 14:50:39 +04:00
raucao 95e9c621a5 Fix sidebar link layout issue 2026-02-24 13:31:11 +04:00
raucao e980431c17 Add Wikipedia icon, support for filled SVGs 2026-02-24 13:25:17 +04:00
raucao 4fdf2e2fb6 WIP Add more icons 2026-02-24 13:04:15 +04:00
raucao de1b162ee9 Different sidebar headers for nearby and full search 2026-02-24 12:49:07 +04:00
raucao 1df77c2045 Tweak search box width on small screens 2026-02-24 12:21:03 +04:00
raucao eb1445b749 Update status doc 2026-02-24 11:52:55 +04:00
raucao 316a38dbf8 Complete tests for localized names 2026-02-24 11:51:25 +04:00
raucao 7bcb572dbf If place key's value is "yes", display key instead
For example, building=yes with no other useful tags (e.g. amenity) will
show as Building now
2026-02-24 11:46:59 +04:00
raucao d827fe263b Draw outlines/areas for ways and relations on map 2026-02-24 11:22:57 +04:00
raucao 1926e2b20c Switch back to more reliable Overpass default 2026-02-24 11:05:25 +04:00
raucao df1f32d8bd More place type improvements 2026-02-24 11:05:02 +04:00
raucao aa058bd7a3 Include places that only have localized names
For example "name" absent, but "name:en" present
2026-02-24 10:41:54 +04:00
raucao 361a826e4f Improve nearby search
* Use regular expression queries for place types
* Add more place types
* Add relations
* Only return results with a name
2026-02-24 09:58:12 +04:00
raucao ff01d54fdd Update status doc 2026-02-23 23:28:11 +04:00
raucao f73677139d Zoom to fit ways and relations into map view 2026-02-23 22:01:46 +04:00
raucao 8135695bba Add waterways (e.g. river) 2026-02-23 21:58:45 +04:00
raucao 8217e85836 Improve display of boundaries like cities, states, etc. 2026-02-23 21:14:40 +04:00
raucao d9645d1a8c Fix search result subheadings
Should be address for non-nearby results
2026-02-23 20:28:03 +04:00
raucao 688e8eda8d Add more place types 2026-02-23 20:16:24 +04:00
raucao 323aab8256 Add more place types, refactor tag usage 2026-02-23 18:02:15 +04:00
raucao ecb3fe4b5a Tweak sizes and layout for icon buttons 2026-02-23 16:50:34 +04:00
raucao 43b2700465 Don't start nearby search when unfocusing search by clicking map 2026-02-20 19:48:41 +04:00
raucao 00454c8fab Integrate the menu button in the search box
Allows us to make the search box wider, too
2026-02-20 18:35:01 +04:00
raucao bf12305600 Add full-text search
Add a search box with a quick results popover, as well full results in
the sidebar on pressing enter.
2026-02-20 12:39:04 +04:00
raucao 2734f08608 Formatting 2026-02-20 12:38:57 +04:00
raucao 2aa59f9384 Fetch place details from OSM API, support relations
* Much faster
* Has more place details, which allows us to locate relations, in
  addition to nodes and ways
2026-02-20 12:34:48 +04:00
raucao bcf8ca4255 Add service for Photon requests 2026-02-19 16:28:07 +04:00
raucao 20f63065ad 1.11.4 2026-02-10 19:21:34 +04:00
raucao 39a7ec3595 Improve code based on linting 2026-02-10 19:20:38 +04:00
raucao 32dfa3a30f Merge pull request 'Prefer place name in UA/browser language' (#19) from feature/16-place_names into master
Reviewed-on: #19
2026-02-10 15:20:23 +00:00
raucao 64ccc694d3 Prefer place name in UA/browser language
closes #16
2026-02-10 19:19:36 +04:00
raucao 87e2380ef6 1.11.3 2026-02-10 18:53:21 +04:00
raucao 66c31b19f1 Merge pull request 'UI improvements' (#18) from feature/location_improvements into master
Reviewed-on: #18
2026-02-10 14:52:22 +00:00
raucao 55aecbd699 Apply same button-press effect to both header buttons 2026-02-10 18:51:26 +04:00
raucao ccaa56b78f Remove remaining default tap highlights on mobiles 2026-02-10 18:44:41 +04:00
raucao d30375707a Prevent map search when zoomed out too much
It's usually an accidental click, and if not, the search radius/pulse
wouldn't be clearly visible.
2026-02-10 18:33:44 +04:00
raucao 53300b92f5 Re-add zoom controls 2026-02-10 17:47:03 +04:00
raucao c37f794eea Auto-locate user on first app launch
closes #17
2026-02-10 17:18:59 +04:00
raucao 4bc92bb7cc Run tests before versioning 2026-02-08 17:01:56 +04:00
raucao 9f48d7b264 1.11.2 2026-02-08 17:01:01 +04:00
raucao bbd3bf47c6 Merge pull request 'Fix back button behavior' (#14) from bugfix/back_button into master
Reviewed-on: #14
2026-02-08 13:00:07 +00:00
raucao 59e3d91071 Fix back button behavior
fixes #12
2026-02-08 16:59:53 +04:00
raucao 348b721876 1.11.1 2026-01-27 15:05:08 +07:00
raucao 3d982a6a7c More kinetic panning optimizations 2026-01-27 15:04:25 +07:00
raucao 0af9d9f16d 1.11.0 2026-01-27 14:24:52 +07:00
raucao a0f132ec64 Disable kinetic panning on mobile by default, add setting for it 2026-01-27 14:23:43 +07:00
raucao 925f26ae5d Update status doc 2026-01-27 14:08:27 +07:00
raucao 58bb8831f3 Prevent autofocus on mobile
Makes it difficult to fine-tune the location first
2026-01-27 14:06:26 +07:00
raucao 585837cae7 1.10.1 2026-01-27 13:47:09 +07:00
raucao 42c5282844 Don't show GMaps link for private bookmarks 2026-01-27 13:46:43 +07:00
raucao 8a0603c65e 1.10.0 2026-01-27 13:38:33 +07:00
raucao 8e3187f38d Improve place-create button 2026-01-27 13:37:59 +07:00
raucao a73e5cda6a Clean up code comments 2026-01-27 13:25:54 +07:00
raucao 0212fa359b Change console statements to debug or warn 2026-01-27 12:58:36 +07:00
raucao 8c58a76030 Create new places
And find them in search
2026-01-27 12:58:23 +07:00
raucao a10f87290a Update status doc 2026-01-27 11:32:21 +07:00
raucao e7b3b72e2f 1.9.0 2026-01-27 11:22:37 +07:00
raucao 399ad1822d Humanize place type properly, refactor for other tags 2026-01-27 11:21:51 +07:00
raucao 104a742543 Use dark grey for all text, change theme color 2026-01-27 11:00:06 +07:00
raucao a8dc4c81e4 Implement simple query cache for Overpass/OSM search
So when we return to the search route, we don't have to refetch
2026-01-27 09:50:41 +07:00
raucao 156280950f Refactor search results with dedicated route 2026-01-27 09:50:26 +07:00
raucao 41d61be42e 1.8.10 2026-01-27 08:55:23 +07:00
raucao 06b47d96a7 Fix search results scrolling behavior 2026-01-27 08:54:42 +07:00
raucao e8af959be6 Improve search results layout/styling 2026-01-27 08:54:38 +07:00
raucao 254e177cbf Update README 2026-01-26 19:55:18 +07:00
raucao 47fbc8e7cf Use published places module 2026-01-26 18:12:29 +07:00
raucao 4ad0df22e2 1.8.9 2026-01-26 17:53:09 +07:00
raucao 0decb4cf1b Optimize animations on iOS 2026-01-26 17:52:41 +07:00
raucao 2193f935cc Change default center and zoom to show the world on desktop 2026-01-26 17:52:14 +07:00
raucao b2b03c0a38 1.8.8 2026-01-26 17:20:49 +07:00
raucao 0be02c5b20 Update status doc 2026-01-26 17:06:39 +07:00
raucao 653e44348c Fix auto-zoom when focussing form field on iOS 2026-01-26 17:01:29 +07:00
raucao 8fdc697a17 1.8.7 2026-01-26 16:46:34 +07:00
raucao d9b2a17b91 Noto serif or no serif 2026-01-26 16:46:07 +07:00
raucao 85255318ba 1.8.6 2026-01-26 16:32:43 +07:00
raucao 713d9d53e6 Styling optimizations 2026-01-26 16:32:16 +07:00
raucao e0ea0ca988 Prevent mobile Safari from resizing text 2026-01-26 16:23:46 +07:00
raucao 3cc2a2649a 1.8.5 2026-01-26 16:17:53 +07:00
raucao 924484a191 Set base font size explicitly 2026-01-26 16:16:47 +07:00
raucao b960ba0868 Unify button styles, improve sizing 2026-01-26 16:15:52 +07:00
raucao 3b22f8c2f4 1.8.4 2026-01-26 16:03:06 +07:00
raucao 1a643e980d Fix location of additional map controls 2026-01-26 16:02:23 +07:00
raucao b085783ad8 1.8.3 2026-01-26 14:58:21 +07:00
raucao 245f79d6f4 Change manifest filename
Wrong content type on 5apps
2026-01-26 14:57:36 +07:00
raucao 06beb73068 1.8.2 2026-01-26 14:43:47 +07:00
raucao c3185b6a5a Change theme and manifest bg color 2026-01-26 14:43:13 +07:00
raucao b6484aee9d Improve settings sidebar 2026-01-26 14:42:56 +07:00
raucao ace2697de5 Add ember-cli as dev dependency
So we can run generators etc.
2026-01-26 14:23:25 +07:00
raucao 14e02f3641 Complete package.json 2026-01-26 14:23:16 +07:00
raucao 5a14db5601 Improve README layout 2026-01-26 13:54:54 +07:00
raucao af1b4e92ac Add LICENSE 2026-01-26 13:48:42 +07:00
raucao 4cfee75a7c Improve README, add app icon 2026-01-26 13:48:30 +07:00
raucao c61fad07c6 Document system dependencies for icon generation 2026-01-26 13:34:24 +07:00
raucao 9ce29807fd 1.8.1 2026-01-26 13:31:13 +07:00
raucao c659dcc2d4 Deployment instructions 2026-01-26 13:30:38 +07:00
raucao 4bdd25c9c3 Add npm script for building icon versions from SVG source
`pnpm build:icons`
2026-01-26 13:30:02 +07:00
raucao 0f3359f725 1.8.0 2026-01-26 13:17:02 +07:00
raucao 25081f9cfc Add app icon, web app manifest 2026-01-26 13:16:24 +07:00
raucao 2efb15041e Update status doc 2026-01-24 21:15:58 +07:00
raucao da64ae1572 1.7.0 2026-01-24 21:07:40 +07:00
raucao 1a96f95c82 Hide settings pane on outside click, render above places pane 2026-01-24 21:06:50 +07:00
raucao 911e6ddf38 Add setting for Overpass API provider 2026-01-24 20:47:55 +07:00
raucao e61dc00725 Restore some lost styles 2026-01-24 20:47:42 +07:00
raucao 25d45a62c3 1.6.1 2026-01-24 18:00:47 +07:00
raucao 76dd8cdf24 Comment for app settings 2026-01-24 18:00:23 +07:00
raucao 269a6c9eef 1.6.0 2026-01-24 17:55:00 +07:00
raucao 1a2aae631d Fix JS linting errors 2026-01-24 17:54:34 +07:00
raucao 94b7959fd8 Fix CSS linting, organize properly 2026-01-24 17:47:37 +07:00
raucao 9082fb9762 Fix template linting errors 2026-01-24 16:42:53 +07:00
raucao 90730a935d Update status doc 2026-01-24 16:33:07 +07:00
raucao 0f44f42c23 Add settings/about pane 2026-01-24 16:18:39 +07:00
raucao 0d5a0325f4 Allow editing of bookmarks/places 2026-01-24 16:15:48 +07:00
raucao e8f7e74e40 WIP Add settings/about pane 2026-01-24 14:33:00 +07:00
raucao f60dacac80 1.5.0 2026-01-24 14:16:40 +07:00
raucao 9a02363515 Move all map controls to bottom right corner 2026-01-24 14:16:08 +07:00
raucao f28be0c994 Add user/accounts menu, RS connect 2026-01-24 13:51:29 +07:00
raucao 721fe5f01d Fix linting/formatting 2026-01-24 12:52:19 +07:00
raucao 518685b7dc Improve secondary btn style 2026-01-24 11:17:07 +07:00
raucao 262e5b61a8 1.4.3 2026-01-23 16:52:21 +07:00
raucao f87d8bdda9 Improve save button styles 2026-01-23 16:51:53 +07:00
raucao f17f8ca17b Use feather icons in sidebar header 2026-01-23 15:49:59 +07:00
raucao 026d1c4712 1.4.2 2026-01-23 12:59:37 +07:00
raucao 6bd55843bb Switch to bkero's API (for now) 2026-01-23 12:59:11 +07:00
raucao 33a6469a19 Various layout and style improvements for place details 2026-01-23 12:41:27 +07:00
raucao 6d7bea411a 1.4.1 2026-01-23 10:21:25 +07:00
raucao 7b01bb1118 Fix place store/remove behavior 2026-01-23 10:21:02 +07:00
raucao 84d4f9cbbf 1.4.0 2026-01-22 17:35:06 +07:00
raucao f7e7480e51 Pan map to bring loaded place into view if necessary 2026-01-22 17:34:19 +07:00
raucao 6e87ef3573 Load all saved place into memory
Fixes launching the app with a place URL directly, and will be useful
for search etc. later.
2026-01-22 17:23:50 +07:00
raucao 86b85e9a0b Ignore release dir for linting etc. 2026-01-22 16:52:26 +07:00
raucao 2a203e8e82 Add initialSyncDone property to storage service
Allows us to know when the first sync cycle has been completed
2026-01-22 16:40:02 +07:00
raucao b08dcedd13 Slightly brighter icon color 2026-01-22 16:39:26 +07:00
raucao 5267ffdd5c Log map features on click 2026-01-22 14:54:01 +07:00
raucao deae2260b1 Fix occasional exception on mobiles 2026-01-22 14:40:35 +07:00
raucao 3c5b4d9b98 Update status doc 2026-01-21 22:00:34 +07:00
raucao b419b498da 1.3.2 2026-01-21 19:58:08 +07:00
raucao be921cf3ca Prevent pull-to-refresh on mobile 2026-01-21 19:57:34 +07:00
raucao 99aeee51bd 1.3.1 2026-01-21 19:39:52 +07:00
raucao fa4115b714 Pan map on mobile when pin obscured by sidebar 2026-01-21 19:38:55 +07:00
raucao 360e511849 1.3.0 2026-01-21 19:16:22 +07:00
raucao 0fee9ad2dd Link to bookmarked places from nearby places 2026-01-21 19:15:38 +07:00
raucao c61c2c0e7a Show map pin for currently selected place 2026-01-21 19:07:35 +07:00
raucao 25f50f9091 Update status doc 2026-01-21 18:32:37 +07:00
raucao cf9139b9c1 1.2.1 2026-01-21 16:10:58 +07:00
raucao 01c3b5a1ac Improve place details with icons 2026-01-21 16:10:22 +07:00
raucao 3fcaa0bfa2 Move all deps to devDependencies, install feather-icons 2026-01-21 15:00:31 +07:00
raucao 0074b63ab2 1.2.0 2026-01-21 14:55:57 +07:00
raucao da3b5f2dd8 Move place details to dedicated component
With more place infos and formatting
2026-01-21 14:53:58 +07:00
raucao 26548cc97d 1.1.1 2026-01-21 13:09:11 +07:00
raucao babf2c4a89 Enable default rotation control 2026-01-21 13:08:35 +07:00
raucao fbb90a330b Remove obsolete dist files from Git 2026-01-21 13:08:21 +07:00
raucao 15cbb3c9f3 Fix deprecation warning 2026-01-21 13:07:30 +07:00
229 changed files with 40000 additions and 638 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
# Ember.js Best Practices
A structured repository for creating and maintaining Ember.js Best Practices optimized for agents and LLMs.
## Structure
- `rules/` - Individual rule files (one per rule)
- `_sections.md` - Section metadata (titles, impacts, descriptions)
- `_template.md` - Template for creating new rules
- `area-description.md` - Individual rule files
- `metadata.json` - Document metadata (version, organization, abstract)
- **`AGENTS.md`** - Compiled output (generated)
- **`SKILL.md`** - Skill definition for Claude Code
## Rule Categories
Rules are organized by prefix:
- `route-` for Route Loading and Data Fetching (Section 1)
- `bundle-` for Build and Bundle Optimization (Section 2)
- `component-` for Component and Reactivity (Section 3)
- `a11y-` for Accessibility Best Practices (Section 4)
- `service-` for Service and State Management (Section 5)
- `template-` for Template Optimization (Section 6)
- `advanced-` for Advanced Patterns (Section 7)
## Rule File Structure
Each rule file should follow this structure:
````markdown
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description
tags: tag1, tag2, tag3
---
## Rule Title Here
Brief explanation of the rule and why it matters.
**Incorrect (description of what's wrong):**
```javascript
// Bad code example
```
**Correct (description of what's right):**
```javascript
// Good code example
```
Optional explanatory text after examples.
Reference: [Link](https://example.com)
````
## File Naming Convention
- Files starting with `_` are special (excluded from build)
- Rule files: `area-description.md` (e.g., `route-parallel-model.md`)
- Section is automatically inferred from filename prefix
- Rules are sorted alphabetically by title within each section
- IDs (e.g., 1.1, 1.2) are auto-generated during build
## Impact Levels
- `CRITICAL` - Highest priority, major performance gains
- `HIGH` - Significant performance improvements
- `MEDIUM-HIGH` - Moderate-high gains
- `MEDIUM` - Moderate performance improvements
- `LOW-MEDIUM` - Low-medium gains
- `LOW` - Incremental improvements
## Contributing
When adding or modifying rules:
1. Use the correct filename prefix for your section
2. Follow the `_template.md` structure
3. Include clear bad/good examples with explanations
4. Add appropriate tags
5. Rules are automatically sorted by title - no need to manage numbers!
## Accessibility Focus
This guide emphasizes Ember's strong accessibility ecosystem:
- **ember-a11y-testing** - Automated testing with axe-core
- **ember-a11y** - Route announcements and focus management
- **ember-focus-trap** - Modal focus trapping
- **ember-page-title** - Accessible page titles
- **Semantic HTML** - Proper use of native elements
- **ARIA attributes** - When custom elements are needed
- **Keyboard navigation** - Full keyboard support patterns
## Acknowledgments
Built for the Ember.js community, drawing from official guides, Octane patterns, and accessibility best practices.
@@ -0,0 +1,161 @@
---
name: ember-best-practices
description: Ember.js performance optimization and accessibility guidelines. This skill should be used when writing, reviewing, or refactoring Ember.js code to ensure optimal performance patterns and accessibility. Triggers on tasks involving Ember components, routes, data fetching, bundle optimization, or accessibility improvements.
license: MIT
metadata:
author: Ember.js Community
version: '1.0.0'
---
# Ember.js Best Practices
Comprehensive performance optimization and accessibility guide for Ember.js applications. Contains 58 rules across 10 categories, prioritized by impact to guide automated refactoring and code generation.
## When to Apply
Reference these guidelines when:
- Writing new Ember components or routes
- Implementing data fetching with WarpDrive
- Reviewing code for performance issues
- Refactoring existing Ember.js code
- Optimizing bundle size or load times
- Implementing accessibility features
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
| -------- | ------------------------------- | ----------- | ------------------------ |
| 1 | Route Loading and Data Fetching | CRITICAL | `route-` |
| 2 | Build and Bundle Optimization | CRITICAL | `bundle-` |
| 3 | Component and Reactivity | HIGH | `component-`, `exports-` |
| 4 | Accessibility Best Practices | HIGH | `a11y-` |
| 5 | Service and State Management | MEDIUM-HIGH | `service-` |
| 6 | Template Optimization | MEDIUM | `template-`, `helper-` |
| 7 | Performance Optimization | MEDIUM | `performance-` |
| 8 | Testing Best Practices | MEDIUM | `testing-` |
| 9 | Tooling and Configuration | MEDIUM | `vscode-` |
| 10 | Advanced Patterns | MEDIUM-HIGH | `advanced-` |
## Quick Reference
### 1. Route Loading and Data Fetching (CRITICAL)
- `route-parallel-model` - Use RSVP.hash() for parallel data loading
- `route-loading-substates` - Implement loading substates for better UX
- `route-lazy-routes` - Use route-based code splitting with Embroider
- `route-templates` - Use route templates with co-located syntax
- `route-model-caching` - Implement smart route model caching
### 2. Build and Bundle Optimization (CRITICAL)
- `bundle-direct-imports` - Import directly, avoid entire namespaces
- `bundle-embroider-static` - Enable Embroider static mode for tree-shaking
- `bundle-lazy-dependencies` - Lazy load heavy dependencies
### 3. Component and Reactivity Optimization (HIGH)
- `component-use-glimmer` - Use Glimmer components over classic components
- `component-cached-getters` - Use @cached for expensive computations
- `component-minimal-tracking` - Only track properties that affect rendering
- `component-tracked-toolbox` - Use tracked-built-ins for complex state
- `component-composition-patterns` - Use yield blocks and contextual components
- `component-reactive-chains` - Build reactive chains with dependent getters
- `component-class-fields` - Use class fields for component composition
- `component-controlled-forms` - Implement controlled form patterns
- `component-on-modifier` - Use {{on}} modifier for event handling
- `component-args-validation` - Validate component arguments
- `component-memory-leaks` - Prevent memory leaks in components
- `component-strict-mode` - Use strict mode and template-only components
- `component-avoid-classes-in-examples` - Avoid unnecessary classes in component examples
- `component-avoid-constructors` - Avoid constructors in Glimmer components
- `component-avoid-lifecycle-hooks` - Avoid legacy lifecycle hooks
- `component-file-conventions` - Follow proper file naming conventions
- `exports-named-only` - Use named exports only
### 4. Accessibility Best Practices (HIGH)
- `a11y-automated-testing` - Use ember-a11y-testing for automated checks
- `a11y-semantic-html` - Use semantic HTML and proper ARIA attributes
- `a11y-keyboard-navigation` - Ensure full keyboard navigation support
- `a11y-form-labels` - Associate labels with inputs, announce errors
- `a11y-route-announcements` - Announce route transitions to screen readers
### 5. Service and State Management (MEDIUM-HIGH)
- `service-cache-responses` - Cache API responses in services
- `service-shared-state` - Use services for shared state
- `service-ember-data-optimization` - Optimize WarpDrive queries
- `service-owner-linkage` - Manage service owner and linkage patterns
- `service-data-requesting` - Implement robust data requesting patterns
### 6. Template Optimization (MEDIUM)
- `template-let-helper` - Use {{#let}} to avoid recomputation
- `template-each-key` - Use {{#each}} with @key for efficient list updates
- `template-avoid-computation` - Move expensive work to cached getters
- `template-helper-imports` - Import helpers directly in templates
- `template-conditional-rendering` - Optimize conditional rendering
- `template-fn-helper` - Use {{fn}} helper for partial application
- `template-only-component-functions` - Use template-only components
- `helper-composition` - Compose helpers for reusable logic
- `helper-builtin-functions` - Use built-in helpers effectively
- `helper-plain-functions` - Write helpers as plain functions
### 7. Performance Optimization (MEDIUM)
- `performance-on-modifier-vs-handlers` - Use {{on}} modifier instead of event handler properties
### 8. Testing Best Practices (MEDIUM)
- `testing-modern-patterns` - Use modern testing patterns
- `testing-qunit-dom-assertions` - Use qunit-dom for better test assertions
- `testing-test-waiters` - Use @ember/test-waiters for async testing
- `testing-render-patterns` - Use correct render patterns for components
- `testing-msw-setup` - Mock API requests with MSW
- `testing-library-dom-abstraction` - Use Testing Library patterns
### 9. Tooling and Configuration (MEDIUM)
- `vscode-setup-recommended` - VS Code extensions and MCP server setup
### 10. Advanced Patterns (MEDIUM-HIGH)
- `advanced-modifiers` - Use modifiers for DOM side effects
- `advanced-helpers` - Extract reusable logic into helpers
- `advanced-tracked-built-ins` - Use reactive collections from @ember/reactive/collections
- `advanced-concurrency` - Use ember-concurrency for task management
- `advanced-data-loading-with-ember-concurrency` - Data loading patterns with ember-concurrency
## How to Use
Read individual rule files for detailed explanations and code examples:
```
rules/route-parallel-model.md
rules/bundle-embroider-static.md
rules/a11y-automated-testing.md
```
Each rule file contains:
- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
- Additional context and references
## Accessibility with OSS Tools
Ember has excellent accessibility support through community addons:
- **ember-a11y-testing** - Automated accessibility testing in your test suite
- **ember-a11y** - Route announcements and focus management
- **ember-focus-trap** - Focus trapping for modals and dialogs
- **ember-page-title** - Accessible page title management
- **Platform-native validation** - Use browser's Constraint Validation API for accessible form validation
These tools, combined with native web platform features, provide comprehensive a11y support with minimal configuration.
## Full Compiled Document
For the complete guide with all rules expanded: `AGENTS.md`
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
set -e
OUTPUT="AGENTS.md"
RULES_DIR="rules"
# Start with the header
cat > "$OUTPUT" << 'HEADER'
# Ember Best Practices
Comprehensive performance optimization and accessibility patterns for modern Ember.js applications. Includes rules across 7 categories using gjs/gts format and modern Ember patterns.
---
HEADER
# Add sections
cat "$RULES_DIR/_sections.md" >> "$OUTPUT"
echo "" >> "$OUTPUT"
echo "---" >> "$OUTPUT"
echo "" >> "$OUTPUT"
# Add all rules
for file in "$RULES_DIR"/*.md; do
# Skip the _sections.md file
if [[ "$(basename "$file")" == "_sections.md" ]]; then
continue
fi
echo "Adding $(basename "$file")..." >&2
cat "$file" >> "$OUTPUT"
echo "" >> "$OUTPUT"
echo "---" >> "$OUTPUT"
echo "" >> "$OUTPUT"
done
echo "Built $OUTPUT successfully!" >&2
@@ -0,0 +1,57 @@
# Sections
This file defines all sections, their ordering, impact levels, and descriptions.
The section ID (in parentheses) is the filename prefix used to group rules.
When multiple prefixes map to one section, all supported prefixes are listed.
---
## 1. Route Loading and Data Fetching (route)
**Impact:** CRITICAL
**Description:** Efficient route loading and parallel data fetching eliminate waterfalls. Using route model hooks effectively and loading data in parallel yields the largest performance gains.
## 2. Build and Bundle Optimization (bundle)
**Impact:** CRITICAL
**Description:** Using Embroider with static build optimizations, route-based code splitting, and proper imports reduces bundle size and improves Time to Interactive.
## 3. Component and Reactivity Optimization (component, exports)
**Impact:** HIGH
**Description:** Proper use of Glimmer components, modern file conventions, tracked properties, and avoiding unnecessary recomputation improves rendering performance.
## 4. Accessibility Best Practices (a11y)
**Impact:** HIGH
**Description:** Making applications accessible is critical. Use ember-a11y-testing, semantic HTML, proper ARIA attributes, and keyboard navigation support.
## 5. Service and State Management (service)
**Impact:** MEDIUM-HIGH
**Description:** Efficient service patterns, proper dependency injection, and state management reduce redundant computations and API calls.
## 6. Template Optimization (template, helper)
**Impact:** MEDIUM
**Description:** Optimizing templates with proper helpers, avoiding expensive computations in templates, and using {{#each}} efficiently improves rendering speed.
## 7. Performance Optimization (performance)
**Impact:** MEDIUM
**Description:** Performance-focused rendering and event handling patterns help reduce unnecessary work in hot UI paths.
## 8. Testing Best Practices (testing)
**Impact:** MEDIUM
**Description:** Modern testing patterns, waiters, and abstraction utilities improve test reliability and maintainability.
## 9. Tooling and Configuration (vscode)
**Impact:** MEDIUM
**Description:** Consistent editor setup and tooling recommendations improve team productivity and reduce environment drift.
## 10. Advanced Patterns (advanced)
**Impact:** MEDIUM-HIGH
**Description:** Modern Ember patterns including Resources for lifecycle management, ember-concurrency for async operations, modifiers for DOM side effects, helpers for reusable logic, and comprehensive testing patterns with render strategies.
@@ -0,0 +1,28 @@
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description of impact (e.g., "20-50% improvement")
tags: tag1, tag2
---
## Rule Title Here
**Impact: MEDIUM (optional impact description)**
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
**Incorrect (description of what's wrong):**
```glimmer-ts
// Bad code example here
const bad = example();
```
**Correct (description of what's right):**
```glimmer-ts
// Good code example here
const good = example();
```
Reference: [Link to documentation or resource](https://example.com)
@@ -0,0 +1,74 @@
---
title: Use ember-a11y-testing for Automated Checks
impact: HIGH
impactDescription: Catch 30-50% of a11y issues automatically
tags: accessibility, a11y, testing, ember-a11y-testing
---
## Use ember-a11y-testing for Automated Checks
Integrate ember-a11y-testing into your test suite to automatically catch common accessibility violations during development. This addon uses axe-core to identify issues before they reach production.
**Incorrect (no accessibility testing):**
```glimmer-js
// tests/integration/components/user-form-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn, click } from '@ember/test-helpers';
import UserForm from 'my-app/components/user-form';
module('Integration | Component | user-form', function (hooks) {
setupRenderingTest(hooks);
test('it submits the form', async function (assert) {
await render(<template><UserForm /></template>);
await fillIn('input', 'John');
await click('button');
assert.ok(true);
});
});
```
**Correct (with a11y testing):**
```glimmer-js
// tests/integration/components/user-form-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn, click } from '@ember/test-helpers';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import UserForm from 'my-app/components/user-form';
module('Integration | Component | user-form', function (hooks) {
setupRenderingTest(hooks);
test('it submits the form', async function (assert) {
await render(<template><UserForm /></template>);
// Automatically checks for a11y violations
await a11yAudit();
await fillIn('input', 'John');
await click('button');
assert.ok(true);
});
});
```
**Setup (install and configure):**
```bash
ember install ember-a11y-testing
```
```javascript
// tests/test-helper.js
import { setupGlobalA11yHooks } from 'ember-a11y-testing/test-support';
setupGlobalA11yHooks(); // Runs on every test automatically
```
ember-a11y-testing catches issues like missing labels, insufficient color contrast, invalid ARIA, and keyboard navigation problems automatically.
Reference: [ember-a11y-testing](https://github.com/ember-a11y/ember-a11y-testing)
@@ -0,0 +1,147 @@
---
title: Form Labels and Error Announcements
impact: HIGH
impactDescription: Essential for screen reader users
tags: accessibility, a11y, forms, aria-live
---
## Form Labels and Error Announcements
All form inputs must have associated labels, and validation errors should be announced to screen readers using ARIA live regions.
**Incorrect (missing labels and announcements):**
```glimmer-js
// app/components/form.gjs
<template>
<form {{on "submit" this.handleSubmit}}>
<input type="email" value={{this.email}} {{on "input" this.updateEmail}} placeholder="Email" />
{{#if this.emailError}}
<span>{{this.emailError}}</span>
{{/if}}
<button type="submit">Submit</button>
</form>
</template>
```
**Correct (with labels and announcements):**
```glimmer-js
// app/components/form.gjs
<template>
<form {{on "submit" this.handleSubmit}}>
<div>
<label for="email-input">
Email Address
{{#if this.isEmailRequired}}
<span aria-label="required">*</span>
{{/if}}
</label>
<input
id="email-input"
type="email"
value={{this.email}}
{{on "input" this.updateEmail}}
aria-describedby={{if this.emailError "email-error"}}
aria-invalid={{if this.emailError "true"}}
required={{this.isEmailRequired}}
/>
{{#if this.emailError}}
<span id="email-error" role="alert" aria-live="polite">
{{this.emailError}}
</span>
{{/if}}
</div>
<button type="submit" disabled={{this.isSubmitting}}>
{{#if this.isSubmitting}}
<span aria-live="polite">Submitting...</span>
{{else}}
Submit
{{/if}}
</button>
</form>
</template>
```
**For complex forms, use platform-native validation with custom logic:**
```glimmer-js
// app/components/user-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class UserForm extends Component {
@tracked errorMessages = {};
validateEmail = (event) => {
// Custom business logic validation
const input = event.target;
const value = input.value;
if (!value) {
input.setCustomValidity('Email is required');
return false;
}
if (!input.validity.valid) {
input.setCustomValidity('Must be a valid email');
return false;
}
// Additional custom validation (e.g., check if email is already taken)
if (value === 'taken@example.com') {
input.setCustomValidity('This email is already registered');
return false;
}
input.setCustomValidity('');
return true;
};
handleSubmit = async (event) => {
event.preventDefault();
const form = event.target;
// Run custom validations
const emailInput = form.querySelector('[name="email"]');
const fakeEvent = { target: emailInput };
this.validateEmail(fakeEvent);
// Use native validation check
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const formData = new FormData(form);
await this.args.onSubmit(formData);
};
<template>
<form {{on "submit" this.handleSubmit}}>
<label for="user-email">
Email
<input
id="user-email"
type="email"
name="email"
required
value={{@user.email}}
{{on "blur" this.validateEmail}}
/>
</label>
<button type="submit">Save</button>
</form>
</template>
}
```
Always associate labels with inputs and announce dynamic changes to screen readers using aria-live regions.
Reference: [Ember Accessibility - Application Considerations](https://guides.emberjs.com/release/accessibility/application-considerations/)
@@ -0,0 +1,163 @@
---
title: Keyboard Navigation Support
impact: HIGH
impactDescription: Critical for keyboard-only users
tags: accessibility, a11y, keyboard, focus-management
---
## Keyboard Navigation Support
Ensure all interactive elements are keyboard accessible and focus management is handled properly, especially in modals and dynamic content.
**Incorrect (no keyboard support):**
```glimmer-js
// app/components/dropdown.gjs
<template>
<div class="dropdown" {{on "click" this.toggleMenu}}>
Menu
{{#if this.isOpen}}
<div class="dropdown-menu">
<div {{on "click" this.selectOption}}>Option 1</div>
<div {{on "click" this.selectOption}}>Option 2</div>
</div>
{{/if}}
</div>
</template>
```
**Correct (full keyboard support with custom modifier):**
```javascript
// app/modifiers/focus-first.js
import { modifier } from 'ember-modifier';
export default modifier((element, [selector = 'button']) => {
// Focus first matching element when modifier runs
element.querySelector(selector)?.focus();
});
```
```glimmer-js
// app/components/dropdown.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import focusFirst from '../modifiers/focus-first';
class Dropdown extends Component {
@tracked isOpen = false;
@action
toggleMenu() {
this.isOpen = !this.isOpen;
}
@action
handleButtonKeyDown(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.isOpen = true;
}
}
@action
handleMenuKeyDown(event) {
if (event.key === 'Escape') {
this.isOpen = false;
// Return focus to button
event.target.closest('.dropdown').querySelector('button').focus();
}
// Handle arrow key navigation between menu items
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
this.moveFocus(event.key === 'ArrowDown' ? 1 : -1);
}
}
moveFocus(direction) {
const items = Array.from(document.querySelectorAll('[role="menuitem"] button'));
const currentIndex = items.indexOf(document.activeElement);
const nextIndex = (currentIndex + direction + items.length) % items.length;
items[nextIndex]?.focus();
}
@action
selectOption(value) {
this.args.onSelect?.(value);
this.isOpen = false;
}
<template>
<div class="dropdown">
<button
type="button"
{{on "click" this.toggleMenu}}
{{on "keydown" this.handleButtonKeyDown}}
aria-haspopup="true"
aria-expanded="{{this.isOpen}}"
>
Menu
</button>
{{#if this.isOpen}}
<ul
class="dropdown-menu"
role="menu"
{{focusFirst '[role="menuitem"] button'}}
{{on "keydown" this.handleMenuKeyDown}}
>
<li role="menuitem">
<button type="button" {{on "click" (fn this.selectOption "1")}}>
Option 1
</button>
</li>
<li role="menuitem">
<button type="button" {{on "click" (fn this.selectOption "2")}}>
Option 2
</button>
</li>
</ul>
{{/if}}
</div>
</template>
}
```
**For focus trapping in modals, use ember-focus-trap:**
```bash
ember install ember-focus-trap
```
```glimmer-js
// app/components/modal.gjs
import FocusTrap from 'ember-focus-trap/components/focus-trap';
<template>
{{#if this.showModal}}
<FocusTrap @isActive={{true}} @initialFocus="#modal-title">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">{{@title}}</h2>
{{yield}}
<button type="button" {{on "click" this.closeModal}}>Close</button>
</div>
</FocusTrap>
{{/if}}
</template>
```
**Alternative: Use libraries for keyboard support:**
For complex keyboard interactions, consider using libraries that abstract keyboard support patterns:
```bash
npm install @fluentui/keyboard-keys
```
Or use [tabster](https://tabster.io/) for comprehensive keyboard navigation management including focus trapping, arrow key navigation, and modalizers.
Proper keyboard navigation ensures all users can interact with your application effectively.
Reference: [Ember Accessibility - Keyboard](https://guides.emberjs.com/release/accessibility/keyboard/)
@@ -0,0 +1,174 @@
---
title: Announce Route Transitions to Screen Readers
impact: HIGH
impactDescription: Critical for screen reader navigation
tags: accessibility, a11y, routing, screen-readers
---
## Announce Route Transitions to Screen Readers
Announce page title changes and route transitions to screen readers so users know when navigation has occurred.
**Incorrect (no announcements):**
```javascript
// app/router.js
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
```
**Correct (using a11y-announcer library - recommended):**
Use the [a11y-announcer](https://github.com/ember-a11y/a11y-announcer) library for robust route announcements:
```bash
ember install @ember-a11y/a11y-announcer
```
```javascript
// app/router.js
import EmberRouter from '@ember/routing/router';
import config from './config/environment';
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
Router.map(function () {
this.route('about');
this.route('dashboard');
this.route('posts', function () {
this.route('post', { path: '/:post_id' });
});
});
```
The a11y-announcer library automatically handles route announcements. For custom announcements in your routes:
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class DashboardRoute extends Route {
@service announcer;
afterModel() {
this.announcer.announce('Loaded dashboard with latest data');
}
}
```
**Alternative: DIY approach with ARIA live regions:**
If you prefer not to use a library, you can implement route announcements yourself:
```javascript
// app/router.js
import EmberRouter from '@ember/routing/router';
import config from './config/environment';
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
Router.map(function () {
this.route('about');
this.route('dashboard');
this.route('posts', function () {
this.route('post', { path: '/:post_id' });
});
});
```
```javascript
// app/routes/application.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ApplicationRoute extends Route {
@service router;
constructor() {
super(...arguments);
this.router.on('routeDidChange', (transition) => {
// Update document title
const title = this.getPageTitle(transition.to);
document.title = title;
// Announce to screen readers
this.announceRouteChange(title);
});
}
getPageTitle(route) {
// Get title from route metadata or generate it
return route.metadata?.title || route.name;
}
announceRouteChange(title) {
const announcement = document.getElementById('route-announcement');
if (announcement) {
announcement.textContent = `Navigated to ${title}`;
}
}
}
```
```glimmer-js
// app/routes/application.gjs
<template>
<div
id="route-announcement"
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
></div>
{{outlet}}
</template>
```
```css
/* app/styles/app.css */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
```
**Alternative: Use ember-page-title with announcements:**
```bash
ember install ember-page-title
```
```glimmer-js
// app/routes/dashboard.gjs
import { pageTitle } from 'ember-page-title';
<template>
{{pageTitle "Dashboard"}}
<div class="dashboard">
{{outlet}}
</div>
</template>
```
Route announcements ensure screen reader users know when navigation occurs, improving the overall accessibility experience.
Reference: [Ember Accessibility - Page Titles](https://guides.emberjs.com/release/accessibility/page-template-considerations/)
@@ -0,0 +1,102 @@
---
title: Semantic HTML and ARIA Attributes
impact: HIGH
impactDescription: Essential for screen reader users
tags: accessibility, a11y, semantic-html, aria
---
## Semantic HTML and ARIA Attributes
Use semantic HTML elements and proper ARIA attributes to make your application accessible to screen reader users. **The first rule of ARIA is to not use ARIA** - prefer native semantic HTML elements whenever possible.
**Key principle:** Native HTML elements have built-in keyboard support, roles, and behaviors. Only add ARIA when semantic HTML can't provide the needed functionality.
**Incorrect (divs with insufficient semantics):**
```glimmer-js
// app/components/example.gjs
<template>
<div class="button" {{on "click" this.submit}}>
Submit
</div>
<div class="nav">
<div class="nav-item">Home</div>
<div class="nav-item">About</div>
</div>
<div class="alert">
{{this.message}}
</div>
</template>
```
**Correct (semantic HTML with proper ARIA):**
```glimmer-js
// app/components/example.gjs
import { LinkTo } from '@ember/routing';
<template>
<button type="submit" {{on "click" this.submit}}>
Submit
</button>
<nav aria-label="Main navigation">
<ul>
<li><LinkTo @route="index">Home</LinkTo></li>
<li><LinkTo @route="about">About</LinkTo></li>
</ul>
</nav>
<div role="alert" aria-live="polite" aria-atomic="true">
{{this.message}}
</div>
</template>
```
**For interactive custom elements:**
```glimmer-js
// app/components/custom-button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import XIcon from './x-icon';
class CustomButton extends Component {
@action
handleKeyDown(event) {
// Support Enter and Space keys for keyboard users
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleClick();
}
}
@action
handleClick() {
this.args.onClick?.();
}
<template>
<div
role="button"
tabindex="0"
{{on "click" this.handleClick}}
{{on "keydown" this.handleKeyDown}}
aria-label="Close dialog"
>
<XIcon />
</div>
</template>
}
```
Always use native semantic elements when possible. When creating custom interactive elements, ensure they're keyboard accessible and have proper ARIA attributes.
**References:**
- [ARIA Authoring Practices Guide (W3C)](https://www.w3.org/WAI/ARIA/apg/)
- [Using ARIA (W3C)](https://www.w3.org/TR/using-aria/)
- [ARIA in HTML (WHATWG)](https://html.spec.whatwg.org/multipage/aria.html#aria)
- [Ember Accessibility Guide](https://guides.emberjs.com/release/accessibility/)
@@ -0,0 +1,198 @@
---
title: Use Ember Concurrency for User Input Concurrency
impact: HIGH
impactDescription: Better control of user-initiated async operations
tags: ember-concurrency, tasks, user-input, concurrency-patterns
---
## Use Ember Concurrency for User Input Concurrency
Use ember-concurrency for managing **user-initiated** async operations like search, form submission, and autocomplete. It provides automatic cancelation, debouncing, and prevents race conditions from user actions.
**Incorrect (manual async handling with race conditions):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class Search extends Component {
@tracked results = [];
@tracked isSearching = false;
@tracked error = null;
currentRequest = null;
@action
async search(event) {
const query = event.target.value;
// Manual cancelation - easy to get wrong
if (this.currentRequest) {
this.currentRequest.abort();
}
this.isSearching = true;
this.error = null;
const controller = new AbortController();
this.currentRequest = controller;
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
this.results = await response.json();
} catch (e) {
if (e.name !== 'AbortError') {
this.error = e.message;
}
} finally {
this.isSearching = false;
}
}
<template>
<input {{on "input" this.search}} />
{{#if this.isSearching}}Loading...{{/if}}
{{#if this.error}}Error: {{this.error}}{{/if}}
</template>
}
```
**Correct (using ember-concurrency with task return values):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { restartableTask } from 'ember-concurrency';
class Search extends Component {
// restartableTask automatically cancels previous searches
// IMPORTANT: Return the value, don't set tracked state inside tasks
searchTask = restartableTask(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
return response.json(); // Return, don't set @tracked
});
<template>
<input {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />
{{#if this.searchTask.isRunning}}
<div class="loading">Loading...</div>
{{/if}}
{{#if this.searchTask.last.isSuccessful}}
<ul>
{{#each this.searchTask.last.value as |result|}}
<li>{{result.name}}</li>
{{/each}}
</ul>
{{/if}}
{{#if this.searchTask.last.isError}}
<div class="error">{{this.searchTask.last.error.message}}</div>
{{/if}}
</template>
}
```
**With debouncing for user typing:**
```glimmer-js
// app/components/autocomplete.gjs
import Component from '@glimmer/component';
import { restartableTask, timeout } from 'ember-concurrency';
class Autocomplete extends Component {
searchTask = restartableTask(async (query) => {
// Debounce user typing - wait 300ms
await timeout(300);
const response = await fetch(`/api/autocomplete?q=${query}`);
return response.json(); // Return value, don't set tracked state
});
<template>
<input
type="search"
{{on "input" (fn this.searchTask.perform (pick "target.value"))}}
placeholder="Search..."
/>
{{#if this.searchTask.isRunning}}
<div class="spinner"></div>
{{/if}}
{{#if this.searchTask.lastSuccessful}}
<ul class="suggestions">
{{#each this.searchTask.lastSuccessful.value as |item|}}
<li>{{item.title}}</li>
{{/each}}
</ul>
{{/if}}
</template>
}
```
**Task modifiers for different user concurrency patterns:**
```glimmer-js
import Component from '@glimmer/component';
import { dropTask, enqueueTask, restartableTask } from 'ember-concurrency';
class FormActions extends Component {
// dropTask: Prevents double-click - ignores new while running
saveTask = dropTask(async (data) => {
const response = await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
});
// enqueueTask: Queues user actions sequentially
processTask = enqueueTask(async (item) => {
const response = await fetch('/api/process', {
method: 'POST',
body: JSON.stringify(item),
});
return response.json();
});
// restartableTask: Cancels previous, starts new (for search)
searchTask = restartableTask(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
return response.json();
});
<template>
<button {{on "click" (fn this.saveTask.perform @data)}} disabled={{this.saveTask.isRunning}}>
Save
</button>
</template>
}
```
**Key Principles for ember-concurrency:**
1. **User-initiated only** - Use for handling user actions, not component initialization
2. **Return values** - Use `task.last.value`, never set `@tracked` state inside tasks
3. **Avoid side effects** - Don't modify component state that's read during render inside tasks
4. **Choose right modifier**:
- `restartableTask` - User typing/search (cancel previous)
- `dropTask` - Form submit/save (prevent double-click)
- `enqueueTask` - Sequential processing (queue user actions)
**When NOT to use ember-concurrency:**
- ❌ Component initialization data loading (use `getPromiseState` instead)
- ❌ Setting tracked state inside tasks (causes infinite render loops)
- ❌ Route model hooks (return promises directly)
- ❌ Simple async without user concurrency concerns (use async/await)
See **advanced-data-loading-with-ember-concurrency.md** for correct data loading patterns.
ember-concurrency provides automatic cancelation, derived state (isRunning, isIdle), and better patterns for **user-initiated** async operations.
Reference: [ember-concurrency](https://ember-concurrency.com/)
@@ -0,0 +1,243 @@
---
title: Use Ember Concurrency Correctly - User Concurrency Not Data Loading
impact: HIGH
impactDescription: Prevents infinite render loops and improves performance
tags: ember-concurrency, tasks, data-loading, anti-pattern
---
## Use Ember Concurrency Correctly - User Concurrency Not Data Loading
ember-concurrency is designed for **user-initiated concurrency patterns** (debouncing, throttling, preventing double-clicks), not data loading. Use task return values, don't set tracked state inside tasks.
**Incorrect (using ember-concurrency for data loading with tracked state):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
class UserProfile extends Component {
@tracked userData = null;
@tracked error = null;
// WRONG: Setting tracked state inside task
loadUserTask = task(async () => {
try {
const response = await fetch(`/api/users/${this.args.userId}`);
this.userData = await response.json(); // Anti-pattern!
} catch (e) {
this.error = e; // Anti-pattern!
}
});
<template>
{{#if this.loadUserTask.isRunning}}
Loading...
{{else if this.userData}}
<h1>{{this.userData.name}}</h1>
{{/if}}
</template>
}
```
**Why This Is Wrong:**
- Setting tracked state during render can cause infinite render loops
- ember-concurrency adds overhead unnecessary for simple data loading
- Makes component state harder to reason about
- Can trigger multiple re-renders
**Correct (use getPromiseState from warp-drive/reactiveweb for data loading):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { getPromiseState } from '@warp-drive/reactiveweb';
class UserProfile extends Component {
@cached
get userData() {
const promise = fetch(`/api/users/${this.args.userId}`).then((r) => r.json());
return getPromiseState(promise);
}
<template>
{{#if this.userData.isPending}}
<div>Loading...</div>
{{else if this.userData.isRejected}}
<div>Error: {{this.userData.error.message}}</div>
{{else if this.userData.isFulfilled}}
<h1>{{this.userData.value.name}}</h1>
{{/if}}
</template>
}
```
**Correct (use ember-concurrency for USER input with derived data patterns):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { restartableTask, timeout } from 'ember-concurrency';
import { on } from '@ember/modifier';
import { pick } from 'ember-composable-helpers';
class Search extends Component {
// CORRECT: For user-initiated search with debouncing
// Use derived data from TaskInstance API - lastSuccessful
searchTask = restartableTask(async (query) => {
await timeout(300); // Debounce user typing
const response = await fetch(`/api/search?q=${query}`);
return response.json(); // Return value, don't set tracked state
});
<template>
<input type="search" {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />
{{! Use derived data from task state - no tracked properties needed }}
{{#if this.searchTask.isRunning}}
<div>Searching...</div>
{{/if}}
{{! lastSuccessful persists previous results while new search runs }}
{{#if this.searchTask.lastSuccessful}}
<ul>
{{#each this.searchTask.lastSuccessful.value as |result|}}
<li>{{result.name}}</li>
{{/each}}
</ul>
{{/if}}
{{! Show error from most recent failed attempt }}
{{#if this.searchTask.last.isError}}
<div>Error: {{this.searchTask.last.error.message}}</div>
{{/if}}
</template>
}
```
**Good Use Cases for ember-concurrency:**
1. **User input debouncing** - prevent API spam from typing
2. **Form submission** - prevent double-click submits with `dropTask`
3. **Autocomplete** - restart previous searches as user types
4. **Polling** - user-controlled refresh intervals
5. **Multi-step wizards** - sequential async operations
```glimmer-js
// app/components/form-submit.gjs
import Component from '@glimmer/component';
import { dropTask } from 'ember-concurrency';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
class FormSubmit extends Component {
// dropTask prevents double-submit - perfect for user actions
submitTask = dropTask(async (formData) => {
const response = await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(formData),
});
return response.json();
});
<template>
<button
{{on "click" (fn this.submitTask.perform @formData)}}
disabled={{this.submitTask.isRunning}}
>
{{#if this.submitTask.isRunning}}
Saving...
{{else}}
Save
{{/if}}
</button>
{{! Use lastSuccessful for success message - derived data }}
{{#if this.submitTask.lastSuccessful}}
<div>Saved successfully!</div>
{{/if}}
{{#if this.submitTask.last.isError}}
<div>Error: {{this.submitTask.last.error.message}}</div>
{{/if}}
</template>
}
```
**Bad Use Cases for ember-concurrency:**
1. ❌ **Loading data on component init** - use `getPromiseState` instead
2. ❌ **Route model hooks** - just return promises directly
3. ❌ **Simple API calls** - async/await is sufficient
4. ❌ **Setting tracked state inside tasks** - causes render loops
**Key Principles:**
- **Derive data, don't set it** - Use `task.lastSuccessful`, `task.last`, `task.isRunning` (derived from TaskInstance API)
- **Use task return values** - Read from `task.lastSuccessful.value` or `task.last.value`, never set tracked state
- **User-initiated only** - ember-concurrency is for handling user concurrency patterns
- **Data loading** - Use `getPromiseState` from warp-drive/reactiveweb for non-user-initiated loading
- **Avoid side effects** - Don't modify component state inside tasks that's read during render
**TaskInstance API for Derived Data:**
ember-concurrency provides a powerful derived data API through Task and TaskInstance:
- `task.last` - The most recent TaskInstance (successful or failed)
- `task.lastSuccessful` - The most recent successful TaskInstance (persists during new attempts)
- `task.isRunning` - Derived boolean if any instance is running
- `taskInstance.value` - The returned value from the task
- `taskInstance.isError` - Derived boolean if this instance failed
- `taskInstance.error` - The error if this instance failed
This follows the **derived data pattern** - all state comes from the task itself, no tracked properties needed!
References:
- [TaskInstance API](https://ember-concurrency.com/api/TaskInstance.html)
- [Task API](https://ember-concurrency.com/api/Task.html)
**Migration from tracked state pattern:**
```glimmer-js
// BEFORE (anti-pattern - setting tracked state)
class Bad extends Component {
@tracked data = null;
fetchTask = task(async () => {
this.data = await fetch('/api/data').then((r) => r.json());
});
// template reads: {{this.data}}
}
// AFTER (correct - using derived data from TaskInstance API)
class Good extends Component {
fetchTask = restartableTask(async () => {
return fetch('/api/data').then((r) => r.json());
});
// template reads: {{this.fetchTask.lastSuccessful.value}}
// All state derived from task - no tracked properties!
}
// Or better yet, for non-user-initiated loading:
class Better extends Component {
@cached
get data() {
return getPromiseState(fetch('/api/data').then((r) => r.json()));
}
// template reads: {{#if this.data.isFulfilled}}{{this.data.value}}{{/if}}
}
```
ember-concurrency is a powerful tool for **user concurrency patterns**. For data loading, use `getPromiseState` instead.
Reference:
- [ember-concurrency](https://ember-concurrency.com/)
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
@@ -0,0 +1,156 @@
---
title: Use Helper Functions for Reusable Logic
impact: LOW-MEDIUM
impactDescription: Better code reuse and testability
tags: helpers, templates, reusability, advanced
---
## Use Helper Functions for Reusable Logic
Extract reusable template logic into helper functions that can be tested independently and used across templates.
**Incorrect (logic duplicated in components):**
```javascript
// app/components/user-card.js
class UserCard extends Component {
get formattedDate() {
const date = new Date(this.args.user.createdAt);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
}
}
// app/components/post-card.js - same logic duplicated!
class PostCard extends Component {
get formattedDate() {
// Same implementation...
}
}
```
**Correct (reusable helper):**
For single-use helpers, keep them in the same file as the component:
```glimmer-js
// app/components/post-list.gjs
import Component from '@glimmer/component';
// Helper co-located in same file
function formatRelativeDate(date) {
const dateObj = new Date(date);
const now = new Date();
const diffMs = now - dateObj;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return dateObj.toLocaleDateString();
}
class PostList extends Component {
<template>
{{#each @posts as |post|}}
<article>
<h2>{{post.title}}</h2>
<time>{{formatRelativeDate post.createdAt}}</time>
</article>
{{/each}}
</template>
}
```
For helpers shared across multiple components in a feature, use a subdirectory:
```javascript
// app/components/blog/format-relative-date.js
export function formatRelativeDate(date) {
const dateObj = new Date(date);
const now = new Date();
const diffMs = now - dateObj;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return dateObj.toLocaleDateString();
}
```
**Alternative (shared helper in utils):**
For truly shared helpers used across the whole app, use `app/utils/`:
```javascript
// app/utils/format-relative-date.js
// Flat structure - use subpath-imports in package.json for nicer imports if needed
export function formatRelativeDate(date) {
const dateObj = new Date(date);
const now = new Date();
const diffMs = now - dateObj;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return dateObj.toLocaleDateString();
}
```
**Note**: Keep utils flat (`app/utils/format-relative-date.js`), not nested (`app/utils/date/format-relative-date.js`). If you need cleaner top-level imports, configure subpath-imports in package.json instead of nesting files.
```glimmer-js
// app/components/user-card.gjs
import { formatRelativeDate } from '../utils/format-relative-date';
<template>
<p>Joined: {{formatRelativeDate @user.createdAt}}</p>
</template>
```
```glimmer-js
// app/components/post-card.gjs
import { formatRelativeDate } from '../utils/format-relative-date';
<template>
<p>Posted: {{formatRelativeDate @post.createdAt}}</p>
</template>
```
**For helpers with state, use class-based helpers:**
```javascript
// app/utils/helpers/format-currency.js
export class FormatCurrencyHelper {
constructor(owner) {
this.intl = owner.lookup('service:intl');
}
compute(amount, { currency = 'USD' } = {}) {
return this.intl.formatNumber(amount, {
style: 'currency',
currency,
});
}
}
```
**Common helpers to create:**
- Date/time formatting
- Number formatting
- String manipulation
- Array operations
- Conditional logic
Helpers promote code reuse, are easier to test, and keep components focused on behavior.
Reference: [Ember Helpers](https://guides.emberjs.com/release/components/helper-functions/)
@@ -0,0 +1,129 @@
---
title: Use Modifiers for DOM Side Effects
impact: LOW-MEDIUM
impactDescription: Better separation of concerns
tags: modifiers, dom, lifecycle, advanced
---
## Use Modifiers for DOM Side Effects
Use modifiers (element modifiers) to handle DOM side effects and lifecycle events in a reusable, composable way.
**Incorrect (manual DOM manipulation in component):**
```glimmer-js
// app/components/chart.gjs
import Component from '@glimmer/component';
class Chart extends Component {
chartInstance = null;
constructor() {
super(...arguments);
// Can't access element here - element doesn't exist yet!
}
willDestroy() {
super.willDestroy();
this.chartInstance?.destroy();
}
<template>
<canvas id="chart-canvas"></canvas>
{{! Manual setup is error-prone and not reusable }}
</template>
}
```
**Correct (function modifier - preferred for simple side effects):**
```javascript
// app/modifiers/chart.js
import { modifier } from 'ember-modifier';
export default modifier((element, [config]) => {
// Initialize chart
const chartInstance = new Chart(element, config);
// Return cleanup function
return () => {
chartInstance.destroy();
};
});
```
**Also correct (class-based modifier for complex state):**
```javascript
// app/modifiers/chart.js
import Modifier from 'ember-modifier';
import { registerDestructor } from '@ember/destroyable';
export default class ChartModifier extends Modifier {
chartInstance = null;
modify(element, [config]) {
// Cleanup previous instance if config changed
if (this.chartInstance) {
this.chartInstance.destroy();
}
this.chartInstance = new Chart(element, config);
// Register cleanup
registerDestructor(this, () => {
this.chartInstance?.destroy();
});
}
}
```
```glimmer-js
// app/components/chart.gjs
import chart from '../modifiers/chart';
<template>
<canvas {{chart @config}}></canvas>
</template>
```
**Use function modifiers** for simple side effects. Use class-based modifiers only when you need complex state management.
**For commonly needed modifiers, use ember-modifier helpers:**
```javascript
// app/modifiers/autofocus.js
import { modifier } from 'ember-modifier';
export default modifier((element) => {
element.focus();
});
```
```glimmer-js
// app/components/input-field.gjs
import autofocus from '../modifiers/autofocus';
<template><input {{autofocus}} type="text" /></template>
```
**Use ember-resize-observer-modifier for resize handling:**
```bash
ember install ember-resize-observer-modifier
```
```glimmer-js
// app/components/resizable.gjs
import onResize from 'ember-resize-observer-modifier';
<template>
<div {{onResize this.handleResize}}>
Content that responds to size changes
</div>
</template>
```
Modifiers provide a clean, reusable way to manage DOM side effects without coupling to specific components.
Reference: [Ember Modifiers](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/)
@@ -0,0 +1,277 @@
---
title: Use Reactive Collections from @ember/reactive/collections
impact: HIGH
impactDescription: Enables reactive arrays, maps, and sets
tags: reactivity, tracked, collections, advanced
---
## Use Reactive Collections from @ember/reactive/collections
Use reactive collections from `@ember/reactive/collections` to make arrays, Maps, and Sets reactive in Ember. Standard JavaScript collections don't trigger Ember's reactivity system when mutated—reactive collections solve this.
**The Problem:**
Standard arrays, Maps, and Sets are not reactive in Ember when you mutate them. Changes won't trigger template updates.
**The Solution:**
Use Ember's built-in reactive collections from `@ember/reactive/collections`.
### Reactive Arrays
**Incorrect (non-reactive array):**
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class TodoList extends Component {
@tracked todos = []; // ❌ Array mutations (push, splice, etc.) won't trigger updates
@action
addTodo(text) {
// This won't trigger a re-render!
this.todos.push({ id: Date.now(), text });
}
@action
removeTodo(id) {
// This also won't trigger a re-render!
const index = this.todos.findIndex((t) => t.id === id);
this.todos.splice(index, 1);
}
<template>
<ul>
{{#each this.todos as |todo|}}
<li>
{{todo.text}}
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
</li>
{{/each}}
</ul>
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
</template>
}
```
**Correct (reactive array with @ember/reactive/collections):**
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedArray } from '@ember/reactive/collections';
export default class TodoList extends Component {
todos = trackedArray([]); // ✅ Mutations are reactive
@action
addTodo(text) {
// Now this triggers re-render!
this.todos.push({ id: Date.now(), text });
}
@action
removeTodo(id) {
// This also triggers re-render!
const index = this.todos.findIndex((t) => t.id === id);
this.todos.splice(index, 1);
}
<template>
<ul>
{{#each this.todos as |todo|}}
<li>
{{todo.text}}
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
</li>
{{/each}}
</ul>
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
</template>
}
```
### Reactive Maps
Maps are useful for key-value stores with non-string keys:
```glimmer-js
// app/components/user-cache.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedMap } from '@ember/reactive/collections';
export default class UserCache extends Component {
userCache = trackedMap(); // key: userId, value: userData
@action
cacheUser(userId, userData) {
this.userCache.set(userId, userData);
}
@action
clearUser(userId) {
this.userCache.delete(userId);
}
get cachedUsers() {
return Array.from(this.userCache.values());
}
<template>
<ul>
{{#each this.cachedUsers as |user|}}
<li>{{user.name}}</li>
{{/each}}
</ul>
<p>Cache size: {{this.userCache.size}}</p>
</template>
}
```
### Reactive Sets
Sets are useful for unique collections:
```glimmer-js
// app/components/tag-selector.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedSet } from '@ember/reactive/collections';
export default class TagSelector extends Component {
selectedTags = trackedSet();
@action
toggleTag(tag) {
if (this.selectedTags.has(tag)) {
this.selectedTags.delete(tag);
} else {
this.selectedTags.add(tag);
}
}
get selectedCount() {
return this.selectedTags.size;
}
<template>
<div>
{{#each @availableTags as |tag|}}
<label>
<input
type="checkbox"
checked={{this.selectedTags.has tag}}
{{on "change" (fn this.toggleTag tag)}}
/>
{{tag}}
</label>
{{/each}}
</div>
<p>Selected: {{this.selectedCount}} tags</p>
</template>
}
```
### When to Use Each Type
| Type | Use Case |
| -------------- | ------------------------------------------------------------------ |
| `trackedArray` | Ordered lists that need mutation methods (push, pop, splice, etc.) |
| `trackedMap` | Key-value pairs with non-string keys or when you need `size` |
| `trackedSet` | Unique values, membership testing |
### Common Patterns
**Initialize with data:**
```javascript
import { trackedArray, trackedMap, trackedSet } from '@ember/reactive/collections';
// Array
const todos = trackedArray([
{ id: 1, text: 'First' },
{ id: 2, text: 'Second' },
]);
// Map
const userMap = trackedMap([
[1, { name: 'Alice' }],
[2, { name: 'Bob' }],
]);
// Set
const tags = trackedSet(['javascript', 'ember', 'web']);
```
**Convert to plain JavaScript:**
```javascript
// Array
const plainArray = [...trackedArray];
const plainArray2 = Array.from(trackedArray);
// Map
const plainObject = Object.fromEntries(trackedMap);
// Set
const plainArray3 = [...trackedSet];
```
**Functional array methods still work:**
```javascript
const todos = trackedArray([...]);
// All of these work and are reactive
const completed = todos.filter(t => t.done);
const titles = todos.map(t => t.title);
const allDone = todos.every(t => t.done);
const firstIncomplete = todos.find(t => !t.done);
```
### Alternative: Immutable Updates
If you prefer immutability, you can use regular `@tracked` with reassignment:
```javascript
import { tracked } from '@glimmer/tracking';
export default class TodoList extends Component {
@tracked todos = [];
@action
addTodo(text) {
// Reassignment is reactive
this.todos = [...this.todos, { id: Date.now(), text }];
}
@action
removeTodo(id) {
// Reassignment is reactive
this.todos = this.todos.filter((t) => t.id !== id);
}
}
```
**When to use each approach:**
- Use reactive collections when you need mutable operations (better performance for large lists)
- Use immutable updates when you want simpler mental model or need history/undo
### Best Practices
1. **Don't mix approaches** - choose either reactive collections or immutable updates
2. **Initialize in class field** - no need for constructor
3. **Use appropriate type** - Map for key-value, Set for unique values, Array for ordered lists
4. **Export from modules** if shared across components
Reactive collections from `@ember/reactive/collections` provide the best of both worlds: mutable operations with full reactivity. They're especially valuable for large lists or frequent updates where immutable updates would be expensive.
**References:**
- [Ember Reactivity System](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
- [JavaScript Built-in Objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects)
- [Reactive Collections RFC](https://github.com/emberjs/rfcs/blob/master/text/0869-reactive-collections.md)
@@ -0,0 +1,62 @@
---
title: Avoid Importing Entire Addon Namespaces
impact: CRITICAL
impactDescription: 200-500ms import cost reduction
tags: bundle, imports, tree-shaking, performance
---
## Avoid Importing Entire Addon Namespaces
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.
@@ -0,0 +1,69 @@
---
title: Use Embroider Build Pipeline
impact: CRITICAL
impactDescription: Modern build system with better performance
tags: bundle, embroider, build-performance, vite
---
## Use Embroider Build Pipeline
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: [Embroider Documentation](https://github.com/embroider-build/embroider)
@@ -0,0 +1,71 @@
---
title: Lazy Load Heavy Dependencies
impact: CRITICAL
impactDescription: 30-50% initial bundle reduction
tags: bundle, lazy-loading, dynamic-imports, performance
---
## Lazy Load Heavy Dependencies
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;
};
<template>
{{#if this.chartLoader.isLoading}}
<p>Loading chart library...</p>
{{else if this.chartLoader.isError}}
<p>Error loading chart: {{this.chartLoader.error.message}}</p>
{{else if this.chartLoader.isResolved}}
<canvas {{on "click" this.loadChart}}></canvas>
{{/if}}
</template>
}
```
**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.
@@ -0,0 +1,174 @@
---
title: Validate Component Arguments
impact: MEDIUM
impactDescription: Better error messages and type safety
tags: components, validation, arguments, typescript
---
## Validate Component Arguments
Validate component arguments for better error messages, documentation, and type safety.
**Incorrect (no argument validation):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div>
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
}
```
**Correct (with TypeScript signature):**
```glimmer-ts
// app/components/user-card.gts
import Component from '@glimmer/component';
interface UserCardSignature {
Args: {
user: {
name: string;
email: string;
avatarUrl?: string;
};
onEdit?: (user: UserCardSignature['Args']['user']) => void;
};
Blocks: {
default: [];
};
Element: HTMLDivElement;
}
class UserCard extends Component<UserCardSignature> {
<template>
<div ...attributes>
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
{{#if @user.avatarUrl}}
<img src={{@user.avatarUrl}} alt={{@user.name}} />
{{/if}}
{{#if @onEdit}}
<button {{on "click" (fn @onEdit @user)}}>Edit</button>
{{/if}}
{{yield}}
</div>
</template>
}
```
**Runtime validation with assertions (using getters):**
```glimmer-js
// app/components/data-table.gjs
import Component from '@glimmer/component';
import { assert } from '@ember/debug';
class DataTable extends Component {
// Use getters so validation runs on each access and catches arg changes
get columns() {
assert(
'DataTable requires @columns argument',
this.args.columns && Array.isArray(this.args.columns),
);
assert(
'@columns must be an array of objects with "key" and "label" properties',
this.args.columns.every((col) => col.key && col.label),
);
return this.args.columns;
}
get rows() {
assert('DataTable requires @rows argument', this.args.rows && Array.isArray(this.args.rows));
return this.args.rows;
}
<template>
<table>
<thead>
<tr>
{{#each this.columns as |column|}}
<th>{{column.label}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.rows as |row|}}
<tr>
{{#each this.columns as |column|}}
<td>{{get row column.key}}</td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</template>
}
```
**Template-only component with TypeScript:**
```glimmer-ts
// app/components/icon.gts
import type { TOC } from '@ember/component/template-only';
interface IconSignature {
Args: {
name: string;
size?: 'small' | 'medium' | 'large';
};
Element: HTMLSpanElement;
}
const Icon: TOC<IconSignature> = <template>
<span ...attributes></span>
</template>;
export default Icon;
```
**Documentation with JSDoc:**
```glimmer-js
// app/components/modal.gjs
import Component from '@glimmer/component';
/**
* Modal dialog component
*
* @param {Object} args
* @param {boolean} args.isOpen - Controls modal visibility
* @param {() => void} args.onClose - Called when modal should close
* @param {string} [args.title] - Optional modal title
* @param {string} [args.size='medium'] - Modal size: 'small', 'medium', 'large'
*/
class Modal extends Component {
<template>
{{#if @isOpen}}
<div>
{{#if @title}}
<h2>{{@title}}</h2>
{{/if}}
{{yield}}
<button {{on "click" @onClose}}>Close</button>
</div>
{{/if}}
</template>
}
```
Argument validation provides better error messages during development, serves as documentation, and enables better IDE support.
Reference: [TypeScript in Ember](https://guides.emberjs.com/release/typescript/)
@@ -0,0 +1,174 @@
---
title: Avoid CSS Classes in Learning Examples
impact: LOW-MEDIUM
impactDescription: Cleaner, more focused learning materials
tags: documentation, examples, learning, css, classes
---
## Avoid CSS Classes in Learning Examples
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 {
<template>
<div class="user-card">
<div class="user-card__header">
<h3 class="user-card__name">{{@user.name}}</h3>
<p class="user-card__email">{{@user.email}}</p>
</div>
{{#if @user.avatarUrl}}
<img class="user-card__avatar" src={{@user.avatarUrl}} alt={{@user.name}} />
{{/if}}
{{#if @onEdit}}
<button class="user-card__edit-button" {{on "click" (fn @onEdit @user)}}>
Edit
</button>
{{/if}}
<div class="user-card__content">
{{yield}}
</div>
</div>
</template>
}
```
**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 {
<template>
<div ...attributes>
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
{{#if @user.avatarUrl}}
<img src={{@user.avatarUrl}} alt={{@user.name}} />
{{/if}}
{{#if @onEdit}}
<button {{on "click" (fn @onEdit @user)}}>Edit</button>
{{/if}}
{{yield}}
</div>
</template>
}
```
**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 conditional classes
export class StatusBadge extends Component {
get statusClass() {
return this.args.status === 'active' ? 'badge-success' : 'badge-error';
}
<template>
<span class={{this.statusClass}}>
{{@status}}
</span>
</template>
}
```
```glimmer-js
// Example: Teaching about ...attributes for styling flexibility
export class Card extends Component {
<template>
{{! Caller can add their own classes via ...attributes }}
<div ...attributes>
{{yield}}
</div>
</template>
}
{{! Usage: <Card class="user-card">...</Card> }}
```
**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 dynamic classes
export class TabButton extends Component {
<template>
<button class={{if @isActive "active"}} {{on "click" @onClick}}>
{{yield}}
</button>
</template>
}
```
```glimmer-js
// Good: Teaching about class composition
import { cn } from 'ember-cn';
export class Button extends Component {
<template>
<button class={{cn "btn" (if @primary "btn-primary" "btn-secondary")}}>
{{yield}}
</button>
</template>
}
```
**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: [Ember Components Guide](https://guides.emberjs.com/release/components/)
@@ -0,0 +1,162 @@
---
title: Avoid Constructors in Components
impact: HIGH
impactDescription: Prevents infinite render loops and simplifies code
tags: components, constructors, initialization, anti-pattern
---
## Avoid Constructors in Components
**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.
**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;
}
}
<template>
{{#if this.isLoading}}
<div>Loading...</div>
{{else if this.error}}
<div>Error: {{this.error.message}}</div>
{{else if this.data}}
<h1>{{this.data.name}}</h1>
{{/if}}
</template>
}
```
**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}`,
});
}
<template>
{{#let (getRequestState this.userRequest) as |state|}}
{{#if state.isPending}}
<div>Loading...</div>
{{else if state.isError}}
<div>Error loading user</div>
{{else}}
<h1>{{state.value.name}}</h1>
{{/if}}
{{/let}}
</template>
}
```
**When You Might Need a Constructor (Very Rare):**
Very rarely, you might need a constructor for truly exceptional cases. Even then, use modern patterns:
```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
}
<template>
<!-- template -->
</template>
}
```
**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.
Reference:
- [Ember Octane Guide](https://guides.emberjs.com/release/upgrading/current-edition/)
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
@@ -0,0 +1,322 @@
---
title: Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
impact: HIGH
impactDescription: Prevents memory leaks and enforces modern patterns
tags: components, lifecycle, anti-pattern, modifiers, derived-data
---
## Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
**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.
### Why These Are Problematic
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
### Alternative 1: Use Derived Data
For computed values or reactive transformations, use getters and `@cached`:
**❌ 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}`;
}
<template>
<div {{did-update this.updateDisplayName @user}}>
Hello,
{{this.displayName}}
</div>
</template>
}
```
**✅ 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}`;
}
<template>
<div>
Hello,
{{this.displayName}}
</div>
</template>
}
```
**✅ 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,
};
}
<template>
<div>
<p>Total: {{this.statistics.total}}</p>
<p>Published: {{this.statistics.published}}</p>
<p>Drafts: {{this.statistics.drafts}}</p>
<ul>
{{#each this.sortedPosts as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
</div>
</template>
}
```
### Alternative 2: Use Custom Modifiers
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();
}
<template>
<canvas {{did-insert this.setupChart}}></canvas>
</template>
}
```
**✅ Correct (custom modifier with automatic cleanup):**
```javascript
// app/modifiers/chart.js
import { modifier } from 'ember-modifier';
import { registerDestructor } from '@ember/destroyable';
export default modifier((element, [config]) => {
// Setup
const chartInstance = new Chart(element, config);
// Cleanup happens automatically
registerDestructor(element, () => {
chartInstance.destroy();
});
});
```
```glimmer-js
// app/components/chart.gjs
import chart from '../modifiers/chart';
<template>
<canvas {{chart @config}}></canvas>
</template>
```
### Alternative 3: Use Resources for Lifecycle Management
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!
}
<template>
<div {{did-insert this.loadUser}}>
{{#if this.loading}}
Loading...
{{else}}
{{this.userData.name}}
{{/if}}
</div>
</template>
}
```
**✅ Correct (Resource with automatic cleanup):**
```javascript
// app/resources/user-data.js
import { Resource } from 'ember-resources';
import { tracked } from '@glimmer/tracking';
export default class UserDataResource extends Resource {
@tracked data = null;
@tracked loading = true;
controller = new AbortController();
modify(positional, named) {
const [userId] = positional;
this.loadUser(userId);
}
async loadUser(userId) {
this.loading = true;
try {
const response = await fetch(`/api/users/${userId}`, {
signal: this.controller.signal,
});
this.data = await response.json();
} finally {
this.loading = false;
}
}
willDestroy() {
// Cleanup happens automatically
this.controller.abort();
}
}
```
```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]);
<template>
{{#if this.userData.loading}}
Loading...
{{else}}
{{this.userData.data.name}}
{{/if}}
</template>
}
```
### When to Use Each Alternative
| 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 |
### Migration Strategy
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}}`
### Performance Benefits
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
### Common Pitfalls to Avoid
❌ **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**
### Summary
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.**
Reference:
- [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/)
@@ -0,0 +1,53 @@
---
title: Use @cached for Expensive Getters
impact: HIGH
impactDescription: 50-90% reduction in recomputation
tags: components, performance, caching, tracked
---
## Use @cached for Expensive Getters
Use `@cached` from `@glimmer/tracking` to memoize expensive computations that depend on tracked properties. The cached value is automatically invalidated when dependencies change.
**Incorrect (recomputes on every access):**
```javascript
import Component from '@glimmer/component';
class DataTable extends Component {
get filteredAndSortedData() {
// Expensive: runs on every access, even if nothing changed
return this.args.data
.filter((item) => item.status === this.args.filter)
.sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
.map((item) => this.transformItem(item));
}
}
```
**Correct (cached computation):**
```javascript
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class DataTable extends Component {
@cached
get filteredAndSortedData() {
// Computed once per unique combination of dependencies
return this.args.data
.filter((item) => item.status === this.args.filter)
.sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
.map((item) => this.transformItem(item));
}
transformItem(item) {
// Expensive transformation
return { ...item, computed: this.expensiveCalculation(item) };
}
}
```
`@cached` memoizes the getter result and only recomputes when tracked dependencies change, providing 50-90% reduction in unnecessary work.
Reference: [@cached decorator](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/#toc_caching)
@@ -0,0 +1,324 @@
---
title: Use Class Fields for Component Composition
impact: MEDIUM-HIGH
impactDescription: Better composition and initialization patterns
tags: components, class-fields, composition, initialization
---
## Use Class Fields for Component Composition
Use class fields for clean component composition, initialization, and dependency injection patterns. Tracked class fields should be **roots of state** - representing the minimal independent state that your component owns. In most apps, you should have very few tracked fields.
**Incorrect (imperative initialization, scattered state):**
```glimmer-js
// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
class DataManager extends Component {
@service store;
@service router;
// Scattered state management - hard to track relationships
@tracked currentUser = null;
@tracked isLoading = false;
@tracked error = null;
loadData = async () => {
this.isLoading = true;
try {
this.currentUser = await this.store.request({ url: '/users/me' });
} catch (e) {
this.error = e;
} finally {
this.isLoading = false;
}
};
<template>
<div>{{this.currentUser.name}}</div>
</template>
}
```
**Correct (class fields with proper patterns):**
```glimmer-js
// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { cached } from '@glimmer/tracking';
import { getPromiseState } from '@warp-drive/reactiveweb';
class DataManager extends Component {
// Service injection as class fields
@service store;
@service router;
// Tracked state as class fields - this is a "root of state"
// Most components should have very few of these
@tracked selectedFilter = 'all';
// Data loading with getPromiseState
@cached
get currentUser() {
const promise = this.store.request({
url: '/users/me',
});
return getPromiseState(promise);
}
<template>
{{#if this.currentUser.isFulfilled}}
<div>{{this.currentUser.value.name}}</div>
{{else if this.currentUser.isRejected}}
<div>Error: {{this.currentUser.error.message}}</div>
{{/if}}
</template>
}
```
**Understanding "roots of state":**
Tracked fields should represent **independent state** that your component owns - not derived data or loaded data. Examples of good tracked fields:
- User selections (selected tab, filter option)
- UI state (is modal open, is expanded)
- Form input values (not yet persisted)
In most apps, you'll have very few tracked fields because most data comes from arguments, services, or computed getters.
**Composition through class field assignment:**
```glimmer-js
// app/components/form-container.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { TrackedObject } from 'tracked-built-ins';
class FormContainer extends Component {
// Compose form state
@tracked formData = new TrackedObject({
firstName: '',
lastName: '',
email: '',
preferences: {
newsletter: false,
notifications: true,
},
});
// Compose validation state
@tracked errors = new TrackedObject({});
// Compose UI state
@tracked ui = new TrackedObject({
isSubmitting: false,
isDirty: false,
showErrors: false,
});
// Computed field based on composed state
get isValid() {
return Object.keys(this.errors).length === 0 && this.formData.email && this.formData.firstName;
}
get canSubmit() {
return this.isValid && !this.ui.isSubmitting && this.ui.isDirty;
}
updateField = (field, value) => {
this.formData[field] = value;
this.ui.isDirty = true;
this.validate(field, value);
};
validate(field, value) {
if (field === 'email' && !value.includes('@')) {
this.errors.email = 'Invalid email';
} else {
delete this.errors[field];
}
}
<template>
<form>
<input
value={{this.formData.firstName}}
{{on "input" (pick "target.value" (fn this.updateField "firstName"))}}
/>
<button disabled={{not this.canSubmit}}>
Submit
</button>
</form>
</template>
}
```
**Mixin-like composition with class fields:**
```javascript
// app/utils/pagination-mixin.js
import { tracked } from '@glimmer/tracking';
export class PaginationState {
@tracked page = 1;
@tracked perPage = 20;
get offset() {
return (this.page - 1) * this.perPage;
}
nextPage = () => {
this.page++;
};
prevPage = () => {
if (this.page > 1) this.page--;
};
goToPage = (page) => {
this.page = page;
};
}
```
```glimmer-js
// app/components/paginated-list.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { PaginationState } from '../utils/pagination-mixin';
class PaginatedList extends Component {
// Compose pagination functionality
pagination = new PaginationState();
@cached
get paginatedItems() {
const start = this.pagination.offset;
const end = start + this.pagination.perPage;
return this.args.items.slice(start, end);
}
get totalPages() {
return Math.ceil(this.args.items.length / this.pagination.perPage);
}
<template>
<div class="list">
{{#each this.paginatedItems as |item|}}
<div>{{item.name}}</div>
{{/each}}
<div class="pagination">
<button {{on "click" this.pagination.prevPage}} disabled={{eq this.pagination.page 1}}>
Previous
</button>
<span>Page {{this.pagination.page}} of {{this.totalPages}}</span>
<button
{{on "click" this.pagination.nextPage}}
disabled={{eq this.pagination.page this.totalPages}}
>
Next
</button>
</div>
</div>
</template>
}
```
**Shareable state objects:**
```javascript
// app/utils/selection-state.js
import { tracked } from '@glimmer/tracking';
import { TrackedSet } from 'tracked-built-ins';
export class SelectionState {
@tracked selectedIds = new TrackedSet();
get count() {
return this.selectedIds.size;
}
get hasSelection() {
return this.selectedIds.size > 0;
}
isSelected(id) {
return this.selectedIds.has(id);
}
toggle = (id) => {
if (this.selectedIds.has(id)) {
this.selectedIds.delete(id);
} else {
this.selectedIds.add(id);
}
};
selectAll = (items) => {
items.forEach((item) => this.selectedIds.add(item.id));
};
clear = () => {
this.selectedIds.clear();
};
}
```
```glimmer-js
// app/components/selectable-list.gjs
import Component from '@glimmer/component';
import { SelectionState } from '../utils/selection-state';
class SelectableList extends Component {
// Compose selection behavior
selection = new SelectionState();
get selectedItems() {
return this.args.items.filter((item) => this.selection.isSelected(item.id));
}
<template>
<div class="toolbar">
<button {{on "click" (fn this.selection.selectAll @items)}}>
Select All
</button>
<button {{on "click" this.selection.clear}}>
Clear
</button>
<span>{{this.selection.count}} selected</span>
</div>
<ul>
{{#each @items as |item|}}
<li class={{if (this.selection.isSelected item.id) "selected"}}>
<input
type="checkbox"
checked={{this.selection.isSelected item.id}}
{{on "change" (fn this.selection.toggle item.id)}}
/>
{{item.name}}
</li>
{{/each}}
</ul>
{{#if this.selection.hasSelection}}
<div class="actions">
<button>Delete {{this.selection.count}} items</button>
</div>
{{/if}}
</template>
}
```
Class fields provide clean composition patterns, better initialization, and shareable state objects that can be tested independently.
Reference: [JavaScript Class Fields](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Public_class_fields)
@@ -0,0 +1,241 @@
---
title: Use Component Composition Patterns
impact: HIGH
impactDescription: Better code reuse and maintainability
tags: components, composition, yield, blocks, contextual-components
---
## Use Component Composition Patterns
Use component composition with yield blocks, named blocks, and contextual components for flexible, reusable UI patterns.
**Named blocks** are for invocation consistency in design systems where you **don't want the caller to have full markup control**. They provide structured extension points while maintaining design system constraints - the same concept as named slots in other frameworks.
**Incorrect (monolithic component):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div class="user-card">
<div class="header">
<img src={{@user.avatar}} alt={{@user.name}} />
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
{{#if @showActions}}
<div class="actions">
<button {{on "click" @onEdit}}>Edit</button>
<button {{on "click" @onDelete}}>Delete</button>
</div>
{{/if}}
{{#if @showStats}}
<div class="stats">
<span>Posts: {{@user.postCount}}</span>
<span>Followers: {{@user.followers}}</span>
</div>
{{/if}}
</div>
</template>
}
```
**Correct (composable with named blocks):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div class="user-card" ...attributes>
{{#if (has-block "header")}}
{{yield to="header"}}
{{else}}
<div class="header">
<img src={{@user.avatar}} alt={{@user.name}} />
<h3>{{@user.name}}</h3>
</div>
{{/if}}
{{yield @user to="default"}}
{{#if (has-block "actions")}}
<div class="actions">
{{yield @user to="actions"}}
</div>
{{/if}}
{{#if (has-block "footer")}}
<div class="footer">
{{yield @user to="footer"}}
</div>
{{/if}}
</div>
</template>
}
```
**Usage with flexible composition:**
```glimmer-js
// app/components/user-list.gjs
import UserCard from './user-card';
<template>
{{#each @users as |user|}}
<UserCard @user={{user}}>
<:header>
<div class="custom-header">
<span class="badge">{{user.role}}</span>
<h3>{{user.name}}</h3>
</div>
</:header>
<:default as |u|>
<p class="bio">{{u.bio}}</p>
<p class="email">{{u.email}}</p>
</:default>
<:actions as |u|>
<button {{on "click" (fn @onEdit u)}}>Edit</button>
<button {{on "click" (fn @onDelete u)}}>Delete</button>
</:actions>
<:footer as |u|>
<div class="stats">
Posts:
{{u.postCount}}
| Followers:
{{u.followers}}
</div>
</:footer>
</UserCard>
{{/each}}
</template>
```
**Contextual components pattern:**
```glimmer-js
// app/components/data-table.gjs
import Component from '@glimmer/component';
import { hash } from '@ember/helper';
class HeaderCell extends Component {
<template>
<th class="sortable" {{on "click" @onSort}}>
{{yield}}
{{#if @sorted}}
<span class="sort-icon">{{if @ascending "↑" "↓"}}</span>
{{/if}}
</th>
</template>
}
class Row extends Component {
<template>
<tr class={{if @selected "selected"}}>
{{yield}}
</tr>
</template>
}
class Cell extends Component {
<template>
<td>{{yield}}</td>
</template>
}
class DataTable extends Component {
<template>
<table class="data-table">
{{yield (hash Header=HeaderCell Row=Row Cell=Cell)}}
</table>
</template>
}
```
**Using contextual components:**
```glimmer-js
// app/components/users-table.gjs
import DataTable from './data-table';
<template>
<DataTable as |Table|>
<thead>
<tr>
<Table.Header @onSort={{fn @onSort "name"}}>Name</Table.Header>
<Table.Header @onSort={{fn @onSort "email"}}>Email</Table.Header>
<Table.Header @onSort={{fn @onSort "role"}}>Role</Table.Header>
</tr>
</thead>
<tbody>
{{#each @users as |user|}}
<Table.Row @selected={{eq @selectedId user.id}}>
<Table.Cell>{{user.name}}</Table.Cell>
<Table.Cell>{{user.email}}</Table.Cell>
<Table.Cell>{{user.role}}</Table.Cell>
</Table.Row>
{{/each}}
</tbody>
</DataTable>
</template>
```
**Renderless component pattern:**
```glimmer-js
// app/components/dropdown.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { hash } from '@ember/helper';
class Dropdown extends Component {
@tracked isOpen = false;
@action
toggle() {
this.isOpen = !this.isOpen;
}
@action
close() {
this.isOpen = false;
}
<template>{{yield (hash isOpen=this.isOpen toggle=this.toggle close=this.close)}}</template>
}
```
```glimmer-js
// Usage
import Dropdown from './dropdown';
<template>
<Dropdown as |dd|>
<button {{on "click" dd.toggle}}>
Menu
{{if dd.isOpen "▲" "▼"}}
</button>
{{#if dd.isOpen}}
<ul class="dropdown-menu">
<li><a href="#" {{on "click" dd.close}}>Profile</a></li>
<li><a href="#" {{on "click" dd.close}}>Settings</a></li>
<li><a href="#" {{on "click" dd.close}}>Logout</a></li>
</ul>
{{/if}}
</Dropdown>
</template>
```
Component composition provides flexibility, reusability, and clean separation of concerns while maintaining type safety and clarity.
Reference: [Ember Components - Block Parameters](https://guides.emberjs.com/release/components/block-content/)
@@ -0,0 +1,328 @@
---
title: Use Native Forms with Platform Validation
impact: HIGH
impactDescription: Reduces JavaScript form complexity and improves built-in a11y
tags: components, forms, validation, accessibility, platform
---
## Use Native Forms with Platform Validation
Rely on native `<form>` elements and the browser's Constraint Validation API instead of reinventing form handling with JavaScript. The platform is really good at forms.
## Problem
Over-engineering forms with JavaScript when native browser features provide validation, accessibility, and UX patterns for free.
**Incorrect (Too much JavaScript):**
```glimmer-js
// app/components/signup-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
class SignupForm extends Component {
@tracked email = '';
@tracked emailError = '';
validateEmail = () => {
// ❌ Reinventing email validation
if (!this.email.includes('@')) {
this.emailError = 'Invalid email';
}
};
handleSubmit = (event) => {
event.preventDefault();
if (this.emailError) return;
// Submit logic
};
<template>
<div>
<input
type="text"
value={{this.email}}
{{on "input" this.updateEmail}}
{{on "blur" this.validateEmail}}
/>
{{#if this.emailError}}
<span class="error">{{this.emailError}}</span>
{{/if}}
<button type="button" {{on "click" this.handleSubmit}}>Submit</button>
</div>
</template>
}
```
## Solution: Let the Platform Do the Work
Use native `<form>` with proper input types and browser validation:
**Correct (Native form with platform validation):**
```glimmer-js
// app/components/signup-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class SignupForm extends Component {
@tracked validationErrors = null;
handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
// ✅ Use native checkValidity()
if (!form.checkValidity()) {
// Show native validation messages
form.reportValidity();
return;
}
// ✅ Use FormData API - no tracked state needed!
const formData = new FormData(form);
const data = Object.fromEntries(formData);
this.args.onSubmit(data);
};
<template>
<form {{on "submit" this.handleSubmit}}>
{{! ✅ Browser handles validation automatically }}
<input type="email" name="email" required placeholder="email@example.com" />
<input
type="password"
name="password"
required
minlength="8"
placeholder="Min 8 characters"
/>
<button type="submit">Sign Up</button>
</form>
</template>
}
```
**Performance: -15KB** (no validation libraries needed)
**Accessibility: +100%** (native form semantics and error announcements)
**Code: -50%** (let the platform handle it)
## Custom Validation Messages with Constraint Validation API
Access and display native validation state in your component:
```glimmer-js
// app/components/validated-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class ValidatedForm extends Component {
@tracked errors = new Map();
handleInput = (event) => {
const input = event.target;
// ✅ Access Constraint Validation API
if (!input.validity.valid) {
this.errors.set(input.name, input.validationMessage);
} else {
this.errors.delete(input.name);
}
};
handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
if (!form.checkValidity()) {
// Trigger native validation UI
form.reportValidity();
return;
}
const formData = new FormData(form);
this.args.onSubmit(Object.fromEntries(formData));
};
<template>
<form {{on "submit" this.handleSubmit}}>
<div>
<label for="email">Email</label>
<input id="email" type="email" name="email" required {{on "input" this.handleInput}} />
{{#if (this.errors.get "email")}}
<span class="error" role="alert">
{{this.errors.get "email"}}
</span>
{{/if}}
</div>
<div>
<label for="age">Age</label>
<input
id="age"
type="number"
name="age"
min="18"
max="120"
required
{{on "input" this.handleInput}}
/>
{{#if (this.errors.get "age")}}
<span class="error" role="alert">
{{this.errors.get "age"}}
</span>
{{/if}}
</div>
<button type="submit">Submit</button>
</form>
</template>
}
```
## Constraint Validation API Properties
The browser provides rich validation state via `input.validity`:
```javascript
handleInput = (event) => {
const input = event.target;
const validity = input.validity;
// Check specific validation states:
if (validity.valueMissing) {
// required field is empty
}
if (validity.typeMismatch) {
// type="email" but value isn't email format
}
if (validity.tooShort || validity.tooLong) {
// minlength/maxlength violated
}
if (validity.rangeUnderflow || validity.rangeOverflow) {
// min/max violated
}
if (validity.patternMismatch) {
// pattern attribute not matched
}
// Or use the aggregated validationMessage:
if (!validity.valid) {
this.showError(input.name, input.validationMessage);
}
};
```
## Custom Validation with setCustomValidity
For business logic validation beyond HTML5 constraints:
```glimmer-js
// app/components/password-match-form.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
class PasswordMatchForm extends Component {
validatePasswordMatch = (event) => {
const form = event.target.form;
const password = form.querySelector('[name="password"]');
const confirm = form.querySelector('[name="confirm"]');
// ✅ Use setCustomValidity for custom validation
if (password.value !== confirm.value) {
confirm.setCustomValidity('Passwords must match');
} else {
confirm.setCustomValidity(''); // Clear custom error
}
};
handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const formData = new FormData(form);
this.args.onSubmit(Object.fromEntries(formData));
};
<template>
<form {{on "submit" this.handleSubmit}}>
<input type="password" name="password" required minlength="8" placeholder="Password" />
<input
type="password"
name="confirm"
required
placeholder="Confirm password"
{{on "input" this.validatePasswordMatch}}
/>
<button type="submit">Create Account</button>
</form>
</template>
}
```
## When You Need Controlled State
Use controlled patterns when you need real-time interactivity that isn't form submission:
```glimmer-js
// app/components/live-search.gjs - Controlled state needed for instant search
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class LiveSearch extends Component {
@tracked query = '';
updateQuery = (event) => {
this.query = event.target.value;
// Instant search as user types
this.args.onSearch?.(this.query);
};
<template>
{{! Controlled state justified - need instant feedback }}
<input
type="search"
value={{this.query}}
{{on "input" this.updateQuery}}
placeholder="Search..."
/>
{{#if this.query}}
<p>Searching for: {{this.query}}</p>
{{/if}}
</template>
}
```
**Use controlled state when you need:**
- Real-time validation display as user types
- Character counters
- Live search/filtering
- Multi-step forms where state drives UI
- Form state that affects other components
**Use native forms when:**
- Simple submit-and-validate workflows
- Standard HTML5 validation is sufficient
- You want browser-native UX and accessibility
- Simpler code and less JavaScript is better
## References
- [MDN: Constraint Validation API](https://developer.mozilla.org/en-US/docs/Web/API/Constraint_validation)
- [MDN: FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
- [MDN: Form Validation](https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation)
- [Ember Guides: Event Handling](https://guides.emberjs.com/release/components/component-state-and-actions/)
@@ -0,0 +1,216 @@
---
title: Component File Naming and Export Conventions
impact: HIGH
impactDescription: Enforces consistent component structure and predictable imports
tags: components, naming, file-conventions, gjs, strict-mode
---
## Component File Naming and Export Conventions
### Rule
Follow modern Ember component file conventions: use `.gjs`/`.gts` files with `<template>` tags (never `.hbs` files), use kebab-case filenames, match class names to file names (in PascalCase), do not use the `Component` suffix in class names, and avoid `export default` in .gjs/.gts component files.
This export guidance applies to `.gjs`/`.gts` component files only. If your app still uses `.hbs`, keep default exports for resolver-facing invokables used there (or use a named export plus default alias in hybrid codebases).
**Incorrect:**
```handlebars
{{! app/components/user-card.hbs - WRONG: Using .hbs file }}
<div class='user-card'>
{{@name}}
</div>
```
```glimmer-js
// app/components/user-card.js - WRONG: Separate .js and .hbs files
import Component from '@glimmer/component';
export class UserCard extends Component {
// Logic here
}
```
```glimmer-js
// app/components/user-card.gjs - WRONG: Component suffix
import Component from '@glimmer/component';
export class UserCardComponent extends Component {
<template>
<div class="user-card">
{{@name}}
</div>
</template>
}
```
```glimmer-js
// app/components/UserProfile.gjs - WRONG: PascalCase filename
import Component from '@glimmer/component';
export class UserProfile extends Component {
<template>
<div class="profile">
{{@name}}
</div>
</template>
}
```
**Correct:**
```glimmer-js
// app/components/user-card.gjs - CORRECT: kebab-case filename, no Component suffix, no default export
import Component from '@glimmer/component';
export class UserCard extends Component {
<template>
<div class="user-card">
{{@name}}
</div>
</template>
}
```
```glimmer-js
// app/components/user-profile.gjs - CORRECT: All conventions followed
import Component from '@glimmer/component';
import { service } from '@ember/service';
export class UserProfile extends Component {
@service session;
<template>
<div class="profile">
<h1>{{@name}}</h1>
{{#if this.session.isAuthenticated}}
<button>Edit Profile</button>
{{/if}}
</div>
</template>
}
```
## Why
**Never use .hbs files:**
- `.gjs`/`.gts` files with `<template>` tags are the modern standard
- Co-located templates and logic in a single file improve maintainability
- Better tooling support (type checking, imports, refactoring)
- Enables strict mode and proper scope
- Avoid split between `.js` and `.hbs` files which makes components harder to understand
**Filename conventions:**
- Kebab-case filenames (`user-card.gjs`, not `UserCard.gjs`) follow web component standards and Ember conventions
- Predictable: component name maps directly to filename (UserCard → user-card.gjs)
- Avoids filesystem case-sensitivity issues across platforms
**Class naming:**
- No "Component" suffix - it's redundant (extends Component already declares the type)
- PascalCase class name matches the capitalized component invocation: `<UserCard />`
- Cleaner code: `UserCard` vs `UserCardComponent`
**No default export:**
- Modern .gjs/.gts files don't need `export default`
- The template compiler automatically exports the component
- Simpler syntax, less boilerplate
- Consistent with strict-mode semantics
## Naming Pattern Reference
| Filename | Class Name | Template Invocation |
| --------------------- | ---------------------- | -------------------- |
| `user-card.gjs` | `class UserCard` | `<UserCard />` |
| `loading-spinner.gjs` | `class LoadingSpinner` | `<LoadingSpinner />` |
| `nav-bar.gjs` | `class NavBar` | `<NavBar />` |
| `todo-list.gjs` | `class TodoList` | `<TodoList />` |
| `search-input.gjs` | `class SearchInput` | `<SearchInput />` |
**Conversion rule:**
- Filename: all lowercase, words separated by hyphens
- Class: PascalCase, same words, no hyphens
- `user-card.gjs` → `class UserCard`
## Special Cases
**Template-only components:**
```glimmer-js
// app/components/simple-card.gjs - Template-only, no class needed
<template>
<div class="card">
{{yield}}
</div>
</template>
```
**Components in subdirectories:**
```glimmer-js
// app/components/ui/button.gjs
import Component from '@glimmer/component';
export class Button extends Component {
<template>
<button type="button">
{{yield}}
</button>
</template>
}
// Usage: <Ui::Button />
```
**Nested namespaces:**
```glimmer-js
// app/components/admin/user/profile-card.gjs
import Component from '@glimmer/component';
export class ProfileCard extends Component {
<template>
<div class="admin-profile">
{{@user.name}}
</div>
</template>
}
// Usage: <Admin::User::ProfileCard />
```
## Impact
**Positive:**
- ⚡️ Cleaner, more maintainable code
- 🎯 Predictable mapping between files and classes
- 🌐 Follows web standards (kebab-case)
- 📦 Smaller bundle size (less export overhead)
- 🚀 Better alignment with modern Ember/Glimmer
**Negative:**
- None - this is the modern standard
## Metrics
- **Code clarity**: +30% (shorter, clearer names)
- **Bundle size**: -5-10 bytes per component (no export overhead)
- **Developer experience**: Improved (predictable naming)
## References
- [Ember Components Guide](https://guides.emberjs.com/release/components/)
- [Glimmer Components](https://github.com/glimmerjs/glimmer.js)
- [Template Tag Format RFC](https://github.com/emberjs/rfcs/pull/779)
- [Strict Mode Semantics](https://github.com/emberjs/rfcs/blob/master/text/0496-handlebars-strict-mode.md)
## Related Rules
- component-use-glimmer.md - Modern Glimmer component patterns
- component-strict-mode.md - Template-only components and strict mode
- route-templates.md - Route file naming conventions
@@ -0,0 +1,219 @@
---
title: Prevent Memory Leaks in Components
impact: HIGH
impactDescription: Avoid memory leaks and resource exhaustion
tags: memory, cleanup, lifecycle, performance
---
## Prevent Memory Leaks in Components
Properly clean up event listeners, timers, and subscriptions to prevent memory leaks.
**Incorrect (no cleanup):**
```glimmer-js
// app/components/live-clock.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
class LiveClock extends Component {
@tracked time = new Date();
constructor() {
super(...arguments);
// Memory leak: interval never cleared
setInterval(() => {
this.time = new Date();
}, 1000);
}
<template>
<div>{{this.time}}</div>
</template>
}
```
**Correct (proper cleanup with registerDestructor):**
```glimmer-js
// app/components/live-clock.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
class LiveClock extends Component {
@tracked time = new Date();
constructor() {
super(...arguments);
const intervalId = setInterval(() => {
this.time = new Date();
}, 1000);
// Proper cleanup
registerDestructor(this, () => {
clearInterval(intervalId);
});
}
<template>
<div>{{this.time}}</div>
</template>
}
```
**Event listener cleanup:**
```glimmer-js
// app/components/window-size.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
class WindowSize extends Component {
@tracked width = window.innerWidth;
@tracked height = window.innerHeight;
constructor() {
super(...arguments);
const handleResize = () => {
this.width = window.innerWidth;
this.height = window.innerHeight;
};
window.addEventListener('resize', handleResize);
registerDestructor(this, () => {
window.removeEventListener('resize', handleResize);
});
}
<template>
<div>Window: {{this.width}} x {{this.height}}</div>
</template>
}
```
**Using modifiers for automatic cleanup:**
```javascript
// app/modifiers/window-listener.js
import { modifier } from 'ember-modifier';
export default modifier((element, [eventName, handler]) => {
window.addEventListener(eventName, handler);
// Automatic cleanup when element is removed
return () => {
window.removeEventListener(eventName, handler);
};
});
```
```glimmer-js
// app/components/resize-aware.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import windowListener from '../modifiers/window-listener';
class ResizeAware extends Component {
@tracked size = { width: 0, height: 0 };
handleResize = () => {
this.size = {
width: window.innerWidth,
height: window.innerHeight,
};
};
<template>
<div {{windowListener "resize" this.handleResize}}>
{{this.size.width}}
x
{{this.size.height}}
</div>
</template>
}
```
**Abort controller for fetch requests:**
```glimmer-js
// app/components/data-loader.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
class DataLoader extends Component {
@tracked data = null;
abortController = new AbortController();
constructor() {
super(...arguments);
this.loadData();
registerDestructor(this, () => {
this.abortController.abort();
});
}
async loadData() {
try {
const response = await fetch('/api/data', {
signal: this.abortController.signal,
});
this.data = await response.json();
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to load data:', error);
}
}
}
<template>
{{#if this.data}}
<div>{{this.data.content}}</div>
{{/if}}
</template>
}
```
**Using ember-resources for automatic cleanup:**
```glimmer-js
// app/components/websocket-data.gjs
import Component from '@glimmer/component';
import { resource } from 'ember-resources';
class WebsocketData extends Component {
messages = resource(({ on }) => {
const messages = [];
const ws = new WebSocket('wss://example.com/socket');
ws.onmessage = (event) => {
messages.push(event.data);
};
// Automatic cleanup
on.cleanup(() => {
ws.close();
});
return messages;
});
<template>
{{#each this.messages.value as |message|}}
<div>{{message}}</div>
{{/each}}
</template>
}
```
Always clean up timers, event listeners, subscriptions, and pending requests to prevent memory leaks and performance degradation.
Reference: [Ember Destroyable](https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable)
@@ -0,0 +1,57 @@
---
title: Avoid Unnecessary Tracking
impact: HIGH
impactDescription: 20-40% fewer invalidations
tags: components, tracked, performance, reactivity
---
## Avoid Unnecessary Tracking
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.
@@ -0,0 +1,132 @@
---
title: Use {{on}} Modifier for Event Handling
impact: MEDIUM
impactDescription: Better memory management and clarity
tags: events, modifiers, on, performance
---
## Use {{on}} Modifier for Event Handling
Use the `{{on}}` modifier for event handling instead of traditional action handlers for better memory management and clearer code.
**Incorrect (traditional action attribute):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Button extends Component {
@action
handleClick() {
this.args.onClick?.();
}
<template>
<button onclick={{this.handleClick}}>
{{@label}}
</button>
</template>
}
```
**Correct (using {{on}} modifier):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
class Button extends Component {
handleClick = () => {
this.args.onClick?.();
};
<template>
<button {{on "click" this.handleClick}}>
{{@label}}
</button>
</template>
}
```
**With event options:**
```glimmer-js
// app/components/scroll-tracker.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
class ScrollTracker extends Component {
handleScroll = (event) => {
console.log('Scroll position:', event.target.scrollTop);
};
<template>
<div class="scrollable" {{on "scroll" this.handleScroll passive=true}}>
{{yield}}
</div>
</template>
}
```
**Multiple event handlers:**
```glimmer-js
// app/components/input-field.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class InputField extends Component {
@tracked isFocused = false;
handleFocus = () => {
this.isFocused = true;
};
handleBlur = () => {
this.isFocused = false;
};
handleInput = (event) => {
this.args.onInput?.(event.target.value);
};
<template>
<input
type="text"
class={{if this.isFocused "focused"}}
{{on "focus" this.handleFocus}}
{{on "blur" this.handleBlur}}
{{on "input" this.handleInput}}
value={{@value}}
/>
</template>
}
```
**Using fn helper for arguments:**
```glimmer-js
// app/components/item-list.gjs
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';
<template>
<ul>
{{#each @items as |item|}}
<li>
{{item.name}}
<button {{on "click" (fn @onDelete item.id)}}>
Delete
</button>
</li>
{{/each}}
</ul>
</template>
```
The `{{on}}` modifier properly cleans up event listeners, supports event options (passive, capture, once), and makes event handling more explicit.
Reference: [Ember Modifiers - on](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_event-handlers)
@@ -0,0 +1,292 @@
---
title: Build Reactive Chains with Dependent Getters
impact: HIGH
impactDescription: Clear data flow and automatic reactivity
tags: reactivity, getters, tracked, derived-state, composition
---
## Build Reactive Chains with Dependent Getters
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;
}
<template>
<div class="cart">
<div>Subtotal: ${{this.subtotal}}</div>
<div>Tax: ${{this.tax}}</div>
<div>Shipping: ${{this.shipping}}</div>
<div>Total: ${{this.total}}</div>
</div>
</template>
}
```
**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!
}
<template>
<div class="cart">
<div>Items: {{this.items.length}}</div>
<div>Subtotal: ${{this.subtotal.toFixed 2}}</div>
<div>Tax: ${{this.tax.toFixed 2}}</div>
<div>Shipping: ${{this.shipping.toFixed 2}}</div>
{{#if this.discount}}
<div class="discount">Discount: -${{this.discount.toFixed 2}}</div>
{{/if}}
<div class="total">Total: {{this.formattedTotal}}</div>
</div>
</template>
}
```
**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;
}
<template>
<div class="analysis">
<h3>Data Quality: {{this.qualityScore.toFixed 2}}</h3>
<div>Valid: {{this.validData.length}} / {{this.rawData.length}}</div>
<div>Mean: {{this.statistics.mean.toFixed 2}}</div>
<div>Std Dev: {{this.statistics.stdDev.toFixed 2}}</div>
<div>Outliers: {{this.outliers.length}}</div>
</div>
</template>
}
```
**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';
}
<template>
<div class="filtered-list">
<input
type="search"
value={{this.searchTerm}}
{{on "input" (pick "target.value" (set this "searchTerm"))}}
/>
<select
value={{this.selectedCategory}}
{{on "change" (pick "target.value" (set this "selectedCategory"))}}
>
<option value="all">All Categories</option>
{{#each @categories as |cat|}}
<option value={{cat}}>{{cat}}</option>
{{/each}}
</select>
<p>Showing {{this.resultsCount}} results</p>
{{#each this.items as |item|}}
<div>{{item.name}}</div>
{{/each}}
</div>
</template>
}
```
Reactive getter chains provide automatic updates, clear data dependencies, and better performance through intelligent caching with @cached.
Reference: [Glimmer Tracking](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
@@ -0,0 +1,86 @@
---
title: Use Strict Mode and Template-Only Components
impact: HIGH
impactDescription: Better type safety and simpler components
tags: strict-mode, template-only, components, gjs
---
## Use Strict Mode and Template-Only Components
Use strict mode and template-only components for simpler, safer code with better tooling support.
**Incorrect (JavaScript component for simple templates):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div class="user-card">
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
}
```
**Correct (template-only component):**
```glimmer-js
// app/components/user-card.gjs
<template>
<div class="user-card">
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
```
**With TypeScript for better type safety:**
```glimmer-ts
// app/components/user-card.gts
import type { TOC } from '@ember/component/template-only';
interface UserCardSignature {
Args: {
user: {
name: string;
email: string;
};
};
}
const UserCard: TOC<UserCardSignature> = <template>
<div class="user-card">
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>;
export default UserCard;
```
**Enable strict mode in your app:**
```javascript
// ember-cli-build.js
'use strict';
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
module.exports = function (defaults) {
const app = new EmberApp(defaults, {
'ember-cli-babel': {
enableTypeScriptTransform: true,
},
});
return app.toTree();
};
```
Template-only components are lighter, more performant, and easier to understand. Strict mode provides better error messages and prevents common mistakes.
Reference: [Ember Strict Mode](https://guides.emberjs.com/release/upgrading/current-edition/templates/)
@@ -0,0 +1,68 @@
---
title: Use Tracked Toolbox for Complex State
impact: HIGH
impactDescription: Cleaner state management
tags: components, tracked, state-management, performance
---
## Use Tracked Toolbox for Complex State
For complex state patterns like maps, sets, and arrays that need fine-grained reactivity, use tracked-toolbox utilities instead of marking entire structures as @tracked.
**Incorrect (tracking entire structures):**
```javascript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
class TodoList extends Component {
@tracked items = []; // Entire array replaced on every change
addItem = (item) => {
// Creates new array, invalidates all consumers
this.items = [...this.items, item];
};
removeItem = (index) => {
// Creates new array again
this.items = this.items.filter((_, i) => i !== index);
};
}
```
**Correct (using tracked-toolbox):**
```javascript
import Component from '@glimmer/component';
import { TrackedArray } from 'tracked-built-ins';
class TodoList extends Component {
items = new TrackedArray([]);
// Use arrow functions for methods used in templates (no @action needed)
addItem = (item) => {
// Efficiently adds to tracked array
this.items.push(item);
};
removeItem = (index) => {
// Efficiently removes from tracked array
this.items.splice(index, 1);
};
}
```
**Also useful for Maps and Sets:**
```javascript
import { TrackedMap, TrackedSet } from 'tracked-built-ins';
class Cache extends Component {
cache = new TrackedMap(); // Fine-grained reactivity per key
selected = new TrackedSet(); // Fine-grained reactivity per item
}
```
tracked-built-ins provides fine-grained reactivity and better performance than replacing entire structures.
Reference: [tracked-built-ins](https://github.com/tracked-tools/tracked-built-ins)
@@ -0,0 +1,56 @@
---
title: Use Glimmer Components Over Classic Components
impact: HIGH
impactDescription: 30-50% faster rendering
tags: components, glimmer, performance, reactivity
---
## Use Glimmer Components Over Classic Components
Glimmer components are lighter, faster, and have a simpler lifecycle than classic Ember components. They don't have two-way bindings or element lifecycle hooks, making them more predictable and performant.
**Incorrect (classic component):**
```javascript
// app/components/user-card.js
import Component from '@ember/component';
import { computed } from '@ember/object';
export default Component.extend({
tagName: 'div',
classNames: ['user-card'],
fullName: computed('user.{firstName,lastName}', function () {
return `${this.user.firstName} ${this.user.lastName}`;
}),
didInsertElement() {
this._super(...arguments);
// Complex lifecycle management
},
});
```
**Correct (Glimmer component):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
get fullName() {
return `${this.args.user.firstName} ${this.args.user.lastName}`;
}
<template>
<div class="user-card">
<h3>{{this.fullName}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
}
```
Glimmer components are 30-50% faster, have cleaner APIs, and integrate better with tracked properties.
Reference: [Glimmer Components](https://guides.emberjs.com/release/components/component-state-and-actions/)
@@ -0,0 +1,168 @@
---
title: Prefer Named Exports, Fallback to Default for Implicit Template Lookup
impact: LOW
impactDescription: Clear export contracts across .hbs and template-tag codebases
tags: exports, hbs, gjs, interop, code-organization
---
## Prefer Named Exports, Fallback to Default for Implicit Template Lookup
Use named exports for shared modules imported directly in JS/TS (utilities, constants, pure functions). If a module should be invokable from `.hbs` templates via implicit lookup, provide a default export. In hybrid `.gjs`/`.hbs` projects, a practical pattern is a named export plus a default export alias.
**Incorrect (default export in a shared utility module):**
```javascript
// app/utils/format-date.js
export default function formatDate(date) {
return new Date(date).toLocaleDateString();
}
```
**Correct (named export in a shared utility module):**
```javascript
// app/utils/format-date.js
export function formatDate(date) {
return new Date(date).toLocaleDateString();
}
```
**Correct (hybrid `.gjs`/`.hbs` named export + default alias):**
```javascript
// app/helpers/format-date.js
import { helper } from '@ember/component/helper';
export const formatDate = helper(([value]) => {
return new Date(value).toLocaleDateString();
});
export default formatDate;
```
## Where Named Exports Are Preferred
Use named exports when the module is imported directly by other modules and is not resolved via implicit template lookup.
**Example (utility module with multiple named exports):**
```javascript
// app/utils/validators.js
export function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
export function isPhoneNumber(value) {
return /^\d{3}-\d{3}-\d{4}$/.test(value);
}
```
Benefits:
1. Explicit import contracts
2. Better refactor safety (symbol rename tracking)
3. Better tree-shaking for utility modules
4. Easier multi-export module organization
## Where Default Exports Are Required
Use default exports for modules consumed through resolver/template lookup.
If your project uses `.hbs`, invokables that should be accessible from templates should provide `export default`.
In hybrid `.gjs`/`.hbs` codebases, use named exports plus a default export alias where you want both explicit imports and template compatibility.
**Service:**
```javascript
// app/services/auth.js
import Service from '@ember/service';
export default class AuthService extends Service {
// ...
}
```
**Route:**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class DashboardRoute extends Route {
@service store;
model() {
return this.store.findAll('dashboard-item');
}
}
```
**Modifier (when invoked from `.hbs`):**
```javascript
// app/modifiers/focus.js
import { modifier } from 'ember-modifier';
export default modifier((element) => {
element.focus();
});
```
**Template (`.gjs`):**
```glimmer-js
// app/templates/dashboard.gjs
<template>
<h1>Dashboard</h1>
</template>
```
**Template (`.gts`):**
```glimmer-ts
// app/templates/dashboard.gts
import type { TOC } from '@ember/component/template-only';
interface Signature {
Args: {
model: unknown;
};
}
export default <template>
<h1>Dashboard</h1>
</template> satisfies TOC<Signature>;
```
Template-tag files must resolve via a module default export in convention-based and `import.meta.glob` flows.
For `app/templates/*.gjs`, the default export is implicit after compilation.
## Strict Resolver Nuance
With `ember-strict-application-resolver`, you can register explicit module values in `App.modules`:
**Strict resolver explicit modules registration:**
```ts
modules = {
'./services/manual': { default: ManualService },
'./services/manual-shorthand': ManualService,
};
```
In that explicit shorthand case, a direct value works without a default-exported module object.
This is an explicit registration escape hatch and does not replace default-export requirements for `.hbs`-invokable modules.
## Rule of Thumb
1. If a module should be invokable from `.hbs`, provide a default export.
2. In hybrid `.gjs`/`.hbs` projects, use named export + default alias for resolver-facing modules.
3. Strict resolver explicit `modules` entries may use direct shorthand values where appropriate.
4. Plain shared modules (`app/utils`, shared constants, reusable pure functions): prefer named exports.
5. Template-tag components (`.gjs`/`.gts`): follow the component file-conventions rule and use named class exports.
## References
- [ES Modules Best Practices](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)
- [ember-strict-application-resolver](https://github.com/ember-cli/ember-strict-application-resolver)
- [ember-resolver](https://github.com/ember-cli/ember-resolver)
@@ -0,0 +1,362 @@
---
title: Use Helper Libraries Effectively
impact: MEDIUM
impactDescription: Reduces custom helper maintenance and keeps templates concise
tags: templates, helpers, ember-truth-helpers, ember-composable-helpers
---
## Use Helper Libraries Effectively
Leverage community helper libraries to write cleaner templates and avoid creating unnecessary custom helpers for common operations.
## Problem
Reinventing common functionality with custom helpers adds maintenance burden and bundle size when well-maintained helper libraries already provide the needed functionality.
**Incorrect:**
```glimmer-js
// app/utils/is-equal.js - Unnecessary custom helper
export function isEqual(a, b) {
return a === b;
}
// app/components/user-badge.gjs
import { isEqual } from '../utils/is-equal';
class UserBadge extends Component {
<template>
{{#if (isEqual @user.role "admin")}}
<span class="badge">Admin</span>
{{/if}}
</template>
}
```
## Solution
**Note:** These helpers will be built into Ember 7 core, but currently require installing the respective addon packages.
**Installation:**
```bash
npm install ember-truth-helpers ember-composable-helpers
```
Use helper libraries like `ember-truth-helpers` and `ember-composable-helpers`:
**Correct:**
```glimmer-js
// app/components/user-badge.gjs
import Component from '@glimmer/component';
import { eq } from 'ember-truth-helpers';
class UserBadge extends Component {
<template>
{{! eq helper from ember-truth-helpers }}
{{#if (eq @user.role "admin")}}
<span class="badge">Admin</span>
{{/if}}
</template>
}
```
## Comparison Helpers (ember-truth-helpers)
**Installation:** `npm install ember-truth-helpers`
```glimmer-js
// app/components/comparison-examples.gjs
import Component from '@glimmer/component';
import { eq, not, and, or, lt, lte, gt, gte } from 'ember-truth-helpers';
class ComparisonExamples extends Component {
<template>
{{! Equality }}
{{#if (eq @status "active")}}Active{{/if}}
{{! Negation }}
{{#if (not @isDeleted)}}Visible{{/if}}
{{! Logical AND }}
{{#if (and @isPremium @hasAccess)}}Premium Content{{/if}}
{{! Logical OR }}
{{#if (or @isAdmin @isModerator)}}Moderation Tools{{/if}}
{{! Comparisons }}
{{#if (gt @score 100)}}High Score!{{/if}}
{{#if (lte @attempts 3)}}Try again{{/if}}
</template>
}
```
## Array and Object Helpers (ember-composable-helpers)
**Installation:** `npm install ember-composable-helpers`
```glimmer-js
// app/components/collection-helpers.gjs
import Component from '@glimmer/component';
import { array, hash } from 'ember-composable-helpers/helpers';
import { get } from 'ember-composable-helpers/helpers';
class CollectionHelpers extends Component {
<template>
{{! Create array inline }}
{{#each (array "apple" "banana" "cherry") as |fruit|}}
<li>{{fruit}}</li>
{{/each}}
{{! Create object inline }}
{{#let (hash name="John" age=30 active=true) as |user|}}
<p>{{user.name}} is {{user.age}} years old</p>
{{/let}}
{{! Dynamic property access }}
<p>{{get @user @propertyName}}</p>
</template>
}
```
## String Helpers
```glimmer-js
// app/components/string-helpers.gjs
import Component from '@glimmer/component';
import { concat } from '@ember/helper'; // Built-in to Ember
class StringHelpers extends Component {
<template>
{{! Concatenate strings }}
<p class={{concat "user-" @user.id "-card"}}>
{{concat @user.firstName " " @user.lastName}}
</p>
{{! With dynamic values }}
<img
src={{concat "/images/" @category "/" @filename ".jpg"}}
alt={{concat "Image of " @title}}
/>
</template>
}
```
## Action Helpers (fn)
```glimmer-js
// app/components/action-helpers.gjs
import Component from '@glimmer/component';
import { fn } from '@ember/helper'; // Built-in to Ember
import { on } from '@ember/modifier';
class ActionHelpers extends Component {
updateValue = (field, event) => {
this.args.onChange(field, event.target.value);
};
deleteItem = (id) => {
this.args.onDelete(id);
};
<template>
{{! Partial application with fn }}
<input {{on "input" (fn this.updateValue "email")}} />
{{#each @items as |item|}}
<li>
{{item.name}}
<button {{on "click" (fn this.deleteItem item.id)}}>
Delete
</button>
</li>
{{/each}}
</template>
}
```
## Conditional Helpers (if/unless)
```glimmer-js
// app/components/conditional-inline.gjs
import Component from '@glimmer/component';
import { if as ifHelper } from '@ember/helper'; // Built-in to Ember
class ConditionalInline extends Component {
<template>
{{! Ternary-like behavior }}
<span class={{ifHelper @isActive "active" "inactive"}}>
{{@user.name}}
</span>
{{! Conditional attribute }}
<button disabled={{ifHelper @isProcessing true}}>
{{ifHelper @isProcessing "Processing..." "Submit"}}
</button>
{{! With default value }}
<p>{{ifHelper @description @description "No description provided"}}</p>
</template>
}
```
## Practical Combinations
**Dynamic Classes:**
```glimmer-js
// app/components/dynamic-classes.gjs
import Component from '@glimmer/component';
import { concat, if as ifHelper } from '@ember/helper'; // Built-in to Ember
import { and, not } from 'ember-truth-helpers';
class DynamicClasses extends Component {
<template>
<div
class={{concat
"card "
(ifHelper @isPremium "premium ")
(ifHelper (and @isNew (not @isRead)) "unread ")
@customClass
}}
>
<h3>{{@title}}</h3>
</div>
</template>
}
```
**List Filtering:**
```glimmer-js
// app/components/filtered-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { cached } from '@glimmer/tracking';
import { fn, concat } from '@ember/helper';
import { on } from '@ember/modifier';
import { eq } from 'ember-truth-helpers';
import { array } from 'ember-composable-helpers/helpers';
class FilteredList extends Component {
@tracked filter = 'all';
@cached
get filteredItems() {
if (this.filter === 'all') return this.args.items;
return this.args.items.filter((item) => item.status === this.filter);
}
<template>
<select {{on "change" (fn (mut this.filter) target.value)}}>
{{#each (array "all" "active" "pending" "completed") as |option|}}
<option value={{option}} selected={{eq this.filter option}}>
{{option}}
</option>
{{/each}}
</select>
{{#each this.filteredItems as |item|}}
<div class={{concat "item " item.status}}>
{{item.name}}
</div>
{{/each}}
</template>
}
```
## Complex Example
```glimmer-js
// app/components/user-profile-card.gjs
import Component from '@glimmer/component';
import { concat, if as ifHelper, fn } from '@ember/helper'; // Built-in to Ember
import { eq, not, and, or } from 'ember-truth-helpers';
import { hash, array, get } from 'ember-composable-helpers/helpers';
import { on } from '@ember/modifier';
class UserProfileCard extends Component {
updateField = (field, value) => {
this.args.onUpdate(field, value);
};
<template>
<div
class={{concat
"profile-card "
(ifHelper @user.isPremium "premium ")
(ifHelper (and @user.isOnline (not @user.isAway)) "online ")
}}
>
<h2>{{concat @user.firstName " " @user.lastName}}</h2>
{{#if (or (eq @user.role "admin") (eq @user.role "moderator"))}}
<span class="badge">
{{get (hash admin="Administrator" moderator="Moderator") @user.role}}
</span>
{{/if}}
{{#if (and @canEdit (not @user.locked))}}
<div class="actions">
{{#each (array "profile" "settings" "privacy") as |section|}}
<button {{on "click" (fn this.updateField "activeSection" section)}}>
Edit
{{section}}
</button>
{{/each}}
</div>
{{/if}}
<p class={{ifHelper @user.verified "verified" "unverified"}}>
{{ifHelper @user.bio @user.bio "No bio provided"}}
</p>
</div>
</template>
}
```
## Performance Impact
- **Library helpers**: ~0% overhead (compiled into efficient bytecode)
- **Custom helpers**: 5-15% overhead per helper call
- **Inline logic**: Cleaner templates, better tree-shaking
## When to Use
- **Library helpers**: For all common operations (equality, logic, arrays, strings)
- **Custom helpers**: Only for domain-specific logic not covered by library helpers
- **Component logic**: For complex operations that need @cached or multiple dependencies
## Complete Helper Reference
**Note:** These helpers will be built into Ember 7 core. Until then:
**Actually Built-in to Ember (from `@ember/helper`):**
- `concat` - Concatenate strings
- `fn` - Partial application / bind arguments
- `if` - Ternary-like conditional value
- `mut` - Create settable binding (use sparingly)
**From `ember-truth-helpers` package:**
- `eq` - Equality (===)
- `not` - Negation (!)
- `and` - Logical AND
- `or` - Logical OR
- `lt`, `lte`, `gt`, `gte` - Numeric comparisons
**From `ember-composable-helpers` package:**
- `array` - Create array inline
- `hash` - Create object inline
- `get` - Dynamic property access
## References
- [Ember Built-in Helpers](https://guides.emberjs.com/release/templates/built-in-helpers/)
- [Template Helpers API](https://api.emberjs.com/ember/release/modules/@ember%2Fhelper)
- [fn Helper Guide](https://guides.emberjs.com/release/components/helper-functions/)
- [ember-truth-helpers](https://github.com/jmurphyau/ember-truth-helpers)
- [ember-composable-helpers](https://github.com/DockYard/ember-composable-helpers)
@@ -0,0 +1,258 @@
---
title: Compose Helpers for Reusable Logic
impact: MEDIUM-HIGH
impactDescription: Better code reuse and testability
tags: helpers, composition, functions, pipes, reusability
---
## Compose Helpers for Reusable Logic
Compose helpers to create reusable, testable logic that can be combined in templates and components.
**Incorrect (logic duplicated in templates):**
```glimmer-js
// app/components/user-profile.gjs
<template>
<div class="profile">
<h1>{{uppercase (truncate @user.name 20)}}</h1>
{{#if (and @user.isActive (not @user.isDeleted))}}
<span class="status">Active</span>
{{/if}}
<p>{{lowercase @user.email}}</p>
{{#if (gt @user.posts.length 0)}}
<span>Posts: {{@user.posts.length}}</span>
{{/if}}
</div>
</template>
```
**Correct (composed helpers):**
```javascript
// app/helpers/display-name.js
export function displayName(name, { maxLength = 20 } = {}) {
if (!name) return '';
const truncated = name.length > maxLength ? name.slice(0, maxLength) + '...' : name;
return truncated.toUpperCase();
}
```
```javascript
// app/helpers/is-visible-user.js
export function isVisibleUser(user) {
return user && user.isActive && !user.isDeleted;
}
```
```javascript
// app/helpers/format-email.js
export function formatEmail(email) {
return email?.toLowerCase() || '';
}
```
```glimmer-js
// app/components/user-profile.gjs
import { displayName } from '../helpers/display-name';
import { isVisibleUser } from '../helpers/is-visible-user';
import { formatEmail } from '../helpers/format-email';
<template>
<div class="profile">
<h1>{{displayName @user.name}}</h1>
{{#if (isVisibleUser @user)}}
<span class="status">Active</span>
{{/if}}
<p>{{formatEmail @user.email}}</p>
{{#if (gt @user.posts.length 0)}}
<span>Posts: {{@user.posts.length}}</span>
{{/if}}
</div>
</template>
```
**Functional composition with pipe helper:**
```javascript
// app/helpers/pipe.js
export function pipe(...fns) {
return (value) => fns.reduce((acc, fn) => fn(acc), value);
}
```
**Or use a compose helper:**
```javascript
// app/helpers/compose.js
export function compose(...helperFns) {
return (value) => helperFns.reduceRight((acc, fn) => fn(acc), value);
}
```
**Usage:**
```glimmer-js
// app/components/text-processor.gjs
import { fn } from '@ember/helper';
// Individual helpers
const uppercase = (str) => str?.toUpperCase() || '';
const trim = (str) => str?.trim() || '';
const truncate = (str, length = 20) => str?.slice(0, length) || '';
<template>
{{! Compose multiple transformations }}
<div>
{{pipe @text (fn trim) (fn uppercase) (fn truncate 50)}}
</div>
</template>
```
**Higher-order helpers:**
```javascript
// app/helpers/partial-apply.js
export function partialApply(fn, ...args) {
return (...moreArgs) => fn(...args, ...moreArgs);
}
```
```javascript
// app/helpers/map-by.js
export function mapBy(array, property) {
return array?.map((item) => item[property]) || [];
}
```
```glimmer-js
// Usage in template
import { mapBy } from '../helpers/map-by';
import { partialApply } from '../helpers/partial-apply';
<template>
{{! Extract property from array }}
<ul>
{{#each (mapBy @users "name") as |name|}}
<li>{{name}}</li>
{{/each}}
</ul>
{{! Partial application }}
{{#let (partialApply @formatNumber 2) as |formatTwoDecimals|}}
<span>Price: {{formatTwoDecimals @price}}</span>
{{/let}}
</template>
```
**Chainable transformation helpers:**
```javascript
// app/helpers/transform.js
class Transform {
constructor(value) {
this.value = value;
}
filter(fn) {
this.value = this.value?.filter(fn) || [];
return this;
}
map(fn) {
this.value = this.value?.map(fn) || [];
return this;
}
sort(fn) {
this.value = [...(this.value || [])].sort(fn);
return this;
}
take(n) {
this.value = this.value?.slice(0, n) || [];
return this;
}
get result() {
return this.value;
}
}
export function transform(value) {
return new Transform(value);
}
```
```glimmer-js
// Usage
import { transform } from '../helpers/transform';
function filter(items) {
return items
.filter((item) => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
.take(10).result;
}
<template>
{{#let (transform @items) as |t|}}
{{#each (filter t) as |item|}}
<div>{{item.name}}</div>
{{/each}}
{{/let}}
</template>
```
**Conditional composition:**
```javascript
// app/helpers/when.js
export function when(condition, trueFn, falseFn) {
return condition ? trueFn() : falseFn ? falseFn() : null;
}
```
```javascript
// app/helpers/unless.js
export function unless(condition, falseFn, trueFn) {
return !condition ? falseFn() : trueFn ? trueFn() : null;
}
```
**Testing composed helpers:**
```javascript
// tests/helpers/display-name-test.js
import { module, test } from 'qunit';
import { displayName } from 'my-app/helpers/display-name';
module('Unit | Helper | display-name', function () {
test('it formats name correctly', function (assert) {
assert.strictEqual(displayName('John Doe'), 'JOHN DOE');
});
test('it truncates long names', function (assert) {
assert.strictEqual(
displayName('A Very Long Name That Should Be Truncated', { maxLength: 10 }),
'A VERY LON...',
);
});
test('it handles null', function (assert) {
assert.strictEqual(displayName(null), '');
});
});
```
Composed helpers provide testable, reusable logic that keeps templates clean and components focused on behavior rather than data transformation.
Reference: [Ember Helpers](https://guides.emberjs.com/release/components/helper-functions/)
@@ -0,0 +1,145 @@
---
title: No helper() Wrapper for Plain Functions
impact: LOW-MEDIUM
impactDescription: Simpler code, better performance
tags: helpers, templates, modern-ember
---
## No helper() Wrapper for Plain Functions
In modern Ember, plain functions can be used directly as helpers without wrapping them with `helper()`. The `helper()` wrapper is legacy and adds unnecessary complexity.
**Incorrect (using helper() wrapper):**
```javascript
// app/utils/format-date.js
import { helper } from '@ember/component/helper';
function formatDate([date]) {
return new Date(date).toLocaleDateString();
}
export default helper(formatDate);
```
**Correct (plain function):**
```javascript
// app/utils/format-date.js
export function formatDate(date) {
return new Date(date).toLocaleDateString();
}
```
**Usage in templates:**
```glimmer-js
// app/components/post-card.gjs
import { formatDate } from '../utils/format-date';
<template>
<article>
<h2>{{@post.title}}</h2>
<time>{{formatDate @post.publishedAt}}</time>
</article>
</template>
```
**With Multiple Arguments:**
```javascript
// app/utils/format-currency.js
export function formatCurrency(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
```
```glimmer-js
// app/components/price.gjs
import { formatCurrency } from '../utils/format-currency';
<template>
<span class="price">
{{formatCurrency @amount @currency}}
</span>
</template>
```
**For Helpers that Need Services (use class-based):**
When you need dependency injection, use a class instead of `helper()`:
```javascript
// app/utils/format-relative-time.js
export class FormatRelativeTime {
constructor(owner) {
this.intl = owner.lookup('service:intl');
}
compute(date) {
return this.intl.formatRelative(date);
}
}
```
**Why Avoid helper():**
1. **Simpler**: Plain functions are easier to understand
2. **Standard JavaScript**: No Ember-specific wrapper needed
3. **Better Testing**: Plain functions are easier to test
4. **Performance**: No wrapper overhead
5. **Modern Pattern**: Aligns with modern Ember conventions
**Migration from helper():**
```javascript
// Before
import { helper } from '@ember/component/helper';
function capitalize([text]) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export default helper(capitalize);
// After
export function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
```
**Common Helper Patterns:**
```javascript
// app/utils/string-helpers.js
export function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function truncate(text, length = 50) {
if (text.length <= length) return text;
return text.slice(0, length) + '...';
}
export function pluralize(count, singular, plural) {
return count === 1 ? singular : plural;
}
```
```glimmer-js
// Usage
import { capitalize, truncate, pluralize } from '../utils/string-helpers';
<template>
<h1>{{capitalize @title}}</h1>
<p>{{truncate @description 100}}</p>
<span>{{@count}} {{pluralize @count "item" "items"}}</span>
</template>
```
Plain functions are the modern way to create helpers in Ember. Only use classes when you need dependency injection.
Reference: [Ember Helpers - Plain Functions](https://guides.emberjs.com/release/components/helper-functions/)
@@ -0,0 +1,250 @@
---
title: Use {{on}} Modifier Instead of Event Handler Properties
impact: MEDIUM
impactDescription: Better performance and clearer event handling
tags: performance, events, modifiers, best-practices
---
## Use {{on}} Modifier Instead of Event Handler Properties
Always use the `{{on}}` modifier for event handling instead of HTML event handler properties. The `{{on}}` modifier provides better memory management, automatic cleanup, and clearer intent.
**Why {{on}} is Better:**
- Automatic cleanup when element is removed (prevents memory leaks)
- Supports event options (`capture`, `passive`, `once`)
- More explicit and searchable in templates
**Incorrect (HTML event properties):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
export default class Button extends Component {
@action
handleClick() {
console.log('clicked');
}
<template>
<button onclick={{this.handleClick}}>
Click Me
</button>
</template>
}
```
**Correct ({{on}} modifier):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class Button extends Component {
@action
handleClick() {
console.log('clicked');
}
<template>
<button {{on "click" this.handleClick}}>
Click Me
</button>
</template>
}
```
### Event Options
The `{{on}}` modifier supports standard event listener options:
```glimmer-js
// app/components/scrollable.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class Scrollable extends Component {
@action
handleScroll(event) {
console.log('scrolled', event.target.scrollTop);
}
<template>
{{! passive: true improves scroll performance }}
<div {{on "scroll" this.handleScroll passive=true}}>
{{yield}}
</div>
</template>
}
```
**Available options:**
- `capture` - Use capture phase instead of bubble phase
- `once` - Remove listener after first invocation
- `passive` - Indicates handler won't call `preventDefault()` (better scroll performance)
### Handling Multiple Events
```glimmer-js
// app/components/input-field.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class InputField extends Component {
@action
handleFocus() {
console.log('focused');
}
@action
handleBlur() {
console.log('blurred');
}
@action
handleInput(event) {
this.args.onChange?.(event.target.value);
}
<template>
<input
type="text"
value={{@value}}
{{on "focus" this.handleFocus}}
{{on "blur" this.handleBlur}}
{{on "input" this.handleInput}}
/>
</template>
}
```
### Preventing Default and Stopping Propagation
Handle these in your action, not in the template:
```glimmer-js
// app/components/form.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class Form extends Component {
@action
handleSubmit(event) {
event.preventDefault(); // Prevent page reload
event.stopPropagation(); // Stop event bubbling if needed
this.args.onSubmit?.(/* form data */);
}
<template>
<form {{on "submit" this.handleSubmit}}>
<button type="submit">Submit</button>
</form>
</template>
}
```
### Keyboard Events
```glimmer-js
// app/components/keyboard-nav.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class KeyboardNav extends Component {
@action
handleKeyDown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.args.onActivate?.();
}
if (event.key === 'Escape') {
this.args.onCancel?.();
}
}
<template>
<div role="button" tabindex="0" {{on "keydown" this.handleKeyDown}}>
{{yield}}
</div>
</template>
}
```
### Performance Tip: Event Delegation
For lists with many items, use event delegation on the parent:
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class TodoList extends Component {
@action
handleClick(event) {
// Find which todo was clicked
const todoId = event.target.closest('[data-todo-id]')?.dataset.todoId;
if (todoId) {
this.args.onTodoClick?.(todoId);
}
}
<template>
{{! Single listener for all todos - better than one per item }}
<ul {{on "click" this.handleClick}}>
{{#each @todos as |todo|}}
<li data-todo-id={{todo.id}}>
{{todo.title}}
</li>
{{/each}}
</ul>
</template>
}
```
### Common Pitfalls
**❌ Don't bind directly without @action:**
```glimmer-js
// This won't work - loses 'this' context
<button {{on "click" this.myMethod}}>Bad</button>
```
**✅ Use @action decorator:**
```glimmer-js
@action
myMethod() {
// 'this' is correctly bound
}
<button {{on "click" this.myMethod}}>Good</button>
```
**❌ Don't use string event handlers:**
```glimmer-js
{{! Security risk and doesn't work in strict mode }}
<button onclick="handleClick()">Bad</button>
```
Always use the `{{on}}` modifier for cleaner, safer, and more performant event handling in Ember applications.
**References:**
- [Ember Modifiers Guide](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/)
- [{{on}} Modifier RFC](https://github.com/emberjs/rfcs/blob/master/text/0471-on-modifier.md)
- [Event Listener Options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#parameters)
@@ -0,0 +1,45 @@
---
title: Use Route-Based Code Splitting
impact: CRITICAL
impactDescription: 30-70% initial bundle reduction
tags: routes, lazy-loading, embroider, bundle-size
---
## Use Route-Based Code Splitting
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: [Embroider Documentation](https://github.com/embroider-build/embroider)
@@ -0,0 +1,47 @@
---
title: Use Loading Substates for Better UX
impact: CRITICAL
impactDescription: Perceived performance improvement
tags: routes, loading, ux, performance
---
## Use Loading Substates for Better UX
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):**
```glimmer-js
// app/routes/posts-loading.gjs
import { LoadingSpinner } from './loading-spinner';
<template>
<div class="loading-spinner" role="status" aria-live="polite">
<span class="sr-only">Loading posts...</span>
<LoadingSpinner />
</div>
</template>
```
```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.
@@ -0,0 +1,230 @@
---
title: Implement Smart Route Model Caching
impact: MEDIUM-HIGH
impactDescription: Reduce redundant API calls and improve UX
tags: routes, caching, performance, model
---
## Implement Smart Route Model Caching
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}` });
}
<template>
<article>
<h1>{{@model.title}}</h1>
<div>{{@model.content}}</div>
</article>
{{outlet}}
</template>
}
```
**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;
}
<template>
<article>
<h1>{{@model.title}}</h1>
<div>{{@model.content}}</div>
</article>
{{outlet}}
</template>
}
```
**Service-based caching layer:**
```javascript
// app/services/post-cache.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { TrackedMap } from 'tracked-built-ins';
export default class PostCacheService extends Service {
@service store;
cache = new TrackedMap();
cacheTimes = new Map();
cacheTimeout = 5 * 60 * 1000; // 5 minutes
async getPost(id, { forceRefresh = false } = {}) {
const now = Date.now();
const cacheTime = this.cacheTimes.get(id) || 0;
const isFresh = now - cacheTime < this.cacheTimeout;
if (!forceRefresh && isFresh && this.cache.has(id)) {
return this.cache.get(id);
}
const post = await this.store.request({ url: `/posts/${id}` });
this.cache.set(id, post);
this.cacheTimes.set(id, now);
return post;
}
invalidate(id) {
this.cache.delete(id);
this.cacheTimes.delete(id);
}
invalidateAll() {
this.cache.clear();
this.cacheTimes.clear();
}
}
```
```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 });
}
<template>
<article>
<h1>{{@model.title}}</h1>
<div>{{@model.content}}</div>
</article>
{{outlet}}
</template>
}
```
**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,
});
}
<template>
<div class="posts">
<button {{on "click" (fn this.refresh)}}>
Refresh
</button>
<ul>
{{#each @model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
</div>
{{outlet}}
</template>
}
```
**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' });
}
<template>
<div class="dashboard">
<h1>Dashboard</h1>
<div>Stats: {{@model.stats}}</div>
</div>
{{outlet}}
</template>
}
```
Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh.
Reference: [WarpDrive Caching](https://warp-drive.io/)
@@ -0,0 +1,54 @@
---
title: Parallel Data Loading in Model Hooks
impact: CRITICAL
impactDescription: 2-10× improvement
tags: routes, data-fetching, parallelization, performance
---
## Parallel Data Loading in Model Hooks
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.
@@ -0,0 +1,105 @@
---
title: Use Route Templates with Co-located Syntax
impact: MEDIUM-HIGH
impactDescription: Better code organization and maintainability
tags: routes, templates, gjs, co-location
---
## Use Route Templates with Co-located Syntax
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)
<template>
<h1>Posts</h1>
<ul>
{{#each @model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
</template>
```
**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' });
}
<template>
<h1>Posts</h1>
<ul>
{{#each @model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
{{outlet}}
</template>
}
```
**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>
<div class="posts-page">
<h1>Posts</h1>
{{#if @model}}
<ul>
{{#each @model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
{{/if}}
{{outlet}}
</div>
</template>
}
```
**Template-only routes:**
```glimmer-js
// app/routes/about.gjs
<template>
<div class="about-page">
<h1>About Us</h1>
<p>Welcome to our application!</p>
</div>
</template>
```
Co-located route templates keep route logic and presentation together, making the codebase easier to navigate and maintain.
Reference: [Ember Routes](https://guides.emberjs.com/release/routing/)
@@ -0,0 +1,98 @@
---
title: Cache API Responses in Services
impact: MEDIUM-HIGH
impactDescription: 50-90% reduction in duplicate requests
tags: services, caching, performance, api
---
## Cache API Responses in Services
Cache API responses in services to avoid duplicate network requests. Use tracked properties to make the cache reactive.
**Incorrect (no caching):**
```javascript
// app/services/user.js
import Service from '@ember/service';
import { service } from '@ember/service';
export default class UserService extends Service {
@service store;
async getCurrentUser() {
// Fetches from API every time
return this.store.request({ url: '/users/me' });
}
}
```
**Correct (with caching):**
```javascript
// app/services/user.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TrackedMap } from 'tracked-built-ins';
export default class UserService extends Service {
@service store;
@tracked currentUser = null;
cache = new TrackedMap();
async getCurrentUser() {
if (!this.currentUser) {
const response = await this.store.request({ url: '/users/me' });
this.currentUser = response.content.data;
}
return this.currentUser;
}
async getUser(id) {
if (!this.cache.has(id)) {
const response = await this.store.request({ url: `/users/${id}` });
this.cache.set(id, response.content.data);
}
return this.cache.get(id);
}
clearCache() {
this.currentUser = null;
this.cache.clear();
}
}
```
**For time-based cache invalidation:**
```javascript
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class DataService extends Service {
@tracked _cache = null;
_cacheTimestamp = null;
_cacheDuration = 5 * 60 * 1000; // 5 minutes
async getData() {
const now = Date.now();
const isCacheValid =
this._cache && this._cacheTimestamp && now - this._cacheTimestamp < this._cacheDuration;
if (!isCacheValid) {
this._cache = await this.fetchData();
this._cacheTimestamp = now;
}
return this._cache;
}
async fetchData() {
const response = await fetch('/api/data');
return response.json();
}
}
```
Caching in services prevents duplicate API requests and improves performance significantly.
@@ -0,0 +1,342 @@
---
title: Implement Robust Data Requesting Patterns
impact: HIGH
impactDescription: Prevents request waterfalls and race conditions in data flows
tags: services, data-fetching, concurrency, cancellation, reliability
---
## Implement Robust Data Requesting Patterns
Use proper patterns for data fetching including parallel requests, error handling, request cancellation, and retry logic.
`export default` in route/service snippets below is intentional because these modules are commonly resolved by convention and referenced from templates. In hybrid `.gjs`/`.hbs` codebases, you can pair named exports with a default alias where needed.
## Problem
Naive data fetching creates waterfall requests, doesn't handle errors properly, and can cause race conditions or memory leaks from uncanceled requests.
**Incorrect:**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
export default class DashboardRoute extends Route {
async model() {
// Sequential waterfall - slow!
const user = await this.store.request({ url: '/users/me' });
const posts = await this.store.request({ url: '/posts' });
const notifications = await this.store.request({ url: '/notifications' });
// No error handling
// No cancellation
return { user, posts, notifications };
}
}
```
## Solution: Parallel Requests
Use `RSVP.hash` or `Promise.all` for parallel loading:
**Correct (parallelized model loading):**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
export default class DashboardRoute extends Route {
async 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' }),
});
}
}
```
## Error Handling Pattern
Handle errors gracefully with fallbacks:
```javascript
// app/services/api.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class ApiService extends Service {
@service store;
@tracked lastError = null;
async fetchWithFallback(url, fallback = null) {
try {
const response = await this.store.request({ url });
this.lastError = null;
return response.content;
} catch (error) {
this.lastError = error.message;
console.error(`API Error fetching ${url}:`, error);
return fallback;
}
}
async fetchWithRetry(url, { maxRetries = 3, delay = 1000 } = {}) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await this.store.request({ url });
} catch (error) {
if (attempt === maxRetries - 1) throw error;
await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1)));
}
}
}
}
```
## Request Cancellation with AbortController
Prevent race conditions by canceling stale requests:
```glimmer-js
// app/components/search-results.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { restartableTask, timeout } from 'ember-concurrency';
class SearchResults extends Component {
@service store;
@tracked results = [];
// Automatically cancels previous searches
@restartableTask
*searchTask(query) {
yield timeout(300); // Debounce
try {
const response = yield this.store.request({
url: `/search?q=${encodeURIComponent(query)}`,
});
this.results = response.content;
} catch (error) {
if (error.name !== 'TaskCancelation') {
console.error('Search failed:', error);
}
}
}
<template>
<input
type="search"
{{on "input" (fn this.searchTask.perform @value)}}
placeholder="Search..."
/>
{{#if this.searchTask.isRunning}}
<div class="loading">Searching...</div>
{{else}}
<ul>
{{#each this.results as |result|}}
<li>{{result.title}}</li>
{{/each}}
</ul>
{{/if}}
</template>
}
```
## Manual AbortController Pattern
For non-ember-concurrency scenarios:
```javascript
// app/services/data-fetcher.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
export default class DataFetcherService extends Service {
@service store;
@tracked data = null;
@tracked isLoading = false;
abortController = null;
constructor() {
super(...arguments);
registerDestructor(this, () => {
this.abortController?.abort();
});
}
async fetch(url) {
// Cancel previous request
this.abortController?.abort();
this.abortController = new AbortController();
this.isLoading = true;
try {
// Note: WarpDrive handles AbortSignal internally
const response = await this.store.request({
url,
signal: this.abortController.signal,
});
this.data = response.content;
} catch (error) {
if (error.name !== 'AbortError') {
throw error;
}
} finally {
this.isLoading = false;
}
}
}
```
## Dependent Requests Pattern
When requests depend on previous results:
```javascript
// app/routes/post.js
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
export default class PostRoute extends Route {
async model({ post_id }) {
// First fetch the post
const post = await this.store.request({
url: `/posts/${post_id}`,
});
// Then fetch related data in parallel
return hash({
post,
author: this.store.request({
url: `/users/${post.content.authorId}`,
}),
comments: this.store.request({
url: `/posts/${post_id}/comments`,
}),
relatedPosts: this.store.request({
url: `/posts/${post_id}/related`,
}),
});
}
}
```
## Polling Pattern
For real-time data updates:
```javascript
// app/services/live-data.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
export default class LiveDataService extends Service {
@service store;
@tracked data = null;
intervalId = null;
constructor() {
super(...arguments);
registerDestructor(this, () => {
this.stopPolling();
});
}
startPolling(url, interval = 5000) {
this.stopPolling();
this.poll(url); // Initial fetch
this.intervalId = setInterval(() => this.poll(url), interval);
}
async poll(url) {
try {
const response = await this.store.request({ url });
this.data = response.content;
} catch (error) {
console.error('Polling error:', error);
}
}
stopPolling() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
}
```
## Batch Requests
Optimize multiple similar requests:
```javascript
// app/services/batch-loader.js
import Service, { service } from '@ember/service';
export default class BatchLoaderService extends Service {
@service store;
pendingIds = new Set();
batchTimeout = null;
async loadUser(id) {
this.pendingIds.add(id);
if (!this.batchTimeout) {
this.batchTimeout = setTimeout(() => this.executeBatch(), 50);
}
// Return a promise that resolves when batch completes
return new Promise((resolve) => {
this.registerCallback(id, resolve);
});
}
async executeBatch() {
const ids = Array.from(this.pendingIds);
this.pendingIds.clear();
this.batchTimeout = null;
const response = await this.store.request({
url: `/users?ids=${ids.join(',')}`,
});
// Resolve all pending promises
response.content.forEach((user) => {
this.resolveCallback(user.id, user);
});
}
}
```
## Performance Impact
- **Parallel requests (RSVP.hash)**: 60-80% faster than sequential
- **Request cancellation**: Prevents memory leaks and race conditions
- **Retry logic**: Improves reliability with < 5% overhead
- **Batch loading**: 40-70% reduction in requests
## When to Use
- **RSVP.hash**: Independent data that can load in parallel
- **ember-concurrency**: Search, autocomplete, or user-driven requests
- **AbortController**: Long-running requests that may become stale
- **Retry logic**: Critical data with transient network issues
- **Batch loading**: Loading many similar items (N+1 scenarios)
## References
- [WarpDrive Documentation](https://warp-drive.io/)
- [ember-concurrency](https://ember-concurrency.com/)
- [RSVP.js](https://github.com/tildeio/rsvp.js)
- [AbortController MDN](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
@@ -0,0 +1,129 @@
---
title: Optimize WarpDrive Queries
impact: MEDIUM-HIGH
impactDescription: 40-70% reduction in API calls
tags: warp-drive, performance, api, optimization
---
## Optimize WarpDrive Queries
Use WarpDrive's request features effectively to reduce API calls and load only the data you need.
**Incorrect (multiple queries, overfetching):**
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
@service store;
async model() {
// Loads all posts (could be thousands)
const response = await this.store.request({ url: '/posts' });
const posts = response.content.data;
// Then filters in memory
return posts.filter((post) => post.attributes.status === 'published');
}
}
```
**Correct (filtered query with pagination):**
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
@service store;
queryParams = {
page: { refreshModel: true },
filter: { refreshModel: true },
};
model(params) {
// Server-side filtering and pagination
return this.store.request({
url: '/posts',
data: {
filter: {
status: 'published',
},
page: {
number: params.page || 1,
size: 20,
},
include: 'author', // Sideload related data
fields: {
// Sparse fieldsets
posts: 'title,excerpt,publishedAt,author',
users: 'name,avatar',
},
},
});
}
}
```
**Use request with includes for single records:**
```javascript
// app/routes/post.js
export default class PostRoute extends Route {
@service store;
model(params) {
return this.store.request({
url: `/posts/${params.post_id}`,
data: {
include: 'author,comments.user', // Nested relationships
},
});
}
}
```
**For frequently accessed data, use cache lookups:**
```javascript
// app/components/user-badge.js
class UserBadge extends Component {
@service store;
get user() {
// Check cache first, avoiding API call if already loaded
const cached = this.store.cache.peek({
type: 'user',
id: this.args.userId,
});
if (cached) {
return cached;
}
// Only fetch if not in cache
return this.store.request({
url: `/users/${this.args.userId}`,
});
}
}
```
**Use request options for custom queries:**
```javascript
model() {
return this.store.request({
url: '/posts',
data: {
include: 'author,tags',
customParam: 'value'
},
options: {
reload: true // Bypass cache
}
});
}
```
Efficient WarpDrive usage reduces network overhead and improves application performance significantly.
Reference: [WarpDrive Documentation](https://warp-drive.io/)
@@ -0,0 +1,460 @@
---
title: Manage Service Owner and Linkage Patterns
impact: MEDIUM-HIGH
impactDescription: Better service organization and dependency management
tags: services, owner, linkage, dependency-injection, architecture
---
## Manage Service Owner and Linkage Patterns
Understand how to manage service linkage, owner passing, and alternative service organization patterns beyond the traditional app/services directory.
### Owner and Linkage Fundamentals
**Incorrect (manual service instantiation):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import ApiService from '../services/api';
class UserProfile extends Component {
// ❌ Creates orphaned instance without owner
api = new ApiService();
async loadUser() {
// Won't have access to other services or owner features
return this.api.fetch('/user/me');
}
<template>
<div>{{@user.name}}</div>
</template>
}
```
**Correct (proper service injection with owner):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
class UserProfile extends Component {
// ✅ Proper injection with owner linkage
@service api;
async loadUser() {
// Has full owner context and can inject other services
return this.api.fetch('/user/me');
}
<template>
<div>{{@user.name}}</div>
</template>
}
```
### Manual Owner Passing (Without Libraries)
**Creating instances with owner:**
```glimmer-js
// app/components/data-processor.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { service } from '@ember/service';
class DataTransformer {
@service store;
transform(data) {
// Can use injected services because it has an owner
return this.store.request({ url: '/transform', data });
}
}
class DataProcessor extends Component {
@service('store') storeService;
constructor(owner, args) {
super(owner, args);
// Manual instantiation with owner linkage
this.transformer = new DataTransformer();
setOwner(this.transformer, getOwner(this));
}
processData(data) {
// transformer can now access services
return this.transformer.transform(data);
}
<template>
<div>Processing...</div>
</template>
}
```
**Factory pattern with owner:**
```javascript
// app/utils/logger-factory.js
import { getOwner } from '@ember/application';
class Logger {
constructor(owner, context) {
this.owner = owner;
this.context = context;
}
get config() {
// Access configuration service via owner
return getOwner(this).lookup('service:config');
}
log(message) {
if (this.config.enableLogging) {
console.log(`[${this.context}]`, message);
}
}
}
export function createLogger(owner, context) {
return new Logger(owner, context);
}
```
```glimmer-js
// Usage in component
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { createLogger } from '../utils/logger-factory';
class My extends Component {
logger = createLogger(getOwner(this), 'MyComponent');
performAction() {
this.logger.log('Action performed');
}
<template>
<button {{on "click" this.performAction}}>Do Something</button>
</template>
}
```
### Owner Passing with Modern Libraries
**Using reactiveweb's link() for ownership and destruction:**
The `link()` function from `reactiveweb` provides both ownership transfer and automatic destruction linkage.
```glimmer-js
// app/components/advanced-form.gjs
import Component from '@glimmer/component';
import { link } from 'reactiveweb/link';
class ValidationService {
validate(data) {
// Validation logic
return data.email && data.email.includes('@');
}
}
class FormStateManager {
data = { email: '' };
updateEmail(value) {
this.data.email = value;
}
}
export class AdvancedForm extends Component {
// link() handles both owner and destruction automatically
validation = link(this, () => new ValidationService());
formState = link(this, () => new FormStateManager());
get isValid() {
return this.validation.validate(this.formState.data);
}
<template>
<form>
<input value={{this.formState.data.email}} />
{{#if (not this.isValid)}}
<span>Invalid form</span>
{{/if}}
</form>
</template>
}
```
**Why use link():**
- Automatically transfers owner from parent to child instance
- Registers destructor so child is cleaned up when parent is destroyed
- No manual `setOwner` or `registerDestructor` calls needed
- See [RFC #1067](https://github.com/emberjs/rfcs/pull/1067) for the proposal and reasoning
- Documentation: https://reactive.nullvoxpopuli.com/functions/link.link.html
### Services Outside app/services Directory
**Using createService from ember-primitives:**
```glimmer-js
// app/components/analytics-tracker.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { createService } from 'ember-primitives/utils';
// Define service logic as a plain function
function AnalyticsService() {
let events = [];
return {
get events() {
return events;
},
track(event) {
events.push({ ...event, timestamp: Date.now() });
// Send to analytics endpoint
fetch('/analytics', {
method: 'POST',
body: JSON.stringify(event),
});
},
};
}
export class AnalyticsTracker extends Component {
// createService handles owner linkage and cleanup automatically
analytics = createService(this, AnalyticsService);
<template>
<div>Tracking {{this.analytics.events.length}} events</div>
</template>
}
```
**Why createService:**
- No need to extend Service class
- Automatic owner linkage and cleanup
- Simpler than manual setOwner/registerDestructor
- Documentation: https://ce1d7e18.ember-primitives.pages.dev/6-utils/createService.md
**Co-located services with components:**
```javascript
// app/components/shopping-cart/service.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TrackedArray } from 'tracked-built-ins';
import { action } from '@ember/object';
export class CartService extends Service {
@tracked items = new TrackedArray([]);
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
@action
addItem(item) {
this.items.push(item);
}
@action
removeItem(id) {
const index = this.items.findIndex((item) => item.id === id);
if (index > -1) this.items.splice(index, 1);
}
@action
clear() {
this.items.clear();
}
}
```
```glimmer-js
// app/components/shopping-cart/index.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { CartService } from './service';
class ShoppingCart extends Component {
cart = (() => {
const instance = new CartService();
setOwner(instance, getOwner(this));
return instance;
})();
<template>
<div class="cart">
<h3>Cart ({{this.cart.items.length}} items)</h3>
<div>Total: ${{this.cart.total}}</div>
{{#each this.cart.items as |item|}}
<div class="cart-item">
{{item.name}}
- ${{item.price}}
<button {{on "click" (fn this.cart.removeItem item.id)}}>
Remove
</button>
</div>
{{/each}}
<button {{on "click" this.cart.clear}}>Clear Cart</button>
</div>
</template>
}
```
**Service-like utilities in utils/ directory:**
```javascript
// app/utils/notification-manager.js
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { TrackedArray } from 'tracked-built-ins';
import { setOwner } from '@ember/application';
export class NotificationManager {
@tracked notifications = new TrackedArray([]);
constructor(owner) {
setOwner(this, owner);
}
@action
add(message, type = 'info') {
const notification = {
id: Math.random().toString(36),
message,
type,
timestamp: Date.now(),
};
this.notifications.push(notification);
// Auto-dismiss after 5 seconds
setTimeout(() => this.dismiss(notification.id), 5000);
}
@action
dismiss(id) {
const index = this.notifications.findIndex((n) => n.id === id);
if (index > -1) this.notifications.splice(index, 1);
}
}
```
```glimmer-js
// app/components/notification-container.gjs
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { NotificationManager } from '../utils/notification-manager';
class NotificationContainer extends Component {
notifications = new NotificationManager(getOwner(this));
<template>
<div class="notifications">
{{#each this.notifications.notifications as |notif|}}
<div class="notification notification-{{notif.type}}">
{{notif.message}}
<button {{on "click" (fn this.notifications.dismiss notif.id)}}>
×
</button>
</div>
{{/each}}
</div>
{{! Example usage }}
<button {{on "click" (fn this.notifications.add "Success!" "success")}}>
Show Notification
</button>
</template>
}
```
### Registering Custom Services Dynamically
**Runtime service registration:**
```javascript
// app/instance-initializers/dynamic-services.js
export function initialize(appInstance) {
// Register service dynamically without app/services file
appInstance.register(
'service:feature-flags',
class FeatureFlagsService {
flags = {
newDashboard: true,
betaFeatures: false,
};
isEnabled(flag) {
return this.flags[flag] || false;
}
},
);
// Make it a singleton
appInstance.inject('route', 'featureFlags', 'service:feature-flags');
appInstance.inject('component', 'featureFlags', 'service:feature-flags');
}
export default {
initialize,
};
```
**Using registered services:**
```glimmer-js
// app/components/feature-gated.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
class FeatureGated extends Component {
@service featureFlags;
get shouldShow() {
return this.featureFlags.isEnabled(this.args.feature);
}
<template>
{{#if this.shouldShow}}
{{yield}}
{{else}}
<div class="feature-disabled">This feature is not available</div>
{{/if}}
</template>
}
```
### Best Practices
1. **Use @service decorator** for app/services - cleanest and most maintainable
2. **Use link() from reactiveweb** for ownership and destruction linkage
3. **Use createService from ember-primitives** for component-scoped services without extending Service class
4. **Manual owner passing** for utilities that need occasional service access
5. **Co-located services** for component-specific state that doesn't need global access
6. **Runtime registration** for dynamic services or testing scenarios
7. **Always use setOwner** when manually instantiating classes that need services
### When to Use Each Pattern
- **app/services**: Global singletons needed across the app
- **link() from reactiveweb**: When you need both owner and destruction linkage
- **createService from ember-primitives**: Component-scoped services without Service class
- **Co-located services**: Component-specific state, not needed elsewhere
- **Utils with owner**: Stateless utilities that occasionally need config/services
- **Runtime registration**: Dynamic configuration, feature flags, testing
Reference: [Ember Owner API](https://api.emberjs.com/ember/release/functions/@ember%2Fapplication/getOwner), [Dependency Injection](https://guides.emberjs.com/release/applications/dependency-injection/), [reactiveweb link()](https://reactive.nullvoxpopuli.com/functions/link.link.html), [ember-primitives createService](https://ce1d7e18.ember-primitives.pages.dev/6-utils/createService.md)
@@ -0,0 +1,119 @@
---
title: Use Services for Shared State
impact: MEDIUM-HIGH
impactDescription: Better state management and reusability
tags: services, state-management, dependency-injection
---
## Use Services for Shared State
Use services to manage shared state across components and routes instead of passing data through multiple layers or duplicating state.
**Incorrect (prop drilling):**
```glimmer-js
// app/routes/dashboard.gjs
export default class DashboardRoute extends Route {
model() {
return { currentTheme: 'dark' };
}
<template>
<Header @theme={{@model.currentTheme}} />
<Sidebar @theme={{@model.currentTheme}} />
<MainContent @theme={{@model.currentTheme}} />
</template>
}
```
**Correct (using service):**
```javascript
// app/services/theme.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class ThemeService extends Service {
@tracked currentTheme = 'dark';
@action
setTheme(theme) {
this.currentTheme = theme;
localStorage.setItem('theme', theme);
}
@action
loadTheme() {
this.currentTheme = localStorage.getItem('theme') || 'dark';
}
}
```
```javascript
// app/components/header.js
import Component from '@glimmer/component';
import { service } from '@ember/service';
class Header extends Component {
@service theme;
// Access theme.currentTheme directly
}
```
```javascript
// app/components/sidebar.js
import Component from '@glimmer/component';
import { service } from '@ember/service';
class Sidebar extends Component {
@service theme;
// Access theme.currentTheme directly
}
```
Services provide centralized state management with automatic reactivity through tracked properties.
**For complex state, consider using Ember Data or ember-orbit:**
```javascript
// app/services/cart.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { TrackedArray } from 'tracked-built-ins';
import { cached } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class CartService extends Service {
@service store;
items = new TrackedArray([]);
@cached
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
@cached
get itemCount() {
return this.items.length;
}
@action
addItem(item) {
this.items.push(item);
}
@action
removeItem(item) {
const index = this.items.indexOf(item);
if (index > -1) {
this.items.splice(index, 1);
}
}
}
```
Reference: [Ember Services](https://guides.emberjs.com/release/services/)
@@ -0,0 +1,94 @@
---
title: Avoid Heavy Computation in Templates
impact: MEDIUM
impactDescription: 40-60% reduction in render time
tags: templates, performance, getters, helpers
---
## Avoid Heavy Computation in Templates
Move expensive computations from templates to cached getters in the component class or in-scope functions for template-only components. Templates should only display data, not compute it. Keep templates easy for humans to read by minimizing nested function invocations.
**Why this matters:**
- Templates should be easy to read and understand
- Nested function calls create cognitive overhead
- Computations should be cached and reused, not recalculated on every render
- Template-only components (without `this`) need alternative patterns
**Incorrect (heavy computation in template):**
```glimmer-js
// app/components/stats.gjs
import { sum, map, div, max, multiply, sortBy } from '../helpers/math';
<template>
<div>
<p>Total: {{sum (map @items "price")}}</p>
<p>Average: {{div (sum (map @items "price")) @items.length}}</p>
<p>Max: {{max (map @items "price")}}</p>
{{#each (sortBy "name" @items) as |item|}}
<div>{{item.name}}: {{multiply item.price item.quantity}}</div>
{{/each}}
</div>
</template>
```
**Correct (computation in component with cached getters):**
```glimmer-js
// app/components/stats.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
export class Stats extends Component {
// @cached is useful when getters are accessed multiple times
// For single access, regular getters are sufficient
@cached
get total() {
return this.args.items.reduce((sum, item) => sum + item.price, 0);
}
get average() {
// No @cached needed if only accessed once in template
return this.args.items.length > 0 ? this.total / this.args.items.length : 0;
}
get maxPrice() {
return Math.max(...this.args.items.map((item) => item.price));
}
@cached
get sortedItems() {
// @cached useful here as it's used by itemsWithTotal
return [...this.args.items].sort((a, b) => a.name.localeCompare(b.name));
}
@cached
get itemsWithTotal() {
// @cached useful as accessed multiple times in {{#each}}
return this.sortedItems.map((item) => ({
...item,
total: item.price * item.quantity,
}));
}
<template>
<div>
<p>Total: {{this.total}}</p>
<p>Average: {{this.average}}</p>
<p>Max: {{this.maxPrice}}</p>
{{#each this.itemsWithTotal key="id" as |item|}}
<div>{{item.name}}: {{item.total}}</div>
{{/each}}
</div>
</template>
}
```
**Note on @cached**: Use `@cached` when a getter is accessed multiple times (like in `{{#each}}` loops or by other getters). For getters accessed only once, regular getters are sufficient and avoid unnecessary memoization overhead.
Moving computations to getters ensures they run only when dependencies change, not on every render. Templates remain clean and readable.
@@ -0,0 +1,280 @@
---
title: Optimize Conditional Rendering
impact: HIGH
impactDescription: Reduces unnecessary rerenders in dynamic template branches
tags: templates, conditionals, rendering, performance, glimmer
---
## Optimize Conditional Rendering
Use efficient conditional rendering patterns to minimize unnecessary DOM updates and improve rendering performance.
## Problem
Inefficient conditional logic causes excessive re-renders, creates complex template code, and can lead to poor performance in lists and dynamic UIs.
**Incorrect:**
```glimmer-js
// app/components/user-list.gjs
import Component from '@glimmer/component';
class UserList extends Component {
<template>
{{#each @users as |user|}}
<div class="user">
{{! Recomputes every time}}
{{#if (eq user.role "admin")}}
<span class="badge admin">{{user.name}} (Admin)</span>
{{/if}}
{{#if (eq user.role "moderator")}}
<span class="badge mod">{{user.name}} (Mod)</span>
{{/if}}
{{#if (eq user.role "user")}}
<span>{{user.name}}</span>
{{/if}}
</div>
{{/each}}
</template>
}
```
## Solution
Use `{{#if}}` / `{{#else if}}` / `{{#else}}` chains and extract computed logic to getters for better performance and readability.
**Correct:**
```glimmer-js
// app/components/user-list.gjs
import Component from '@glimmer/component';
class UserList extends Component {
<template>
{{#each @users as |user|}}
<div class="user">
{{#if (eq user.role "admin")}}
<span class="badge admin">{{user.name}} (Admin)</span>
{{else if (eq user.role "moderator")}}
<span class="badge mod">{{user.name}} (Mod)</span>
{{else}}
<span>{{user.name}}</span>
{{/if}}
</div>
{{/each}}
</template>
}
```
## Extracted Logic Pattern
For complex conditions, use getters:
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class UserCard extends Component {
@cached
get isActive() {
return this.args.user.status === 'active' && this.args.user.lastLoginDays < 30;
}
@cached
get showActions() {
return this.args.canEdit && !this.args.user.locked && this.isActive;
}
<template>
<div class="user-card">
<h3>{{@user.name}}</h3>
{{#if this.isActive}}
<span class="status active">Active</span>
{{else}}
<span class="status inactive">Inactive</span>
{{/if}}
{{#if this.showActions}}
<div class="actions">
<button>Edit</button>
<button>Delete</button>
</div>
{{/if}}
</div>
</template>
}
```
## Conditional Lists
Use `{{#if}}` to guard `{{#each}}` and avoid rendering empty states:
```glimmer-js
// app/components/task-list.gjs
import Component from '@glimmer/component';
class TaskList extends Component {
get hasTasks() {
return this.args.tasks?.length > 0;
}
<template>
{{#if this.hasTasks}}
<ul class="task-list">
{{#each @tasks as |task|}}
<li>
{{task.title}}
{{#if task.completed}}
<span class="done">✓</span>
{{/if}}
</li>
{{/each}}
</ul>
{{else}}
<p class="empty-state">No tasks yet</p>
{{/if}}
</template>
}
```
## Avoid Nested Conditionals
**Bad:**
```glimmer-js
{{#if @user}}
{{#if @user.isPremium}}
{{#if @user.hasAccess}}
<PremiumContent />
{{/if}}
{{/if}}
{{/if}}
```
**Good:**
```glimmer-js
// app/components/content-gate.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class ContentGate extends Component {
@cached
get canViewPremium() {
return this.args.user?.isPremium && this.args.user?.hasAccess;
}
<template>
{{#if this.canViewPremium}}
<PremiumContent />
{{else}}
<UpgradeCTA />
{{/if}}
</template>
}
```
## Component Switching Pattern
Use conditional rendering for component selection:
```glimmer-js
// app/components/media-viewer.gjs
import Component from '@glimmer/component';
import ImageViewer from './image-viewer';
import VideoPlayer from './video-player';
import AudioPlayer from './audio-player';
import { cached } from '@glimmer/tracking';
class MediaViewer extends Component {
@cached
get mediaType() {
return this.args.media?.type;
}
<template>
{{#if (eq this.mediaType "image")}}
<ImageViewer @src={{@media.url}} />
{{else if (eq this.mediaType "video")}}
<VideoPlayer @src={{@media.url}} />
{{else if (eq this.mediaType "audio")}}
<AudioPlayer @src={{@media.url}} />
{{else}}
<p>Unsupported media type</p>
{{/if}}
</template>
}
```
## Loading States
Pattern for async data with loading/error states:
```glimmer-js
// app/components/data-display.gjs
import Component from '@glimmer/component';
import { Resource } from 'ember-resources';
import { resource } from 'ember-resources';
class DataResource extends Resource {
@tracked data = null;
@tracked isLoading = true;
@tracked error = null;
modify(positional, named) {
this.fetchData(named.url);
}
async fetchData(url) {
this.isLoading = true;
this.error = null;
try {
const response = await fetch(url);
this.data = await response.json();
} catch (e) {
this.error = e.message;
} finally {
this.isLoading = false;
}
}
}
class DataDisplay extends Component {
@resource data = DataResource.from(() => ({
url: this.args.url,
}));
<template>
{{#if this.data.isLoading}}
<div class="loading">Loading...</div>
{{else if this.data.error}}
<div class="error">Error: {{this.data.error}}</div>
{{else}}
<div class="content">
{{this.data.data}}
</div>
{{/if}}
</template>
}
```
## Performance Impact
- **Chained if/else**: 40-60% faster than multiple independent {{#if}} blocks
- **Extracted getters**: ~20% faster for complex conditions (cached)
- **Component switching**: Same performance as {{#if}} but better code organization
## When to Use
- **{{#if}}/{{#else}}**: For simple true/false conditions
- **Extracted getters**: For complex or reused conditions
- **Component switching**: For different component types based on state
- **Guard clauses**: To avoid rendering large subtrees when not needed
## References
- [Ember Guides - Conditionals](https://guides.emberjs.com/release/components/conditional-content/)
- [Glimmer VM Performance](https://github.com/glimmerjs/glimmer-vm)
- [@cached decorator](https://api.emberjs.com/ember/release/functions/@glimmer%2Ftracking/cached)
@@ -0,0 +1,91 @@
---
title: Use {{#each}} with @key for Lists
impact: MEDIUM
impactDescription: 50-100% faster list updates
tags: templates, each, performance, rendering
---
## Use {{#each}} with @key for Lists
Use the `key=` parameter with `{{#each}}` when objects are recreated between renders (e.g., via `.map()` or fresh API data). The default behavior uses object identity (`@identity`), which works when object references are stable.
**Incorrect (no key):**
```glimmer-js
// app/components/user-list.gjs
import UserCard from './user-card';
<template>
<ul>
{{#each this.users as |user|}}
<li>
<UserCard @user={{user}} />
</li>
{{/each}}
</ul>
</template>
```
**Correct (with key):**
```glimmer-js
// app/components/user-list.gjs
import UserCard from './user-card';
<template>
<ul>
{{#each this.users key="id" as |user|}}
<li>
<UserCard @user={{user}} />
</li>
{{/each}}
</ul>
</template>
```
**For arrays of primitives (strings, numbers):**
`@identity` is the default, so you rarely need to specify it explicitly. It compares items by value for primitives.
```glimmer-js
// app/components/tag-list.gjs
<template>
{{! @identity is implicit, no need to write it }}
{{#each this.tags as |tag|}}
<span class="tag">{{tag}}</span>
{{/each}}
</template>
```
**For complex scenarios with @index:**
```glimmer-js
// app/components/item-list.gjs
<template>
{{#each this.items key="@index" as |item index|}}
<div data-index={{index}}>
{{item.name}}
</div>
{{/each}}
</template>
```
Using proper keys allows Ember's rendering engine to efficiently update, reorder, and remove items without re-rendering the entire list.
**When to use `key=`:**
- Objects recreated between renders (`.map()`, generators, fresh API responses) → use `key="id"` or similar
- High-frequency updates (animations, real-time data) → always specify a key
- Stable object references (Apollo cache, Ember Data) → default `@identity` is fine
- Items never reorder → `key="@index"` is acceptable
**Performance comparison (dbmon benchmark, 40 rows at 60fps):**
- Without key (objects recreated): Destroys/recreates DOM every frame
- With `key="data.db.id"`: DOM reuse, **2x FPS improvement**
### References:
- [Ember API: each helper](https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/each)
- [Ember template lint: equire-each-key](https://github.com/ember-template-lint/ember-template-lint/blob/main/docs/rule/require-each-key.md)
- [Example PR showing the fps improvement on updated lists](https://github.com/universal-ember/table/pull/68)
@@ -0,0 +1,148 @@
---
title: Use {{fn}} for Partial Application Only
impact: LOW-MEDIUM
impactDescription: Clearer code, avoid unnecessary wrapping
tags: helpers, templates, fn, partial-application
---
## Use {{fn}} for Partial Application Only
The `{{fn}}` helper is used for partial application (binding arguments), similar to JavaScript's `.bind()`. Only use it when you need to pre-bind arguments to a function. Don't use it to simply pass a function reference.
**Incorrect (unnecessary use of {{fn}}):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Search extends Component {
@action
handleSearch(event) {
console.log('Searching:', event.target.value);
}
<template>
{{! Wrong - no arguments being bound}}
<input {{on "input" (fn this.handleSearch)}} />
</template>
}
```
**Correct (direct function reference):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Search extends Component {
@action
handleSearch(event) {
console.log('Searching:', event.target.value);
}
<template>
{{! Correct - pass function directly}}
<input {{on "input" this.handleSearch}} />
</template>
}
```
**When to Use {{fn}} - Partial Application:**
Use `{{fn}}` when you need to pre-bind arguments to a function, similar to JavaScript's `.bind()`:
```glimmer-js
// app/components/user-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class UserList extends Component {
@action
deleteUser(userId, event) {
console.log('Deleting user:', userId);
this.args.onDelete(userId);
}
<template>
<ul>
{{#each @users as |user|}}
<li>
{{user.name}}
{{! Correct - binding user.id as first argument}}
<button {{on "click" (fn this.deleteUser user.id)}}>
Delete
</button>
</li>
{{/each}}
</ul>
</template>
}
```
**Multiple Arguments:**
```glimmer-js
// app/components/data-grid.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class DataGrid extends Component {
@action
updateCell(rowId, columnKey, event) {
const newValue = event.target.value;
this.args.onUpdate(rowId, columnKey, newValue);
}
<template>
{{#each @rows as |row|}}
{{#each @columns as |column|}}
<input
value={{get row column.key}}
{{! Pre-binding rowId and columnKey}}
{{on "input" (fn this.updateCell row.id column.key)}}
/>
{{/each}}
{{/each}}
</template>
}
```
**Think of {{fn}} like .bind():**
```javascript
// JavaScript comparison
const boundFn = this.deleteUser.bind(this, userId); // .bind() pre-binds args
// Template equivalent: {{fn this.deleteUser userId}}
// Direct reference
const directFn = this.handleSearch; // No pre-binding
// Template equivalent: {{this.handleSearch}}
```
**Common Patterns:**
```javascript
// ❌ Wrong - no partial application
<button {{on "click" (fn this.save)}}>Save</button>
// ✅ Correct - direct reference
<button {{on "click" this.save}}>Save</button>
// ✅ Correct - partial application with argument
<button {{on "click" (fn this.save "draft")}}>Save Draft</button>
// ❌ Wrong - no partial application
<input {{on "input" (fn this.handleInput)}} />
// ✅ Correct - direct reference
<input {{on "input" this.handleInput}} />
// ✅ Correct - partial application with field name
<input {{on "input" (fn this.updateField "email")}} />
```
Only use `{{fn}}` when you're binding arguments. For simple function references, pass them directly.
Reference: [Ember Templates - fn Helper](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_passing-arguments-to-functions)
@@ -0,0 +1,116 @@
---
title: Import Helpers Directly in Templates
impact: MEDIUM
impactDescription: Better tree-shaking and clarity
tags: helpers, imports, templates, gjs
---
## Import Helpers Directly in Templates
Import helpers directly in gjs/gts files for better tree-shaking, clearer dependencies, and improved type safety.
**Incorrect (global helper resolution):**
```glimmer-js
// app/components/user-profile.gjs
<template>
<div class="profile">
<h1>{{capitalize @user.name}}</h1>
<p>Joined: {{format-date @user.createdAt}}</p>
<p>Posts: {{pluralize @user.postCount "post"}}</p>
</div>
</template>
```
**Correct (explicit helper imports):**
```glimmer-js
// app/components/user-profile.gjs
import { capitalize } from 'ember-string-helpers';
import { formatDate } from 'ember-intl';
import { pluralize } from 'ember-inflector';
<template>
<div class="profile">
<h1>{{capitalize @user.name}}</h1>
<p>Joined: {{formatDate @user.createdAt}}</p>
<p>Posts: {{pluralize @user.postCount "post"}}</p>
</div>
</template>
```
**Built-in and library helpers:**
```glimmer-js
// app/components/conditional-content.gjs
import { fn, hash } from '@ember/helper'; // Actually built-in to Ember
import { eq, not } from 'ember-truth-helpers'; // From ember-truth-helpers addon
<template>
<div class="content">
{{#if (eq @status "active")}}
<span class="badge">Active</span>
{{/if}}
{{#if (not @isLoading)}}
<button {{on "click" (fn @onSave (hash id=@id data=@data))}}>
Save
</button>
{{/if}}
</div>
</template>
```
**Custom helper with imports:**
```javascript
// app/utils/format-currency.js
export function formatCurrency(amount, { currency = 'USD' } = {}) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
```
```glimmer-js
// app/components/price-display.gjs
import { formatCurrency } from '../utils/format-currency';
<template>
<div class="price">
{{formatCurrency @amount currency="EUR"}}
</div>
</template>
```
**Type-safe helpers with TypeScript:**
```glimmer-ts
// app/components/typed-component.gts
import { fn } from '@ember/helper';
import type { TOC } from '@ember/component/template-only';
interface Signature {
Args: {
items: Array<{ id: string; name: string }>;
onSelect: (id: string) => void;
};
}
const TypedComponent: TOC<Signature> = <template>
<ul>
{{#each @items as |item|}}
<li {{on "click" (fn @onSelect item.id)}}>
{{item.name}}
</li>
{{/each}}
</ul>
</template>;
export default TypedComponent;
```
Explicit helper imports enable better tree-shaking, make dependencies clear, and improve IDE support with proper type checking.
Reference: [Template Imports](https://github.com/ember-template-imports/ember-template-imports)
@@ -0,0 +1,79 @@
---
title: Use {{#let}} to Avoid Recomputation
impact: MEDIUM
impactDescription: 30-50% reduction in duplicate work
tags: templates, helpers, performance, optimization
---
## Use {{#let}} to Avoid Recomputation
Use `{{#let}}` to compute expensive values once and reuse them in the template instead of calling getters or helpers multiple times.
**Incorrect (recomputes on every reference):**
```glimmer-js
// app/components/user-card.gjs
<template>
<div class="user-card">
{{#if (and this.user.isActive (not this.user.isDeleted))}}
<h3>{{this.user.fullName}}</h3>
<p>Status: Active</p>
{{/if}}
{{#if (and this.user.isActive (not this.user.isDeleted))}}
<button {{on "click" this.editUser}}>Edit</button>
{{/if}}
{{#if (and this.user.isActive (not this.user.isDeleted))}}
<button {{on "click" this.deleteUser}}>Delete</button>
{{/if}}
</div>
</template>
```
**Correct (compute once, reuse):**
```glimmer-js
// app/components/user-card.gjs
<template>
{{#let (and this.user.isActive (not this.user.isDeleted)) as |isEditable|}}
<div class="user-card">
{{#if isEditable}}
<h3>{{this.user.fullName}}</h3>
<p>Status: Active</p>
{{/if}}
{{#if isEditable}}
<button {{on "click" this.editUser}}>Edit</button>
{{/if}}
{{#if isEditable}}
<button {{on "click" this.deleteUser}}>Delete</button>
{{/if}}
</div>
{{/let}}
</template>
```
**Multiple values:**
```glimmer-js
// app/components/checkout.gjs
<template>
{{#let
(this.calculateTotal this.items) (this.formatCurrency this.total) (this.hasDiscount this.user)
as |total formattedTotal showDiscount|
}}
<div class="checkout">
<p>Total: {{formattedTotal}}</p>
{{#if showDiscount}}
<p>Original: {{total}}</p>
<p>Discount Applied!</p>
{{/if}}
</div>
{{/let}}
</template>
```
`{{#let}}` computes values once and caches them for the block scope, reducing redundant calculations.
@@ -0,0 +1,220 @@
---
title: Template-Only Components with In-Scope Functions
impact: MEDIUM
impactDescription: Clean, performant patterns for template-only components
tags: templates, components, functions, performance
---
## Template-Only Components with In-Scope Functions
For template-only components (components without a class and `this`), use in-scope functions to keep logic close to the template while avoiding unnecessary caching overhead.
**Incorrect (using class-based component for simple logic):**
```glimmer-js
// app/components/product-card.gjs
import Component from '@glimmer/component';
export class ProductCard extends Component {
// Unnecessary class and overhead for simple formatting
formatPrice(price) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
}
<template>
<div class="product-card">
<h3>{{@product.name}}</h3>
<div class="price">{{this.formatPrice @product.price}}</div>
</div>
</template>
}
```
**Correct (template-only component with in-scope functions):**
```glimmer-js
// app/components/product-card.gjs
function formatPrice(price) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
}
function calculateDiscount(price, discountPercent) {
return price * (1 - discountPercent / 100);
}
function isOnSale(product) {
return product.discountPercent > 0;
}
<template>
<div class="product-card">
<h3>{{@product.name}}</h3>
{{#if (isOnSale @product)}}
<div class="price">
<span class="original">{{formatPrice @product.price}}</span>
<span class="sale">
{{formatPrice (calculateDiscount @product.price @product.discountPercent)}}
</span>
</div>
{{else}}
<div class="price">{{formatPrice @product.price}}</div>
{{/if}}
<p>{{@product.description}}</p>
</div>
</template>
```
**When to use class-based vs template-only:**
```glimmer-js
// Use class-based when:
// - You need @cached for expensive computations accessed multiple times
// - You have tracked state
// - You need lifecycle hooks or services
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
export class ProductList extends Component {
@cached
get sortedProducts() {
// Expensive sort, accessed in template multiple times
return [...this.args.products].sort((a, b) => a.name.localeCompare(b.name));
}
@cached
get filteredProducts() {
// Depends on sortedProducts - benefits from caching
return this.sortedProducts.filter((p) => p.category === this.args.selectedCategory);
}
<template>
{{#each this.filteredProducts as |product|}}
<div>{{product.name}}</div>
{{/each}}
</template>
}
```
```glimmer-js
// Use template-only when:
// - Simple transformations
// - Functions accessed once
// - No state or services needed
function formatDate(date) {
return new Date(date).toLocaleDateString();
}
<template>
<div class="timestamp">
Last updated:
{{formatDate @lastUpdate}}
</div>
</template>
```
**Combining in-scope functions for readability:**
```glimmer-js
// app/components/user-badge.gjs
function getInitials(name) {
return name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase();
}
function getBadgeColor(status) {
const colors = {
active: 'green',
pending: 'yellow',
inactive: 'gray',
};
return colors[status] || 'gray';
}
<template>
<div class="user-badge" style="background-color: {{getBadgeColor @user.status}}">
<span class="initials">{{getInitials @user.name}}</span>
<span class="name">{{@user.name}}</span>
</div>
</template>
```
**Anti-pattern - Complex nested calls:**
```glimmer-js
// ❌ Hard to read, lots of nesting
<template>
<div>
{{formatCurrency (multiply (add @basePrice @taxAmount) @quantity)}}
</div>
</template>
// ✅ Better - use intermediate function
function calculateTotal(basePrice, taxAmount, quantity) {
return (basePrice + taxAmount) * quantity;
}
function formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}
<template>
<div>
{{formatCurrency (calculateTotal @basePrice @taxAmount @quantity)}}
</div>
</template>
```
**Key differences from class-based components:**
| Aspect | Template-Only | Class-Based |
| ---------------- | ------------------------ | ------------------------ |
| `this` context | ❌ No `this` | ✅ Has `this` |
| Function caching | ❌ Recreated each render | ✅ `@cached` available |
| Services | ❌ Cannot inject | ✅ `@service` decorator |
| Tracked state | ❌ No instance state | ✅ `@tracked` properties |
| Best for | Simple, stateless | Complex, stateful |
**Best practices:**
1. **Keep functions simple** - If computation is complex, consider a class with `@cached`
2. **One responsibility per function** - Makes them reusable and testable
3. **Minimize nesting** - Use intermediate functions for readability
4. **No side effects** - Functions should be pure transformations
5. **Export for testing** - Export functions so they can be tested independently
```glimmer-js
// app/components/stats-display.gjs
export function average(numbers) {
if (numbers.length === 0) return 0;
return numbers.reduce((sum, n) => sum + n, 0) / numbers.length;
}
export function round(number, decimals = 2) {
return Math.round(number * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
<template>
<div class="stats">
Average:
{{round (average @scores)}}
</div>
</template>
```
Reference: [Template-only Components](https://guides.emberjs.com/release/components/component-types/), [Component Authoring Best Practices](https://guides.emberjs.com/release/components/conditional-content/)
@@ -0,0 +1,329 @@
---
title: Provide DOM-Abstracted Test Utilities for Library Components
impact: MEDIUM
impactDescription: Stabilizes consumer tests against internal DOM refactors
tags: testing, test-support, libraries, dom-abstraction, maintainability
---
## Provide DOM-Abstracted Test Utilities for Library Components
**Impact: Medium** - Critical for library maintainability and consumer testing experience, especially important for team-based projects
## Problem
When building reusable components or libraries, consumers should not need to know implementation details or interact directly with the component's DOM. DOM structure should be considered **private** unless the author of the tests is the **owner** of the code being tested.
Without abstracted test utilities:
- Component refactoring breaks consumer tests
- Tests are tightly coupled to implementation details
- Teams waste time updating tests when internals change
- Testing becomes fragile and maintenance-heavy
## Solution
**Library authors should provide test utilities that fully abstract the DOM.** These utilities expose a public API for testing that remains stable even when internal implementation changes.
**Incorrect (exposing DOM to consumers):**
```glimmer-js
// my-library/src/components/data-grid.gjs
export class DataGrid extends Component {
<template>
<div class="data-grid">
<div class="data-grid__header">
<button class="sort-button" data-column="name">Name</button>
</div>
<div class="data-grid__body">
{{#each @rows as |row|}}
<div class="data-grid__row">{{row.name}}</div>
{{/each}}
</div>
</div>
</template>
}
```
```glimmer-js
// Consumer's test - tightly coupled to DOM
import { render, click } from '@ember/test-helpers';
import { DataGrid } from 'my-library';
test('sorting works', async function (assert) {
await render(<template><DataGrid @rows={{this.rows}} /></template>);
// Fragile: breaks if class names or structure change
await click('.data-grid__header .sort-button[data-column="name"]');
assert.dom('.data-grid__row:first-child').hasText('Alice');
});
```
**Problems:**
- Consumer knows about `.data-grid__header`, `.sort-button`, `[data-column]`
- Refactoring component structure breaks consumer tests
- No clear public API for testing
**Correct (providing DOM-abstracted test utilities):**
```glimmer-js
// my-library/src/test-support/data-grid.js
import { click, findAll } from '@ember/test-helpers';
/**
* Test utility for DataGrid component
* Provides stable API regardless of internal DOM structure
*/
export class DataGridTestHelper {
constructor(containerElement) {
this.container = containerElement;
}
/**
* Sort by column name
* @param {string} columnName - Column to sort by
*/
async sortBy(columnName) {
// Implementation detail hidden from consumer
const button = this.container.querySelector(`[data-test-sort="${columnName}"]`);
if (!button) {
throw new Error(`Column "${columnName}" not found`);
}
await click(button);
}
/**
* Get all row data
* @returns {Array<string>} Row text content
*/
getRows() {
return findAll('[data-test-row]', this.container).map((el) => el.textContent.trim());
}
/**
* Get row by index
* @param {number} index - Zero-based row index
* @returns {string} Row text content
*/
getRow(index) {
const rows = this.getRows();
return rows[index];
}
}
// Factory function for easier usage
export function getDataGrid(container = document) {
const gridElement = container.querySelector('[data-test-data-grid]');
if (!gridElement) {
throw new Error('DataGrid component not found');
}
return new DataGridTestHelper(gridElement);
}
```
```glimmer-js
// my-library/src/components/data-grid.gjs
// Component updated with test hooks (data-test-*)
export class DataGrid extends Component {
<template>
<div data-test-data-grid class="data-grid">
<div class="data-grid__header">
{{#each @columns as |column|}}
<button data-test-sort={{column.name}}>
{{column.label}}
</button>
{{/each}}
</div>
<div class="data-grid__body">
{{#each @rows as |row|}}
<div data-test-row class="data-grid__row">{{row.name}}</div>
{{/each}}
</div>
</div>
</template>
}
```
```glimmer-js
// Consumer's test - abstracted from DOM
import { render } from '@ember/test-helpers';
import { DataGrid } from 'my-library';
import { getDataGrid } from 'my-library/test-support';
test('sorting works', async function (assert) {
await render(<template><DataGrid @rows={{this.rows}} @columns={{this.columns}} /></template>);
const grid = getDataGrid();
// Clean API: no DOM knowledge required
await grid.sortBy('name');
assert.strictEqual(grid.getRow(0), 'Alice');
assert.deepEqual(grid.getRows(), ['Alice', 'Bob', 'Charlie']);
});
```
**Benefits:**
- Component internals can change without breaking consumer tests
- Clear, documented testing API
- Consumer tests are declarative and readable
- Library maintains API stability contract
## When This Matters Most
### Team-Based Projects (Critical)
On projects with teams, DOM abstraction prevents:
- Merge conflicts from test changes
- Cross-team coordination overhead
- Broken tests from uncoordinated refactoring
- Knowledge silos about component internals
### Solo Projects (Less Critical)
For solo projects, the benefit is smaller but still valuable:
- Easier refactoring without test maintenance
- Better separation of concerns
- Professional API design practice
## Best Practices
### 1. Use `data-test-*` Attributes
```glimmer-js
// Stable test hooks that won't conflict with styling
<button data-test-submit>Submit</button>
<div data-test-error-message>{{@errorMessage}}</div>
```
### 2. Document the Test API
```javascript
/**
* @class FormTestHelper
* @description Test utility for Form component
*
* @example
* const form = getForm();
* await form.fillIn('email', 'user@example.com');
* await form.submit();
* assert.strictEqual(form.getError(), 'Invalid email');
*/
```
### 3. Provide Semantic Methods
```javascript
// ✅ Semantic and declarative
await modal.close();
await form.fillIn('email', 'test@example.com');
assert.true(dropdown.isOpen());
// ❌ Exposes implementation
await click('.modal-close-button');
await fillIn('.form-field[name="email"]', 'test@example.com');
assert.dom('.dropdown.is-open').exists();
```
### 4. Handle Edge Cases
```javascript
export class FormTestHelper {
async fillIn(fieldName, value) {
const field = this.container.querySelector(`[data-test-field="${fieldName}"]`);
if (!field) {
throw new Error(
`Field "${fieldName}" not found. Available fields: ${this.getFieldNames().join(', ')}`,
);
}
await fillIn(field, value);
}
getFieldNames() {
return Array.from(this.container.querySelectorAll('[data-test-field]')).map(
(el) => el.dataset.testField,
);
}
}
```
## Example: Complete Test Utility
```javascript
// addon/test-support/modal.js
import { click, find, waitUntil } from '@ember/test-helpers';
export class ModalTestHelper {
constructor(container = document) {
this.container = container;
}
get element() {
return find('[data-test-modal]', this.container);
}
isOpen() {
return this.element !== null;
}
async waitForOpen() {
await waitUntil(() => this.isOpen(), { timeout: 1000 });
}
async waitForClose() {
await waitUntil(() => !this.isOpen(), { timeout: 1000 });
}
getTitle() {
const titleEl = find('[data-test-modal-title]', this.element);
return titleEl ? titleEl.textContent.trim() : null;
}
getBody() {
const bodyEl = find('[data-test-modal-body]', this.element);
return bodyEl ? bodyEl.textContent.trim() : null;
}
async close() {
if (!this.isOpen()) {
throw new Error('Cannot close modal: modal is not open');
}
await click('[data-test-modal-close]', this.element);
}
async clickButton(buttonText) {
const buttons = findAll('[data-test-modal-button]', this.element);
const button = buttons.find((btn) => btn.textContent.trim() === buttonText);
if (!button) {
const available = buttons.map((b) => b.textContent.trim()).join(', ');
throw new Error(`Button "${buttonText}" not found. Available: ${available}`);
}
await click(button);
}
}
export function getModal(container) {
return new ModalTestHelper(container);
}
```
## Performance Impact
**Before:** ~30-50% of test maintenance time spent updating selectors
**After:** Minimal test maintenance when refactoring components
## Related Patterns
- **component-avoid-classes-in-examples.md** - Avoid exposing implementation details
- **testing-modern-patterns.md** - Modern testing approaches
- **testing-render-patterns.md** - Component testing patterns
## References
- [Testing Best Practices - ember-learn](https://guides.emberjs.com/release/testing/)
- [ember-test-selectors](https://github.com/mainmatter/ember-test-selectors) - Addon for stripping test selectors from production
- [Page Objects Pattern](https://martinfowler.com/bliki/PageObject.html) - Related testing abstraction pattern
@@ -0,0 +1,340 @@
---
title: Use Modern Testing Patterns
impact: HIGH
impactDescription: Better test coverage and maintainability
tags: testing, qunit, test-helpers, integration-tests
---
## Use Modern Testing Patterns
Use modern Ember testing patterns with `@ember/test-helpers` and `qunit-dom` for better test coverage and maintainability.
**Incorrect (old testing patterns):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, find, click } from '@ember/test-helpers';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(<template><UserCard /></template>);
// Using find() instead of qunit-dom
assert.ok(find('.user-card'));
});
});
```
**Correct (modern testing patterns):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import { setupIntl } from 'ember-intl/test-support';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks);
test('it renders user information', async function (assert) {
const user = {
name: 'John Doe',
email: 'john@example.com',
avatarUrl: '/avatar.jpg',
};
await render(<template><UserCard @user={{user}} /></template>);
// qunit-dom assertions
assert.dom('[data-test-user-name]').hasText('John Doe');
assert.dom('[data-test-user-email]').hasText('john@example.com');
assert
.dom('[data-test-user-avatar]')
.hasAttribute('src', '/avatar.jpg')
.hasAttribute('alt', 'John Doe');
});
test('it handles edit action', async function (assert) {
assert.expect(1);
const user = { name: 'John Doe', email: 'john@example.com' };
const handleEdit = (editedUser) => {
assert.deepEqual(editedUser, user, 'Edit handler called with user');
};
await render(<template><UserCard @user={{user}} @onEdit={{handleEdit}} /></template>);
await click('[data-test-edit-button]');
});
});
```
**Component testing with reactive state:**
```glimmer-ts
// tests/integration/components/search-box-test.ts
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn } from '@ember/test-helpers';
import { trackedObject } from '@ember/reactive/collections';
import SearchBox from 'my-app/components/search-box';
module('Integration | Component | search-box', function (hooks) {
setupRenderingTest(hooks);
test('it performs search', async function (assert) {
// Use trackedObject for reactive state in tests
const state = trackedObject({
results: [] as string[],
});
const handleSearch = (query: string) => {
state.results = [`Result for ${query}`];
};
await render(
<template>
<SearchBox @onSearch={{handleSearch}} />
<ul data-test-results>
{{#each state.results as |result|}}
<li>{{result}}</li>
{{/each}}
</ul>
</template>,
);
await fillIn('[data-test-search-input]', 'ember');
// State updates reactively - no waitFor needed when using test-waiters
assert.dom('[data-test-results] li').hasText('Result for ember');
});
});
```
**Testing with ember-concurrency tasks:**
```glimmer-js
// app/components/async-button.js
import Component from '@glimmer/component';
import { task } from 'ember-concurrency';
export default class AsyncButtonComponent extends Component {
@task
*saveTask() {
yield this.args.onSave();
}
<template>
<button
type="button"
disabled={{this.saveTask.isRunning}}
{{on "click" (perform this.saveTask)}}
data-test-button
>
{{#if this.saveTask.isRunning}}
<span data-test-loading-spinner>Saving...</span>
{{else}}
{{yield}}
{{/if}}
</button>
</template>
}
```
```glimmer-js
// tests/integration/components/async-button-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import AsyncButton from 'my-app/components/async-button';
module('Integration | Component | async-button', function (hooks) {
setupRenderingTest(hooks);
test('it shows loading state during task execution', async function (assert) {
let resolveTask;
const onSave = () => {
return new Promise((resolve) => {
resolveTask = resolve;
});
};
await render(
<template>
<AsyncButton @onSave={{onSave}}>
Save
</AsyncButton>
</template>,
);
// Trigger the task
await click('[data-test-button]');
// ember-concurrency automatically registers test waiters
// The button will be disabled while the task runs
assert.dom('[data-test-button]').hasAttribute('disabled');
assert.dom('[data-test-loading-spinner]').hasText('Saving...');
// Resolve the task
resolveTask();