hotwire_club-toolbox 0.1.1 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bedf92af7ff8eee2fae5cc33bafda59fc79c68c8c8cb8052a77bc77b43f674b
4
- data.tar.gz: b7ca7fe46f611501e4383398e79d8d73845a64e2a7e647c239be685d137c01a6
3
+ metadata.gz: 5ea1b171969bd0f9185d243aae5a8bf0934a275c537ea19945bb9e386bbd177e
4
+ data.tar.gz: ee66e7f2d9d3ba8a3d0c2308d13349cf87e98cd43e336ced4d74f3b63a0fd8da
5
5
  SHA512:
6
- metadata.gz: 2e072995e35f86644f3f6521aa144375a3291989c0e96ab67e6e93543bf29ddea3c60bae060fc707d668e2c303a7e3b553660c0584d324e36214bb4e7b92c52c
7
- data.tar.gz: cbed3456a119ea18e391b68f62633402f182ac8c7ba4bd8944b409ca24f0ed58460259274477de31af8d38bf54fc3e9f6bb3d6f18557be328dcbaa8127957d84
6
+ metadata.gz: a14b2849acc3cb33b56bc6e64821b92aced673a2543d91bf419fc4cf42c676bc2f5f75da94c2aa85f38183e1afd72116ce0b1585a6aecfa61b7b12ab6306d708
7
+ data.tar.gz: 64802fe9552bafa4d4c23a5e4e2b47f5249d294b55bf2e82914596d2e33a920fd6a074d4d5fdef3cf68103ddc9235a4fd2a415c559e3ab11b320d54d34796608
data/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [0.2.1]
6
+
7
+ ### Fixed
8
+ - The app no longer fails to boot with `uninitialized constant
9
+ HotwireClub::Toolbox::OptimisticFormHelper` when another gem loads
10
+ `ActionView::Base` before the app initializes (prawn-rails does, during
11
+ `Bundler.require`). The engine's `on_load(:action_view)` hook then runs
12
+ immediately, before the main autoloader is set up. The engine's own
13
+ `app/helpers` directory is now managed by the once autoloader, which is
14
+ available during initialization. Nothing else moves: the host app's helpers
15
+ and other engines stay on the main autoloader. The only consequence is that
16
+ the toolbox's helpers are no longer reloaded in development, which suits
17
+ modules mixed into the never-reloaded `ActionView::Base`.
18
+
19
+ ## [0.2.0]
20
+
21
+ ### Changed
22
+ - **Optimistic Form**: reconciliation moved from the form to the document. The
23
+ controller follows each submission it saw on `turbo:submit-start` and
24
+ refreshes on its `turbo:submit-end` when it failed, wherever that event is
25
+ dispatched. `optimistic_form_with` / `optimistic_form_for` now wire only
26
+ `turbo:submit-start->optimistic-form#apply`; the
27
+ `turbo:submit-end->optimistic-form#refresh` action is gone. A form that
28
+ still carries it (hand-wired, or rendered by a cached fragment) keeps
29
+ working: `refresh` runs once per submission.
30
+ - **Upgrading**: apps that copied `optimistic_form_controller.js` (jsbundling,
31
+ vite) must copy the new one before rendering with the 0.2.0 helpers, since
32
+ the old controller only reconciled through the removed action.
33
+
34
+ ### Fixed
35
+ - **Optimistic Form**: a failed submission is reconciled when the optimistic
36
+ paint removed the form itself, as a predicted row removal does. Turbo
37
+ dispatches `turbo:submit-end` on `<html>` once the form has left the page,
38
+ so the form-bound action never ran and the removed row stayed gone.
39
+
5
40
  ## [0.1.1]
6
41
 
7
42
  ### Added
@@ -71,7 +71,7 @@ module HotwireClub
71
71
  options[:html][:data][:controller] =
72
72
  [ options[:html][:data][:controller], "optimistic-form" ].compact.join(" ")
73
73
  options[:html][:data][:action] =
74
- [ options[:html][:data][:action], "turbo:submit-start->optimistic-form#apply turbo:submit-end->optimistic-form#refresh" ].compact.join(" ")
74
+ [ options[:html][:data][:action], "turbo:submit-start->optimistic-form#apply" ].compact.join(" ")
75
75
  end
76
76
  end
77
77
  end
@@ -2,18 +2,25 @@ import { Controller } from "@hotwired/stimulus";
2
2
  import { throttle } from "hotwire_club/toolbox/helpers/timing_helpers";
3
3
 
4
4
  // Applies optimistic UI on form submit by cloning the form's <template>(s)
