hotwire_club-toolbox 0.1.1 → 0.2.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: 9bedf92af7ff8eee2fae5cc33bafda59fc79c68c8c8cb8052a77bc77b43f674b
4
- data.tar.gz: b7ca7fe46f611501e4383398e79d8d73845a64e2a7e647c239be685d137c01a6
3
+ metadata.gz: 3ef4fb1e166b6dfb0c498a89f9dbdec29c95af1228d45d126a080ee4328ca44f
4
+ data.tar.gz: 67c0d7880e2e4a9749632ebd5a59c84575046cf88589ca79e8ef0919062ce661
5
5
  SHA512:
6
- metadata.gz: 2e072995e35f86644f3f6521aa144375a3291989c0e96ab67e6e93543bf29ddea3c60bae060fc707d668e2c303a7e3b553660c0584d324e36214bb4e7b92c52c
7
- data.tar.gz: cbed3456a119ea18e391b68f62633402f182ac8c7ba4bd8944b409ca24f0ed58460259274477de31af8d38bf54fc3e9f6bb3d6f18557be328dcbaa8127957d84
6
+ metadata.gz: 9ace48512f23f6dd4fff86676542564f50354baa4e41520157bbca198ffedf01647798041ac1f6ed9cdd0b2b42933ae6a3cb20fd774a1153c3add0bd5cb00cfb
7
+ data.tar.gz: 63c0130410564e5bee90f918521ab146caca70aeb266c5ba0e781a4e4e01290c149d1dc16f0e1ba5ef6029ec30a201ada01dff066418b73b7627f44956bc87bc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [0.2.0]
6
+
7
+ ### Changed
8
+ - **Optimistic Form**: reconciliation moved from the form to the document. The
9
+ controller follows each submission it saw on `turbo:submit-start` and
10
+ refreshes on its `turbo:submit-end` when it failed, wherever that event is
11
+ dispatched. `optimistic_form_with` / `optimistic_form_for` now wire only
12
+ `turbo:submit-start->optimistic-form#apply`; the
13
+ `turbo:submit-end->optimistic-form#refresh` action is gone. A form that
14
+ still carries it (hand-wired, or rendered by a cached fragment) keeps
15
+ working: `refresh` runs once per submission.
16
+ - **Upgrading**: apps that copied `optimistic_form_controller.js` (jsbundling,
17
+ vite) must copy the new one before rendering with the 0.2.0 helpers, since
18
+ the old controller only reconciled through the removed action.
19
+
20
+ ### Fixed
21
+ - **Optimistic Form**: a failed submission is reconciled when the optimistic
22
+ paint removed the form itself, as a predicted row removal does. Turbo
23
+ dispatches `turbo:submit-end` on `<html>` once the form has left the page,
24
+ so the form-bound action never ran and the removed row stayed gone.
25
+
5
26
  ## [0.1.1]
6
27
 
7
28
  ### 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,5 +1,5 @@
1
1
  module HotwireClub
2
2
  module Toolbox
3
- VERSION = "0.1.1"
3
+ VERSION = "0.2.0"
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.0
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