achilles 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97bbd095ec5feda0679a77b020f80ad70ec59b202757af4d84e1c318643a4632
4
- data.tar.gz: 40bdce2ca5420c29a4be44260e2655cfbca3e6357d294aa017454c0f90b9a642
3
+ metadata.gz: d393e3c7e9d04d6a472ba4e41c727fffca5fa3088f5d064a08a0f9cd4b2bfcf2
4
+ data.tar.gz: 911efcf9ddc72755b3b26b9a8ae3d0a5538d4d33ba2f8476d6c4ae4c8aa54e8e
5
5
  SHA512:
6
- metadata.gz: d21a4690c44bfeb356544370465319b6b6d969731a07aa4f4a9e462e32038a57c53f63f2e6ee05b5b3959e86d1fe7f517b48acce6ab8d08266d9dd7de8589285
7
- data.tar.gz: b1a8de0b8ed2beb3d564df46e35568958989b93b1ca5f8b2d4d89a93dadb40640b1d81c5ef0a0316a7c4016f7ed29da1382ef10b86bdf1103756075a6ec11293
6
+ metadata.gz: a480a6ca6a9b154cdf723aa53560a1caa2a82f41f569a3e2d79dafbd80e50d1dea7a9c9b18d29f69c8eb9d693af79b358929929b9842a014645eaf939105dea5
7
+ data.tar.gz: 52f50f6c02b169e7d592363571da480961aa6ac52a856aaca72dc3f26f4e5c557e9b5c918e24f18bdfec6d5d37685936e49678e093ea182d4dc8730bad6d898c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to Achilles will be documented in this file.
4
4
 
5
+ ## 1.1.0
6
+
7
+ ### Added
8
+
9
+ - Added explicit `Application#start` and `Application#stop` lifecycle methods.
10
+ - Added opt-in `Application#strictLifecycleErrors` handling for tests and
11
+ development.
12
+ - Added browser system coverage for nested components inside Turbo form
13
+ replacement and Turbo Drive navigation.
14
+
15
+ ### Changed
16
+
17
+ - `Application` no longer starts automatically from the constructor. Applications
18
+ should register component classes, then call `achilles.start()`. See
19
+ [docs/upgrading-to-1.1.0.md](docs/upgrading-to-1.1.0.md).
20
+ - Elements with `data-component-class` must now have a non-empty `id`; invalid
21
+ component roots are skipped with a console error.
22
+ - Component teardown now runs from child components to parent components.
23
+ - Component parentage now follows DOM ancestry under the synthetic `Page` root.
24
+ - Turbo lifecycle hooks are now attached by `start()` and removed by `stop()`.
25
+ - Achilles now requires `turbo-rails` so Turbo importmap assets are available to
26
+ host applications.
27
+
5
28
  ## 1.0.0
6
29
 
7
30
  ### Added
@@ -0,0 +1,46 @@
1
+ # Code Of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We want Achilles to be a respectful, useful, and practical open source project.
6
+
7
+ Everyone participating in the project is expected to communicate with clarity,
8
+ patience, and respect. Disagreement is fine. Harassment, personal attacks, and
9
+ abusive behavior are not.
10
+
11
+ ## Expected Behavior
12
+
13
+ - Be respectful of differing experience levels and viewpoints.
14
+ - Keep technical criticism focused on the code, design, or documentation.
15
+ - Assume good intent, but be direct when something is unclear or risky.
16
+ - Help keep issues and pull requests actionable.
17
+ - Respect maintainers' time and project scope.
18
+
19
+ ## Unacceptable Behavior
20
+
21
+ - Harassment, threats, or personal attacks.
22
+ - Insults, slurs, or discriminatory language.
23
+ - Public or private abuse of contributors or maintainers.
24
+ - Publishing someone's private information without permission.
25
+ - Repeated off-topic disruption after maintainers ask for the discussion to
26
+ stop or move elsewhere.
27
+
28
+ ## Enforcement
29
+
30
+ Maintainers may remove comments, close issues, reject pull requests, or block
31
+ participants who violate this code of conduct.
32
+
33
+ To report a concern, email:
34
+
35
+ ```text
36
+ jey@jeygeethan.com
37
+ ```
38
+
39
+ Reports will be reviewed privately. Please include relevant links, screenshots,
40
+ or context when possible.
41
+
42
+ ## Scope
43
+
44
+ This code of conduct applies to Achilles project spaces, including GitHub
45
+ issues, pull requests, discussions, release comments, and private project
46
+ communication with maintainers.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,122 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time to improve Achilles.
4
+
5
+ Achilles is intentionally small. Contributions should keep the public API clear,
6
+ the Rails + Turbo integration reliable, and the documentation practical for
7
+ applications that already use the gem.
8
+
9
+ ## Local Setup
10
+
11
+ Install dependencies:
12
+
13
+ ```bash
14
+ bundle install
15
+ ```
16
+
17
+ Run the dummy Rails app:
18
+
19
+ ```bash
20
+ bin/rails server
21
+ ```
22
+
23
+ Open:
24
+
25
+ ```text
26
+ http://localhost:3000/
27
+ ```
28
+
29
+ The dummy app includes a working counter component that exercises the basic
30
+ Achilles flow.
31
+
32
+ ## Test Commands
33
+
34
+ Run the Rails and JavaScript lifecycle tests:
35
+
36
+ ```bash
37
+ bin/rails test
38
+ ```
39
+
40
+ Run the browser system test:
41
+
42
+ ```bash
43
+ bin/rails test:system
44
+ ```
45
+
46
+ Run JavaScript syntax checks:
47
+
48
+ ```bash
49
+ for file in $(find app/javascript/achilles test/dummy/app/javascript -name '*.js' -print); do node --input-type=module --check < "$file" || exit 1; done
50
+ ```
51
+
52
+ Run the dummy app asset precompile check:
53
+
54
+ ```bash
55
+ RAILS_ENV=test bin/rails app:assets:precompile
56
+ ```
57
+
58
+ Run the full local verification set:
59
+
60
+ ```bash
61
+ bin/rails test
62
+ bin/rails test:system
63
+ for file in $(find app/javascript/achilles test/dummy/app/javascript -name '*.js' -print); do node --input-type=module --check < "$file" || exit 1; done
64
+ RAILS_ENV=test bin/rails app:assets:precompile
65
+ ```
66
+
67
+ ## System Test Browser Requirements
68
+
69
+ System tests use Selenium with headless Chrome.
70
+
71
+ On CI, GitHub's Ubuntu runner provides Chrome. Locally, set these environment
72
+ variables if Chrome or chromedriver are installed in non-standard locations:
73
+
74
+ ```bash
75
+ CHROME_BIN=/path/to/chrome CHROMEDRIVER_PATH=/path/to/chromedriver bin/rails test:system
76
+ ```
77
+
78
+ If Chrome is not available, the system test base skips browser tests cleanly.
79
+
80
+ ## Pull Request Guidelines
81
+
82
+ - Keep changes scoped.
83
+ - Add or update tests for behavior changes.
84
+ - Update the README or docs for public API changes.
85
+ - Add or update an upgrade guide for changes existing applications must make.
86
+ - Mention breaking changes clearly in the pull request.
87
+ - Do not introduce a new runtime dependency without explaining why it belongs in
88
+ a small lifecycle gem.
89
+ - Prefer browser DOM APIs in Achilles internals.
90
+ - Keep jQuery usage out of Achilles internals. Applications may still use jQuery
91
+ explicitly in their own components.
92
+
93
+ ## Public API Expectations
94
+
95
+ Treat these as public API:
96
+
97
+ - `Application`
98
+ - `ComponentBase`
99
+ - `ComponentsClassMapper`
100
+ - `data-component-class`
101
+ - `setup()`
102
+ - `teardown()`
103
+ - `rootElement()`
104
+ - `rootNode()`
105
+ - `rootElementSelector()`
106
+
107
+ Changes to these APIs need tests, documentation, and changelog notes.
108
+
109
+ ## Release Process
110
+
111
+ Use [docs/release-checklist.md](docs/release-checklist.md) for prereleases and
112
+ final releases.
113
+
114
+ Maintainer release ownership and project decision guidelines are documented in
115
+ [MAINTAINERS.md](MAINTAINERS.md).
116
+
117
+ Before releasing:
118
+
119
+ - confirm CI is green
120
+ - run the full local verification set
121
+ - build the gem with `gem build achilles.gemspec`
122
+ - test release candidates in at least one real application
data/MAINTAINERS.md ADDED
@@ -0,0 +1,53 @@
1
+ # Maintainers
2
+
3
+ Achilles is maintained as a small, stable Rails + Turbo lifecycle gem.
4
+
5
+ The maintainer goal is to keep the public API easy to understand, the runtime
6
+ surface small, and releases safe for applications that already depend on the
7
+ gem.
8
+
9
+ ## Decision Making
10
+
11
+ Maintainers should prefer changes that:
12
+
13
+ - preserve the small public API
14
+ - improve reliability across Rails, Turbo, and importmap applications
15
+ - reduce hidden runtime dependencies
16
+ - make migration or debugging easier for existing applications
17
+ - include tests for changed behavior
18
+
19
+ Breaking changes are acceptable when they make the project simpler, safer, or
20
+ more predictable. They should be documented in `CHANGELOG.md`, covered by tests,
21
+ and called out in migration notes when existing applications need code changes.
22
+
23
+ ## Release Ownership
24
+
25
+ Before publishing a release, a maintainer should:
26
+
27
+ - confirm CI is green
28
+ - run the local verification set in `CONTRIBUTING.md`
29
+ - follow `docs/release-checklist.md`
30
+ - test release candidates in at least one real Rails application when behavior
31
+ changes affect runtime integration
32
+
33
+ Patch releases should be narrow and low risk. Minor releases can add compatible
34
+ APIs or documentation improvements. Major releases may remove deprecated
35
+ behavior or tighten public contracts.
36
+
37
+ ## Security Reports
38
+
39
+ Security reports should follow `SECURITY.md`. Reports are handled privately
40
+ until there is a fix or a clear public response.
41
+
42
+ ## Community Standards
43
+
44
+ Project participation is covered by `CODE_OF_CONDUCT.md`. Maintainers may close
45
+ issues, reject pull requests, edit discussions, or block participants when
46
+ needed to keep the project focused and respectful.
47
+
48
+ ## Adding Maintainers
49
+
50
+ New maintainers should already have a history of useful issues, documentation,
51
+ or code contributions. They should understand the project scope, support the
52
+ public API expectations in `CONTRIBUTING.md`, and be comfortable reviewing
53
+ changes conservatively.
data/README.md CHANGED
@@ -1,12 +1,49 @@
1
1
  # Achilles
2
2
 
3
3
  Achilles is a small JavaScript lifecycle layer for Rails + Turbo applications.
4
- It is positioned as a simpler alternative to Stimulus for apps that prefer
5
- explicit component classes mapped to DOM nodes.
4
+ It is an explicit component-class alternative to Stimulus for teams that prefer
5
+ plain JavaScript classes mapped directly to DOM nodes.
6
6
 
7
7
  Achilles scans the page for elements with `data-component-class`, instantiates
8
8
  the matching JavaScript class, and calls `setup` and `teardown` as Turbo renders
9
- new pages.
9
+ new pages or new component markup is inserted.
10
+
11
+ ## Why Achilles?
12
+
13
+ Rails and Turbo make server-rendered interfaces productive, but many apps still
14
+ need page-specific JavaScript for menus, forms, filters, widgets, charts, and
15
+ small interaction islands.
16
+
17
+ Achilles keeps that JavaScript close to the DOM without requiring controller
18
+ naming conventions or a build step. You register classes explicitly, mark the
19
+ HTML root node, and implement lifecycle methods.
20
+
21
+ Use Achilles when you want:
22
+
23
+ - explicit JavaScript classes instead of controller naming conventions
24
+ - lifecycle hooks that match Turbo navigation
25
+ - no internal jQuery dependency
26
+ - no JavaScript build step
27
+ - predictable setup and teardown for server-rendered UI
28
+ - simple dynamic DOM registration through `MutationObserver`
29
+
30
+ ## Achilles vs Stimulus
31
+
32
+ Stimulus is a strong default for many Rails apps. Achilles is intentionally
33
+ smaller and more explicit.
34
+
35
+ | Concern | Achilles | Stimulus |
36
+ | --- | --- | --- |
37
+ | Class registration | Explicit mapper registration | File and identifier conventions |
38
+ | DOM marker | `data-component-class` | `data-controller` |
39
+ | Root element API | `this.rootElement()` | `this.element` |
40
+ | Lifecycle | `setup()` and `teardown()` | `connect()` and `disconnect()` |
41
+ | Targets/actions | Use standard DOM APIs | Built-in targets/actions |
42
+ | Dependency | Rails, Turbo, Importmap | Hotwire Stimulus |
43
+
44
+ Choose Achilles if you want a tiny lifecycle layer with explicit class mapping.
45
+ Choose Stimulus if you want its controller ecosystem, targets, values, actions,
46
+ and conventions.
10
47
 
11
48
  ## Requirements
12
49
 
@@ -39,6 +76,7 @@ import { CounterComponent } from "components/counter_component";
39
76
 
40
77
  const achilles = new Application();
41
78
  achilles.componentsClassMapper.addComponentClass("CounterComponent", CounterComponent);
79
+ achilles.start();
42
80
  ```
43
81
 
44
82
  Create components by extending `ComponentBase`:
@@ -74,13 +112,114 @@ Every component root must have a unique `id`. Achilles uses that id to register
74
112
  the component, find its root element, and avoid running setup twice for the same
75
113
  DOM node.
76
114
 
115
+ ## Dynamic Components
116
+
117
+ Achilles watches the document for inserted component markup. If Turbo Streams,
118
+ custom JavaScript, or another UI flow adds a matching element, Achilles parses
119
+ and sets it up automatically.
120
+
121
+ ```erb
122
+ <div id="notification-42" data-component-class="NotificationComponent">
123
+ Saved successfully
124
+ </div>
125
+ ```
126
+
127
+ ```js
128
+ import { ComponentBase } from "achilles/components/component_base";
129
+
130
+ class NotificationComponent extends ComponentBase {
131
+ setup() {
132
+ this.timeout = window.setTimeout(() => {
133
+ this.rootElement().remove();
134
+ }, 3000);
135
+ }
136
+
137
+ teardown() {
138
+ window.clearTimeout(this.timeout);
139
+ }
140
+ }
141
+
142
+ export { NotificationComponent };
143
+ ```
144
+
145
+ Register the component class once:
146
+
147
+ ```js
148
+ achilles.componentsClassMapper.addComponentClass("NotificationComponent", NotificationComponent);
149
+ ```
150
+
151
+ ## Example App
152
+
153
+ The dummy Rails app includes a working counter component. See
154
+ [examples/README.md](examples/README.md) for the files and local run command.
155
+
77
156
  ## Lifecycle
78
157
 
79
- - `setup` runs after `turbo:load` and after new matching DOM nodes are inserted.
80
- - `teardown` runs before Turbo renders a new page.
81
- - `rootElement()` returns the DOM element for the component id.
82
- - `rootNode()` is an alias for `rootElement()`.
83
- - `rootElementSelector()` returns a CSS selector for the component id.
158
+ - `setup()` runs after `turbo:load` and after new matching DOM nodes are inserted.
159
+ - `teardown()` runs before Turbo renders a new page.
160
+ - `setup()` and `teardown()` are called once per registered component instance.
161
+ - Components that attach listeners, timers, observers, subscriptions, or widgets
162
+ should clean them up in `teardown()`.
163
+
164
+ ## API Reference
165
+
166
+ ### `Application`
167
+
168
+ The top-level Achilles application object.
169
+
170
+ ```js
171
+ import { Application } from "achilles/application/application";
172
+
173
+ const achilles = new Application();
174
+ achilles.start();
175
+ ```
176
+
177
+ Useful properties:
178
+
179
+ - `componentsClassMapper`: register component classes by name.
180
+ - `componentRegistry`: inspect or manage registered component instances.
181
+ - `timezone`: access the configured app timezone.
182
+
183
+ Call `start()` after registering component classes. Call `stop()` when an
184
+ application instance should remove its Turbo hooks and stop observing the DOM.
185
+
186
+ ### `ComponentsClassMapper`
187
+
188
+ Maps `data-component-class` values to JavaScript classes.
189
+
190
+ ```js
191
+ achilles.componentsClassMapper.addComponentClass("MenuComponent", MenuComponent);
192
+ ```
193
+
194
+ ### `ComponentBase`
195
+
196
+ Base class for application components.
197
+
198
+ ```js
199
+ import { ComponentBase } from "achilles/components/component_base";
200
+
201
+ class MenuComponent extends ComponentBase {
202
+ setup() {}
203
+ teardown() {}
204
+ }
205
+ ```
206
+
207
+ Useful methods:
208
+
209
+ - `setup()`: override to initialize behavior.
210
+ - `teardown()`: override to clean up behavior before Turbo renders a new page.
211
+ - `rootElement()`: returns the component root DOM element.
212
+ - `rootNode()`: alias for `rootElement()`.
213
+ - `rootElementSelector()`: returns a CSS selector for the component id.
214
+
215
+ ### Component Markup
216
+
217
+ ```erb
218
+ <div id="account-menu" data-component-class="MenuComponent"></div>
219
+ ```
220
+
221
+ The `data-component-class` value must match a class registered with
222
+ `componentsClassMapper`.
84
223
 
85
224
  ## Timezone
86
225
 
@@ -93,18 +232,67 @@ value through `achilles.timezone.timezoneString`.
93
232
 
94
233
  If no timezone is present, Achilles falls back to `Etc/UTC`.
95
234
 
235
+ ## Upgrading From 0.1.3
236
+
237
+ Achilles `1.0.0` changes `rootElement()` to return a DOM element. It no longer
238
+ returns a jQuery object when `window.$` is present.
239
+
240
+ Old jQuery-style code:
241
+
242
+ ```js
243
+ this.rootElement().addClass("is-open");
244
+ ```
245
+
246
+ Use DOM APIs:
247
+
248
+ ```js
249
+ this.rootElement().classList.add("is-open");
250
+ ```
251
+
252
+ Or wrap explicitly if the application still uses jQuery:
253
+
254
+ ```js
255
+ $(this.rootElement()).addClass("is-open");
256
+ ```
257
+
258
+ Applications upgrading Achilles should start with the
259
+ [upgrade guide](docs/upgrading.md). Applications upgrading from `0.1.3` should
260
+ also read the [v1 migration guide](docs/migrating-from-0.1.3-to-v1.md).
261
+
96
262
  ## Contributing
97
263
 
264
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, test commands, pull request
265
+ guidelines, and release notes. Project participation is covered by the
266
+ [code of conduct](CODE_OF_CONDUCT.md). Maintainer responsibilities are described
267
+ in [MAINTAINERS.md](MAINTAINERS.md).
268
+
98
269
  Run the test suite with:
99
270
 
100
271
  ```bash
101
272
  bin/rails test
102
273
  ```
103
274
 
104
- ## Migration
275
+ Run the browser system test with:
276
+
277
+ ```bash
278
+ bin/rails test:system
279
+ ```
280
+
281
+ Run the JavaScript syntax check with:
282
+
283
+ ```bash
284
+ for file in $(find app/javascript/achilles -name '*.js' -print); do node --input-type=module --check < "$file" || exit 1; done
285
+ ```
286
+
287
+ Run the dummy app asset precompile check with:
288
+
289
+ ```bash
290
+ RAILS_ENV=test bin/rails app:assets:precompile
291
+ ```
292
+
293
+ ## Security
105
294
 
106
- Applications upgrading from `0.1.3` should read the
107
- [v1 migration guide](docs/migrating-from-0.1.3-to-v1.md).
295
+ Report security issues privately. See [SECURITY.md](SECURITY.md).
108
296
 
109
297
  ## License
110
298
 
data/SECURITY.md ADDED
@@ -0,0 +1,50 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Only the latest minor release of Achilles `1.x` receives security fixes.
6
+
7
+ | Version | Supported |
8
+ | --- | --- |
9
+ | `1.x` | Yes |
10
+ | `< 1.0` | No |
11
+
12
+ ## Reporting A Vulnerability
13
+
14
+ Please do not open a public GitHub issue for security vulnerabilities.
15
+
16
+ Report security issues by email:
17
+
18
+ ```text
19
+ jey@jeygeethan.com
20
+ ```
21
+
22
+ Include:
23
+
24
+ - affected Achilles version
25
+ - Rails version
26
+ - Ruby version
27
+ - a description of the vulnerability
28
+ - reproduction steps or proof of concept
29
+ - whether the issue is already public
30
+
31
+ ## Response Expectations
32
+
33
+ You should receive an acknowledgement within 7 days.
34
+
35
+ If the issue is confirmed, the fix will be prepared privately when practical and
36
+ released with a changelog entry that gives users enough information to upgrade
37
+ without exposing unnecessary exploit details before a fix is available.
38
+
39
+ ## Scope
40
+
41
+ Security reports are most useful when they involve Achilles behavior directly,
42
+ including:
43
+
44
+ - unsafe DOM lifecycle behavior
45
+ - asset/importmap packaging issues
46
+ - Rails engine integration issues
47
+ - behavior that could cause applications to execute unintended JavaScript
48
+
49
+ General application security issues in apps that use Achilles should be reported
50
+ to those applications instead.
@@ -16,6 +16,7 @@ class Application {
16
16
  _componentsClassMapper;
17
17
  _hooksManager;
18
18
  _domMutationObserver;
19
+ _started = false;
19
20
 
20
21
  constructor() {
21
22
  // Set the application while creating the object
@@ -53,12 +54,45 @@ class Application {
53
54
  return this._page;
54
55
  }
55
56
 
57
+ get strictLifecycleErrors() {
58
+ return this.componentRegistry.strictLifecycleErrors;
59
+ }
60
+
56
61
  // Setters
57
62
  set engine(engine) {
58
63
  this._engine = engine;
59
64
  }
60
65
 
66
+ set strictLifecycleErrors(value) {
67
+ this.componentRegistry.strictLifecycleErrors = value;
68
+ }
69
+
70
+ start() {
71
+ if (this._started) {
72
+ return;
73
+ }
74
+
75
+ this._started = true;
76
+ this._hooksManager.start();
77
+ this.setup();
78
+ }
79
+
80
+ stop() {
81
+ if (!this._started) {
82
+ return;
83
+ }
84
+
85
+ this._started = false;
86
+ this._hooksManager.stop();
87
+ this.teardown();
88
+ this._domMutationObserver.stop();
89
+ }
90
+
61
91
  setup() {
92
+ if (!this._started) {
93
+ return;
94
+ }
95
+
62
96
  this._domMutationObserver.stop();
63
97
  this.parseHtmlAndRegisterComponents();
64
98
  this.componentRegistry.callSetupForComponent(AppConstants.PageComponentId);
@@ -71,7 +105,10 @@ class Application {
71
105
  this.componentRegistry.callTeardownForComponent(AppConstants.PageComponentId);
72
106
  // Remove/deregister all components from page except Page
73
107
  this.deregisterAllComponentsExceptPage();
74
- this._domMutationObserver.start();
108
+
109
+ if (this._started) {
110
+ this._domMutationObserver.start();
111
+ }
75
112
  }
76
113
 
77
114
  parseHtmlAndRegisterComponents() {
@@ -1,6 +1,7 @@
1
1
  class Observer {
2
2
  _mutationObserver;
3
3
  _callback;
4
+ _callbackScheduled = false;
4
5
 
5
6
  constructor(callback) {
6
7
  this._callback = callback;
@@ -18,10 +19,19 @@ class Observer {
18
19
 
19
20
  stop() {
20
21
  this._mutationObserver.disconnect();
22
+ this._callbackScheduled = false;
21
23
  }
22
24
 
23
25
  domChangedCallback(mutationsList, observer) {
24
- this._callback();
26
+ if (this._callbackScheduled) {
27
+ return;
28
+ }
29
+
30
+ this._callbackScheduled = true;
31
+ queueMicrotask(() => {
32
+ this._callbackScheduled = false;
33
+ this._callback();
34
+ });
25
35
  }
26
36
  }
27
37
 
@@ -2,25 +2,46 @@ class Turbo {
2
2
  _application;
3
3
  _setupCallback;
4
4
  _teardownCallback;
5
+ _setupHandler;
6
+ _teardownHandler;
7
+ _started = false;
5
8
 
6
9
  constructor(application, setupCallback, teardownCallback) {
7
10
  this._application = application;
8
11
  this._setupCallback = setupCallback;
9
12
  this._teardownCallback = teardownCallback;
10
-
11
- this.setupEvents();
12
13
  }
13
14
 
14
15
  // Setups relevant hooks to the page for component lifecycles. This depends on the framework being used.
15
16
  // Here we are using turbo drive, so hooking into that.
16
- setupEvents() {
17
- document.addEventListener("turbo:load", () => {
17
+ start() {
18
+ if (this._started) {
19
+ return;
20
+ }
21
+
22
+ this._setupHandler = () => {
18
23
  this._setupCallback();
19
- });
24
+ };
20
25
 
21
- document.addEventListener("turbo:before-render", () => {
26
+ this._teardownHandler = () => {
22
27
  this._teardownCallback();
23
- });
28
+ };
29
+
30
+ document.addEventListener("turbo:load", this._setupHandler);
31
+ document.addEventListener("turbo:before-render", this._teardownHandler);
32
+ this._started = true;
33
+ }
34
+
35
+ stop() {
36
+ if (!this._started) {
37
+ return;
38
+ }
39
+
40
+ document.removeEventListener("turbo:load", this._setupHandler);
41
+ document.removeEventListener("turbo:before-render", this._teardownHandler);
42
+ this._setupHandler = null;
43
+ this._teardownHandler = null;
44
+ this._started = false;
24
45
  }
25
46
  }
26
47
 
@@ -2,8 +2,7 @@ class ComponentBase {
2
2
  parentComponentId;
3
3
  id;
4
4
  defaultParams;
5
- setupExecuted = false;
6
- teardownExecuted = false;
5
+ mounted = false;
7
6
 
8
7
  constructor(id, parentComponentId = 'Page', defaultParams = []) {
9
8
  this.id = id;
@@ -17,6 +16,24 @@ class ComponentBase {
17
16
  setup() {}
18
17
  teardown() {}
19
18
 
19
+ get setupExecuted() {
20
+ return this.mounted;
21
+ }
22
+
23
+ set setupExecuted(value) {
24
+ this.mounted = value;
25
+ }
26
+
27
+ get teardownExecuted() {
28
+ return !this.mounted;
29
+ }
30
+
31
+ set teardownExecuted(value) {
32
+ if(value === true) {
33
+ this.mounted = false;
34
+ }
35
+ }
36
+
20
37
  rootElement() {
21
38
  return this.rootNode();
22
39
  }
@@ -11,6 +11,10 @@ class ComponentParser {
11
11
  [...document.querySelectorAll('[data-component-class]')].forEach((elem) => {
12
12
  let klassName = elem.dataset.componentClass;
13
13
  if(klassName.trim() === '') { return; }
14
+ if(!this.hasValidId(elem)) {
15
+ console.error(`Component root element is missing an id. className: ${klassName} | Element:`, elem);
16
+ return;
17
+ }
14
18
 
15
19
  let klass = this._componentsClassMapper.getComponentClass(klassName);
16
20
  if(typeof klass === 'undefined' || klass === null) {
@@ -22,7 +26,7 @@ class ComponentParser {
22
26
  return;
23
27
  }
24
28
  try {
25
- let obj = new klass(elem.id, AppConstants.PageComponentId)
29
+ let obj = new klass(elem.id, this.parentComponentIdFor(elem))
26
30
  this._componentRegistry.registerComponentByObj(obj);
27
31
  } catch (e) {
28
32
  console.error(`Error parsing component. className: ${klassName} | Element ID: ${elem.id}`);
@@ -31,6 +35,22 @@ class ComponentParser {
31
35
  }
32
36
  })
33
37
  }
38
+
39
+ hasValidId(elem) {
40
+ return typeof elem.id === 'string' && elem.id.trim() !== '';
41
+ }
42
+
43
+ parentComponentIdFor(elem) {
44
+ let parent = elem.parentElement;
45
+ while(parent) {
46
+ if(parent.dataset?.componentClass && this.hasValidId(parent)) {
47
+ return parent.id;
48
+ }
49
+ parent = parent.parentElement;
50
+ }
51
+
52
+ return AppConstants.PageComponentId;
53
+ }
34
54
  }
35
55
 
36
56
  export { ComponentParser };
@@ -3,6 +3,7 @@ import { AppConstants } from "achilles/application/app_constants";
3
3
  // Contains all the registered components in a page at the current moment
4
4
  class ComponentsRegistry {
5
5
  _registeredComponents = {};
6
+ strictLifecycleErrors = false;
6
7
 
7
8
  matchingElementsForId(id) {
8
9
  return [...document.querySelectorAll('[id]')].filter((elem) => elem.id === id);
@@ -25,6 +26,14 @@ class ComponentsRegistry {
25
26
  // Component is already registered. So have to call deregister and teardown
26
27
  this.teardownAndDeregister(id);
27
28
  }
29
+ let parentComponent = null;
30
+ if(parentComponentId != null) {
31
+ parentComponent = this.getRegisteredComponent(parentComponentId);
32
+ if(!parentComponent) {
33
+ console.error(`Parent component not found while registering component. id: ${id} | parentComponentId: ${parentComponentId}. Skipping registering component`);
34
+ return;
35
+ }
36
+ }
28
37
 
29
38
  this._registeredComponents[id] = {
30
39
  id: id,
@@ -34,7 +43,6 @@ class ComponentsRegistry {
34
43
  subComponents: []
35
44
  };
36
45
  if(parentComponentId != null) {
37
- let parentComponent = this.getRegisteredComponent(parentComponentId);
38
46
  parentComponent.subComponents.push(id);
39
47
  }
40
48
  this.elementForId(id)?.setAttribute('data-component-registered', 'true');
@@ -51,7 +59,7 @@ class ComponentsRegistry {
51
59
  parentComponent.subComponents = parentComponent.subComponents.filter(item => item !== id);
52
60
  }
53
61
 
54
- this._registeredComponents[id] = null;
62
+ delete this._registeredComponents[id];
55
63
  this.elementForId(id)?.removeAttribute('data-component-registered');
56
64
  }
57
65
 
@@ -68,13 +76,13 @@ class ComponentsRegistry {
68
76
  return;
69
77
  }
70
78
 
71
- // Call the objs default setup if its not executed already, if not skip to their children
72
- if(component.obj.setup && component.obj.setupExecuted === false) {
79
+ // Call the objs default setup if it is not mounted already, then continue to children
80
+ if(component.obj.setup && component.obj.mounted === false) {
73
81
  try{
74
82
  component.obj.setup(...component.defaultParams);
75
- component.obj.setupExecuted = true;
83
+ component.obj.mounted = true;
76
84
  } catch(e) {
77
- console.error(e);
85
+ this.handleLifecycleError(e);
78
86
  }
79
87
  }
80
88
 
@@ -89,20 +97,28 @@ class ComponentsRegistry {
89
97
  if(!component || !component.obj)
90
98
  return;
91
99
 
92
- // Call the objs default teardown if not already executed, otherwise skip to its children
93
- if(component.obj.teardown && component.obj.teardownExecuted === false) {
100
+ // Call teardown for all sub view_components before their parent
101
+ component.subComponents.forEach((subComponentId) => {
102
+ this.callTeardownForComponent(subComponentId);
103
+ });
104
+
105
+ // Call the objs default teardown if it is currently mounted
106
+ if(component.obj.teardown && component.obj.mounted === true) {
94
107
  try{
95
108
  component.obj.teardown(...component.defaultParams);
96
- component.obj.teardownExecuted = true;
109
+ component.obj.mounted = false;
97
110
  } catch(e) {
98
- console.error(e);
111
+ this.handleLifecycleError(e);
99
112
  }
100
113
  }
114
+ }
101
115
 
102
- // Call teardown for all sub view_components
103
- component.subComponents.forEach((subComponentId) => {
104
- this.callTeardownForComponent(subComponentId);
105
- });
116
+ handleLifecycleError(error) {
117
+ console.error(error);
118
+
119
+ if(this.strictLifecycleErrors) {
120
+ throw error;
121
+ }
106
122
  }
107
123
 
108
124
  teardownAndDeregister(id) {
@@ -0,0 +1,26 @@
1
+ # Core JavaScript Gaps
2
+
3
+ This is the current improvement backlog for Achilles core JavaScript. API
4
+ reference docs should wait until the structure settles.
5
+
6
+ ## Completed
7
+
8
+ - Added an explicit `Application#start` and `Application#stop` lifecycle.
9
+ - Made Turbo event listeners removable.
10
+ - Validated that component roots have non-empty ids before registration.
11
+ - Changed teardown order so child components tear down before parent components.
12
+ - Deleted deregistered registry entries instead of leaving `null` tombstones.
13
+ - Batched mutation observer setup work with a microtask debounce.
14
+ - Replaced one-way lifecycle flags with a `mounted` state while keeping
15
+ `setupExecuted` and `teardownExecuted` as compatibility aliases.
16
+ - Added tests for missing ids, duplicate starts, listener cleanup,
17
+ child-before-parent teardown, dynamic insertion batching, deregistration, and
18
+ remounting a reused component instance.
19
+ - Chose a DOM ancestry model for nested components under the single synthetic
20
+ `Page` root.
21
+ - Split JavaScript tests by parser, registry, application, Turbo hooks, and
22
+ component base responsibility.
23
+ - Added package/file-list regression coverage for the gemspec.
24
+ - Added opt-in strict lifecycle error handling for tests and development.
25
+
26
+ ## Remaining Priority Gaps
@@ -11,7 +11,8 @@ Use this checklist for `1.0.0.rc1` and future releases.
11
11
 