5
- // into the DOM (Turbo then processes the contained turbo-stream). On submit-end
6
- // it reconciles against the server only when the submission failed.
5
+ // into the DOM (Turbo then processes the contained turbo-stream). Each
6
+ // submission is followed from the document until its submit-end, and the page
7
+ // is refreshed to reconcile against the server only when the submission failed.
7
8
  export default class extends Controller {
8
9
  static targets = ["template"];
9
10
 
10
11
  initialize() {
11
12
  // Throttle so the paint fires immediately, but a burst of rapid submits on
12
13
  // the same form can't stack duplicate clones.
13
- this.apply = throttle(this.apply.bind(this), 200);
14
+ this.paint = throttle(this.paint.bind(this), 200);
15
+ this.reconciled = new WeakSet();
14
16
  }
15
17
 
16
- apply() {
18
+ apply(event) {
19
+ this.follow(event?.detail?.formSubmission);
20
+ this.paint();
21
+ }
22
+
23
+ paint() {
17
24
  if (!this.hasTemplateTarget) return;
18
25
 
19
26
  this.templateTargets.forEach((template) => {
@@ -21,11 +28,37 @@ export default class extends Controller {
21
28
  });
22
29
  }
23
30
 
31
+ // Listened for on the document, not the form: a prediction may remove the
32
+ // form itself (a deleted row), and Turbo then dispatches turbo:submit-end on
33
+ // <html>, where a listener on the form never hears it.
34
+ follow(formSubmission) {
35
+ if (!formSubmission) return;
36
+
37
+ // Not `once`: with several submissions in flight, another form's
38
+ // submit-end may come first, and that one must not consume the listener.
39
+ const onSubmitEnd = (event) => {
40
+ if (event.detail.formSubmission !== formSubmission) return;
41
+
42
+ document.removeEventListener("turbo:submit-end", onSubmitEnd);
43
+ this.refresh(event);
44
+ };
45
+
46
+ document.addEventListener("turbo:submit-end", onSubmitEnd);
47
+ }
48
+
24
49
  refresh(event) {
25
50
  // On success the optimistic paint already reflects the new state; only
26
51
  // refresh to reconcile when the server rejected the submission.
27
52
  if (event.detail?.success) return;
28
53
 
54
+ // Once per submission, so a form that still carries the pre-0.2 submit-end
55
+ // action does not refresh a second time.
56
+ const { formSubmission } = event.detail ?? {};
57
+ if (formSubmission) {
58
+ if (this.reconciled.has(formSubmission)) return;
59
+ this.reconciled.add(formSubmission);
60
+ }
61
+
29
62
  document.body.insertAdjacentHTML(
30
63
  "beforeend",
31
64
  '<turbo-stream action="refresh"></turbo-stream>',
@@ -53,11 +53,30 @@ An add-to-cart button that bumps a counter elsewhere on the page:
53
53
 
54
54
  ## The reconciliation model
55
55
 
56
- - **On submit-start**, the controller clones the form's `<template>`(s) into the document. Turbo processes the contained stream and paints the optimistic state.
56
+ - **On submit-start**, the controller clones the form's `<template>`(s) into the document. Turbo processes the contained stream and paints the optimistic state. The controller also starts following that submission from the document.
57
57
  - **On submit-end**, the controller reconciles **only if the submission failed** (`event.detail.success === false`). On success the optimistic paint already reflects the new state, so nothing happens.
58
58
 
59
+ The helpers wire a single action onto the form, `turbo:submit-start->optimistic-form#apply`. Submit-end is not an action on the form: the controller listens for it on the document, matched to the submission it painted, so it is heard even when the form has left the page by then (see below).
60
+
59
61
  This keeps the happy path free of extra requests. A full page refresh only ever runs when the server rejects the change, which is exactly when you want authoritative truth.
60
62
 
63
+ ### Predictions that remove the form
64
+
65
+ A prediction may take its own form out of the page: a delete button whose template removes the row it sits in, or one that replaces a card around it.
66
+
67
+ ```erb
68
+ <%= optimistic_form_with url: photo_path(photo), method: :delete do |form| %>
69
+ <%= form.optimistic_template { turbo_stream.remove(dom_id(photo)) } %>
70
+ <%= form.button "Remove" %>
71
+ <% end %>
72
+ ```
73
+
74
+ Turbo dispatches `turbo:submit-end` on `<html>` once the form is no longer connected, so a listener on the form would never hear it. Because the controller follows the submission from the document, a rejected removal refreshes the page and brings the row back like any other failure.
75
+
76
+ ### Upgrading from 0.1
77
+
78
+ Before 0.2.0 the helpers also wired `turbo:submit-end->optimistic-form#refresh` onto the form, which is why a removed form was never reconciled. If your app copied `optimistic_form_controller.js` (jsbundling, vite), copy the 0.2.0 one before upgrading the gem: the old controller only reconciles through that action, which the 0.2.0 helpers no longer render. A form that still carries the action is harmless with the new controller, which refreshes once per submission.
79
+
61
80
  ## Server contract
62
81
 
63
82
  Because success trusts the optimistic paint and failure triggers a client refresh, your controller must respond accordingly:
@@ -1,6 +1,11 @@
1
1
  module HotwireClub
2
2
  module Toolbox
3
3
  class Engine < ::Rails::Engine
4
+ # The helpers below are included into ActionView::Base, which some gems
5
+ # load before the app initializes; the hook then runs at once, before the
6
+ # main autoloader exists. The once autoloader is already set up by then.
7
+ paths["app/helpers"].autoload_once!
8
+
4
9
  # Make the engine's view helpers available in the host app's views.
5
10
  # (The engine intentionally does not isolate its namespace.)
6
11
  initializer "hotwire_club.toolbox.helpers" do
@@ -1,5 +1,5 @@
1
1
  module HotwireClub
2
2
  module Toolbox
3
- VERSION = "0.1.1"
3
+ VERSION = "0.2.1"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_club-toolbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Rubisch
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-09-30 00:00:00.000000000 Z
10
+ date: 2026-10-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails