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 +4 -4
- data/CHANGELOG.md +35 -0
- data/app/helpers/hotwire_club/toolbox/optimistic_form_helper.rb +1 -1
- data/app/javascript/hotwire_club/toolbox/optimistic_form_controller.js +37 -4
- data/docs/optimistic-form.md +20 -1
- data/lib/hotwire_club/toolbox/engine.rb +5 -0
- data/lib/hotwire_club/toolbox/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ea1b171969bd0f9185d243aae5a8bf0934a275c537ea19945bb9e386bbd177e
|
|
4
|
+
data.tar.gz: ee66e7f2d9d3ba8a3d0c2308d13349cf87e98cd43e336ced4d74f3b63a0fd8da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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).
|
|
6
|
-
//
|
|
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.
|
|
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>',
|
data/docs/optimistic-form.md
CHANGED
|
@@ -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
|
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.
|
|
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-
|
|
10
|
+
date: 2026-10-03 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|