achilles 1.0.0 → 1.1.1

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.
@@ -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() {
@@ -80,8 +117,8 @@ class Application {
80
117
  }
81
118
 
82
119
  deregisterAllComponentsExceptPage() {
83
- let pageComponent = this.componentRegistry.getRegisteredComponent(AppConstants.PageComponentId)
84
- pageComponent.subComponents.forEach((subComponentId) => {
120
+ let pageComponent = this.componentRegistry.getRegisteredComponent(AppConstants.PageComponentId);
121
+ [...pageComponent.subComponents].forEach((subComponentId) => {
85
122
  this.componentRegistry.deregisterComponent(subComponentId);
86
123
  })
87
124
  }
@@ -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) {
@@ -18,11 +22,11 @@ class ComponentParser {
18
22
  console.error(elem);
19
23
  return;
20
24
  }
21
- if(elem.dataset.componentRegistered === 'true') {
25
+ if(elem.dataset.componentRegistered === 'true' && this._componentRegistry.getRegisteredComponent(elem.id)) {
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');
@@ -45,13 +53,17 @@ class ComponentsRegistry {
45
53
  if(!component)
46
54
  return;
47
55
 
56
+ [...component.subComponents].forEach((subComponentId) => {
57
+ this.deregisterComponent(subComponentId);
58
+ });
59
+
48
60
  let parentComponent = this.getRegisteredComponent(component.parentComponentId);
49
61
 
50
62
  if(parentComponent != null){
51
63
  parentComponent.subComponents = parentComponent.subComponents.filter(item => item !== id);
52
64
  }
53
65
 
54
- this._registeredComponents[id] = null;
66
+ delete this._registeredComponents[id];
55
67
  this.elementForId(id)?.removeAttribute('data-component-registered');
56
68
  }
57
69
 
@@ -68,13 +80,13 @@ class ComponentsRegistry {
68
80
  return;
69
81
  }
70
82
 
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) {
83
+ // Call the objs default setup if it is not mounted already, then continue to children
84
+ if(component.obj.setup && component.obj.mounted === false) {
73
85
  try{
74
86
  component.obj.setup(...component.defaultParams);
75
- component.obj.setupExecuted = true;
87
+ component.obj.mounted = true;
76
88
  } catch(e) {
77
- console.error(e);
89
+ this.handleLifecycleError(e);
78
90
  }
79
91
  }
80
92
 
@@ -89,20 +101,28 @@ class ComponentsRegistry {
89
101
  if(!component || !component.obj)
90
102
  return;
91
103
 
92
- // Call the objs default teardown if not already executed, otherwise skip to its children
93
- if(component.obj.teardown && component.obj.teardownExecuted === false) {
104
+ // Call teardown for all sub view_components before their parent
105
+ component.subComponents.forEach((subComponentId) => {
106
+ this.callTeardownForComponent(subComponentId);
107
+ });
108
+
109
+ // Call the objs default teardown if it is currently mounted
110
+ if(component.obj.teardown && component.obj.mounted === true) {
94
111
  try{
95
112
  component.obj.teardown(...component.defaultParams);
96
- component.obj.teardownExecuted = true;
113
+ component.obj.mounted = false;
97
114
  } catch(e) {
98
- console.error(e);
115
+ this.handleLifecycleError(e);
99
116
  }
100
117
  }
118
+ }
101
119
 
102
- // Call teardown for all sub view_components
103
- component.subComponents.forEach((subComponentId) => {
104
- this.callTeardownForComponent(subComponentId);
105
- });
120
+ handleLifecycleError(error) {
121
+ console.error(error);
122
+
123
+ if(this.strictLifecycleErrors) {
124
+ throw error;
125
+ }
106
126
  }
107
127
 
108
128
  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
@@ -1,17 +1,29 @@
1
1
  # Release Checklist
2
2
 
3
- Use this checklist for `1.0.0.rc1` and future releases.
3
+ Use this checklist for every Achilles release. Replace `VERSION` with the
4
+ version being prepared, for example `1.1.0`.
4
5
 
5
- ## Before Building
6
+ ## Before Finalizing The Version
6
7
 
8
+ - Confirm the release type: patch, minor, major, or prerelease.
7
9
  - Confirm `lib/achilles/version.rb` has the intended version.
8
10
  - Confirm `CHANGELOG.md` has an entry for the intended version.
11
+ - Confirm application-facing changes have an upgrade note in `docs/upgrading.md`.
12
+ - Confirm release notes exist in `docs/releases/` when the release needs a
13
+ GitHub release body.
14
+ - Confirm package file expectations are covered by `test/gemspec_files_test.rb`
15
+ when adding source or documentation files.
16
+
17
+ ## Before Building
18
+
9
19
  - Confirm CI is green on GitHub.
10
20
  - Run the local verification commands:
11
21
 
12
22
  ```bash
13
23
  bin/rails test
14
- for file in $(find app/javascript/achilles -name '*.js' -print); do node --input-type=module --check < "$file" || exit 1; done
24
+ node --test test/javascript/*_test.mjs
25
+ for file in $(rg --files app/javascript/achilles test/dummy/app/javascript test/javascript | rg "\.(js|mjs)$"); do node --input-type=module --check < "$file" || exit 1; done
26
+ bin/rails test:system
15
27
  RAILS_ENV=test bin/rails app:assets:precompile
16
28
  ```
17
29
 
@@ -24,13 +36,13 @@ gem build achilles.gemspec
24
36
  Confirm the generated gem name matches the intended version:
25
37
 
26
38
  ```bash
27
- ls achilles-*.gem
39
+ ls achilles-VERSION.gem
28
40
  ```
29
41
 
30
42
  ## Test In A Real Application
31
43
 
32
- In one application that currently uses Achilles `0.1.3`, point the Gemfile to
33
- the local checkout or install the built prerelease gem.
44
+ Before a broad rollout, test the built gem in at least one real Rails + Turbo
45
+ application that already uses Achilles.
34
46
 
35
47
  Check:
36
48
 
@@ -44,30 +56,37 @@ Check:
44
56
  - components that attach window, document, timer, observer, or third-party
45
57
  widget state
46
58
 
47
- For v1, `rootElement()` returns a DOM element. If a component expects a jQuery
48
- object, update it to use DOM APIs or wrap explicitly with
49
- `$(this.rootElement())`.
59
+ For releases with breaking or compatibility-sensitive behavior, test one app
60
+ first, then roll out to the rest of the maintained apps gradually.
50
61
 
51
62
  ## Publish A Prerelease
52
63
 
53
- Only publish `1.0.0.rc1` after local checks and GitHub CI pass.
64
+ Publish a prerelease only when the release needs real-app validation before a
65
+ final tag.
54
66
 
55
67
  ```bash
56
- gem push achilles-1.0.0.rc1.gem
57
- git tag v1.0.0.rc1
58
- git push origin v1.0.0.rc1
68
+ git tag vVERSION
69
+ git push origin vVERSION
70
+ gem push achilles-VERSION.gem
59
71
  ```
60
72
 
61
- ## Publish Final v1.0.0
73
+ Mark the GitHub release as a prerelease and include the matching release notes.
62
74
 
63
- Publish final `1.0.0` only after at least one real application has successfully
64
- tested the release candidate.
75
+ ## Publish A Final Release
76
+
77
+ Publish a final release after local checks, GitHub CI, packaging verification,
78
+ and real-app testing are complete.
79
+
80
+ ```bash
81
+ git tag vVERSION
82
+ git push origin vVERSION
83
+ gem push achilles-VERSION.gem
84
+ ```
65
85
 
66
- Before final release:
86
+ After pushing:
67
87
 
68
- - update `lib/achilles/version.rb` to `1.0.0`
69
- - update `CHANGELOG.md` from `1.0.0.rc1` to `1.0.0`
70
- - run all local checks
71
- - confirm GitHub CI is green
72
- - build and push `achilles-1.0.0.gem`
73
- - tag `v1.0.0`
88
+ - Create or update the GitHub release for `vVERSION`.
89
+ - Paste the release note from `docs/releases/vVERSION.md` when one exists, for
90
+ example `docs/releases/v1.1.0.md`.
91
+ - Link the release back to the upgrade guide for application-facing changes.
92
+ - Confirm the pushed gem is visible on RubyGems.
@@ -0,0 +1,74 @@
1
+ # Achilles 1.1.0
2
+
3
+ Achilles `1.1.0` tightens the application lifecycle for Rails + Turbo apps and
4
+ adds stronger nested-component behavior. This release is intended for existing
5
+ Achilles users who want explicit startup, safer teardown ordering, and better
6
+ runtime behavior around Turbo navigation and dynamic markup.
7
+
8
+ ## Upgrade First
9
+
10
+ This release includes application-facing changes. Before upgrading an existing
11
+ app, read the full guide:
12
+
13
+ - [Upgrading to 1.1.0](../upgrading-to-1.1.0.md)
14
+
15
+ The most important change is explicit application startup:
16
+
17
+ ```js
18
+ const achilles = new Application();
19
+ achilles.componentsClassMapper.addComponentClass("MenuComponent", MenuComponent);
20
+ achilles.start();
21
+ ```
22
+
23
+ Call `start()` after registering component classes. Achilles no longer starts
24
+ from the `Application` constructor.
25
+
26
+ ## Highlights
27
+
28
+ - Added explicit `Application#start` and `Application#stop` lifecycle methods.
29
+ - Added `Application#strictLifecycleErrors` for tests and development.
30
+ - Component parentage now follows DOM ancestry under the synthetic `Page` root.
31
+ - Component teardown now runs from children to parents.
32
+ - Elements with `data-component-class` must have a non-empty `id`; invalid roots
33
+ are skipped with a browser-console error.
34
+ - Achilles now requires `turbo-rails`, so Turbo importmap assets are available
35
+ to host applications.
36
+ - Added browser system coverage for nested components inside Turbo form
37
+ replacement and Turbo Drive navigation.
38
+
39
+ ## Breaking Or Compatibility-Sensitive Changes
40
+
41
+ Applications should check these items before updating:
42
+
43
+ - Search for `new Application` and make sure each Achilles instance calls
44
+ `start()` after all component classes are registered.
45
+ - Search for `data-component-class` and make sure every component root has a
46
+ non-empty unique `id`.
47
+ - Review parent components whose `teardown()` removes DOM nodes, shared event
48
+ targets, widgets, or state that child components also use during cleanup.
49
+ - If you have tests that should fail on lifecycle errors, enable strict mode:
50
+
51
+ ```js
52
+ achilles.strictLifecycleErrors = true;
53
+ ```
54
+
55
+ ## Verification
56
+
57
+ This release line was verified with:
58
+
59
+ ```bash
60
+ bin/rails test
61
+ node --test test/javascript/*_test.mjs
62
+ bin/rails test:system
63
+ RAILS_ENV=test bin/rails app:assets:precompile
64
+ gem build achilles.gemspec
65
+ ```
66
+
67
+ One real application has also been updated successfully. Continue testing in
68
+ your own Rails + Turbo apps before rolling the update through every project.
69
+
70
+ ## Links
71
+
72
+ - [Changelog](../../CHANGELOG.md)
73
+ - [Upgrade index](../upgrading.md)
74
+ - [1.1.0 upgrade guide](../upgrading-to-1.1.0.md)
@@ -0,0 +1,46 @@
1
+ # Achilles 1.1.1
2
+
3
+ Achilles `1.1.1` is a patch release for Rails + Turbo applications using
4
+ browser history restoration. It fixes restored component markup that appeared on
5
+ screen after browser back or forward navigation but no longer had active
6
+ Achilles event listeners.
7
+
8
+ ## Who Should Upgrade
9
+
10
+ Upgrade from `1.1.0` if your application uses Turbo Drive navigation and users
11
+ can return to Achilles-managed pages with the browser back or forward buttons.
12
+
13
+ There are no new application setup requirements in this patch release. Apps
14
+ upgrading from versions before `1.1.0` should still read the
15
+ [1.1.0 upgrade guide](../upgrading-to-1.1.0.md).
16
+
17
+ ## Fixed
18
+
19
+ - Restored Turbo-cached DOM with stale `data-component-registered="true"` flags
20
+ is now parsed correctly when Achilles no longer has a matching registry entry.
21
+ - Deregistering a parent component now deregisters its child component subtree,
22
+ preventing orphaned child registry entries from blocking future setup.
23
+ - Page-level deregistration now iterates over a stable copy of child component
24
+ ids while the registry is being mutated.
25
+
26
+ ## Verification
27
+
28
+ This release was verified with:
29
+
30
+ ```bash
31
+ bin/rails test
32
+ node --test test/javascript/*_test.mjs
33
+ bin/rails test:system
34
+ RAILS_ENV=test bin/rails app:assets:precompile
35
+ gem build achilles.gemspec
36
+ ```
37
+
38
+ The system suite includes browser back, browser forward, restored nested
39
+ components, restored form-field listeners, restored Turbo-replaced forms, and
40
+ duplicate listener prevention.
41
+
42
+ ## Links
43
+
44
+ - [Changelog](../../CHANGELOG.md)
45
+ - [Upgrade index](../upgrading.md)
46
+ - [1.1.0 upgrade guide](../upgrading-to-1.1.0.md)