12
12
  ```bash
13
13
  bin/rails test
14
- for file in $(find app/javascript/achilles -name '*.js' -print); do node --input-type=module --check < "$file" || exit 1; done
14
+ bin/rails test:system
15
+ for file in $(find app/javascript/achilles test/dummy/app/javascript -name '*.js' -print); do node --input-type=module --check < "$file" || exit 1; done
15
16
  RAILS_ENV=test bin/rails app:assets:precompile
16
17
  ```
17
18
 
@@ -0,0 +1,144 @@
1
+ # Upgrading To 1.1.0
2
+
3
+ This guide covers application-facing changes in Achilles `1.1.0`.
4
+
5
+ ## Application Startup
6
+
7
+ Achilles applications must now be started explicitly.
8
+
9
+ Before:
10
+
11
+ ```js
12
+ import { Application } from "achilles/application/application";
13
+ import { MenuComponent } from "components/menu_component";
14
+
15
+ const achilles = new Application();
16
+ achilles.componentsClassMapper.addComponentClass("MenuComponent", MenuComponent);
17
+ ```
18
+
19
+ After:
20
+
21
+ ```js
22
+ import { Application } from "achilles/application/application";
23
+ import { MenuComponent } from "components/menu_component";
24
+
25
+ const achilles = new Application();
26
+ achilles.componentsClassMapper.addComponentClass("MenuComponent", MenuComponent);
27
+ achilles.start();
28
+ ```
29
+
30
+ Call `start()` after registering component classes. This lets applications
31
+ control when Achilles parses the DOM and attaches Turbo lifecycle hooks.
32
+
33
+ ## Stopping An Application
34
+
35
+ Applications that create temporary Achilles instances can now call:
36
+
37
+ ```js
38
+ achilles.stop();
39
+ ```
40
+
41
+ `stop()` removes Turbo lifecycle hooks and stops observing DOM mutations.
42
+
43
+ Most Rails applications only need one long-lived Achilles instance and do not
44
+ need to call `stop()` manually.
45
+
46
+ ## Search Checklist
47
+
48
+ In each application, search for Achilles construction:
49
+
50
+ ```bash
51
+ rg "new Application"
52
+ ```
53
+
54
+ For every application instance, confirm component classes are registered before
55
+ calling `start()`.
56
+
57
+ ## Component Root Ids
58
+
59
+ Every element with `data-component-class` must have a non-empty `id`.
60
+
61
+ Before:
62
+
63
+ ```erb
64
+ <div data-component-class="MenuComponent"></div>
65
+ ```
66
+
67
+ After:
68
+
69
+ ```erb
70
+ <div id="menu" data-component-class="MenuComponent"></div>
71
+ ```
72
+
73
+ Achilles uses component root ids as registry keys. Components without ids are
74
+ skipped and reported in the browser console.
75
+
76
+ Search for component roots without ids:
77
+
78
+ ```bash
79
+ rg "data-component-class"
80
+ ```
81
+
82
+ ## Nested Components
83
+
84
+ Achilles now builds the component tree from DOM ancestry.
85
+
86
+ - `Page` remains the single synthetic root component.
87
+ - A component's parent is its nearest ancestor element with
88
+ `data-component-class`.
89
+ - If no component ancestor exists, the parent is `Page`.
90
+
91
+ For example:
92
+
93
+ ```erb
94
+ <div id="dashboard" data-component-class="DashboardComponent">
95
+ <div id="filters" data-component-class="FiltersComponent"></div>
96
+ </div>
97
+ ```
98
+
99
+ The component tree is:
100
+
101
+ ```text
102
+ Page
103
+ dashboard
104
+ filters
105
+ ```
106
+
107
+ ## Teardown Order
108
+
109
+ Component teardown now runs from children to parents.
110
+
111
+ Before this change, a parent component's `teardown()` could run before its child
112
+ components. Now child components clean up first, and the parent cleans up after
113
+ its subtree.
114
+
115
+ Review parent components whose `teardown()` removes DOM nodes, shared event
116
+ targets, third-party widgets, or state that child components also use during
117
+ cleanup.
118
+
119
+ ## Strict Lifecycle Errors
120
+
121
+ By default, Achilles logs `setup()` and `teardown()` errors and keeps the page
122
+ running.
123
+
124
+ Tests and development environments can opt into strict lifecycle errors:
125
+
126
+ ```js
127
+ const achilles = new Application();
128
+ achilles.strictLifecycleErrors = true;
129
+ ```
130
+
131
+ When strict mode is enabled, `setup()` and `teardown()` errors are still logged
132
+ and then re-raised.
133
+
134
+ ## Manual Test Checklist
135
+
136
+ After updating an application:
137
+
138
+ - boot the app
139
+ - open a page with Achilles components
140
+ - confirm component `setup()` methods run
141
+ - navigate with Turbo to another page and back
142
+ - confirm `teardown()` still runs before Turbo renders a new page
143
+ - insert any dynamic component markup the app supports
144
+ - check the browser console for Achilles errors
data/docs/upgrading.md ADDED
@@ -0,0 +1,26 @@
1
+ # Upgrading Achilles
2
+
3
+ Use this page as the entry point for application upgrades.
4
+
5
+ Achilles should have an upgrade note for every release that changes application
6
+ setup, component markup, lifecycle behavior, or public APIs.
7
+
8
+ ## Current Upgrade Notes
9
+
10
+ - [Upgrading to 1.1.0](upgrading-to-1.1.0.md)
11
+ - [Migrating from 0.1.3 to v1](migrating-from-0.1.3-to-v1.md)
12
+
13
+ ## Upgrade Policy
14
+
15
+ Before upgrading an existing application:
16
+
17
+ 1. Read the guide for the target version.
18
+ 2. Upgrade one application first.
19
+ 3. Run the application's test suite.
20
+ 4. Visit pages with Achilles components.
21
+ 5. Verify Turbo navigation and dynamically inserted components.
22
+ 6. Check the browser console for lifecycle, missing component class, or duplicate
23
+ id errors.
24
+
25
+ For breaking changes, prefer testing a release candidate in a real application
26
+ before upgrading every project.
@@ -1,3 +1,3 @@
1
1
  module Achilles
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
data/lib/achilles.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "achilles/version"
2
2
  require "importmap-rails"
3
+ require "turbo-rails"
3
4
  require "achilles/engine"
4
5
 
5
6
  module Achilles
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: achilles
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jey Geethan
@@ -60,9 +60,13 @@ extensions: []
60
60
  extra_rdoc_files: []
61
61
  files:
62
62
  - CHANGELOG.md
63
+ - CODE_OF_CONDUCT.md
64
+ - CONTRIBUTING.md
65
+ - MAINTAINERS.md
63
66
  - MIT-LICENSE
64
67
  - README.md
65
68
  - Rakefile
69
+ - SECURITY.md
66
70
  - app/assets/config/achilles/manifest.js
67
71
  - app/assets/stylesheets/achilles/application.css
68
72
  - app/controllers/achilles/application_controller.rb
@@ -83,8 +87,11 @@ files:
83
87
  - app/views/layouts/achilles/application.html.erb
84
88
  - config/importmap.rb
85
89
  - config/routes.rb
90
+ - docs/core-js-gaps.md
86
91
  - docs/migrating-from-0.1.3-to-v1.md
87
92
  - docs/release-checklist.md
93
+ - docs/upgrading-to-1.1.0.md
94
+ - docs/upgrading.md
88
95
  - docs/v1-roadmap.md
89
96
  - lib/achilles.rb
90
97
  - lib/achilles/engine.rb