motion 0.1.2 → 0.4.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 +4 -4
- data/lib/generators/motion/component_generator.rb +34 -0
- data/lib/generators/motion/install_generator.rb +10 -3
- data/lib/generators/motion/templates/motion.js +37 -0
- data/lib/generators/motion/templates/motion.rb +54 -15
- data/lib/motion.rb +6 -0
- data/lib/motion/action_cable_extentions.rb +6 -0
- data/lib/motion/action_cable_extentions/declarative_notifications.rb +101 -0
- data/lib/motion/action_cable_extentions/declarative_streams.rb +9 -43
- data/lib/motion/action_cable_extentions/synchronization.rb +34 -0
- data/lib/motion/callback.rb +35 -0
- data/lib/motion/channel.rb +13 -5
- data/lib/motion/component.rb +4 -0
- data/lib/motion/component/broadcasts.rb +40 -26
- data/lib/motion/component/callbacks.rb +19 -0
- data/lib/motion/component/lifecycle.rb +91 -9
- data/lib/motion/component/motions.rb +26 -16
- data/lib/motion/component/periodic_timers.rb +68 -0
- data/lib/motion/component/rendering.rb +25 -16
- data/lib/motion/component_connection.rb +18 -2
- data/lib/motion/configuration.rb +18 -11
- data/lib/motion/errors.rb +102 -71
- data/lib/motion/event.rb +9 -1
- data/lib/motion/log_helper.rb +2 -0
- data/lib/motion/markup_transformer.rb +6 -21
- data/lib/motion/railtie.rb +1 -0
- data/lib/motion/revision_calculator.rb +48 -0
- data/lib/motion/serializer.rb +4 -0
- data/lib/motion/version.rb +1 -1
- metadata +11 -4
- data/lib/generators/motion/templates/motion_controller.js +0 -28
@@ -11,6 +11,15 @@ module Motion
|
|
11
11
|
RERENDER_MARKER_IVAR = :@__awaiting_forced_rerender__
|
12
12
|
private_constant :RERENDER_MARKER_IVAR
|
13
13
|
|
14
|
+
# Some changes to Motion's state are specifically supported during render.
|
15
|
+
ALLOWED_NEW_IVARS_DURING_RENDER = %i[
|
16
|
+
@_broadcast_handlers
|
17
|
+
@_stable_instance_identifier_for_callbacks
|
18
|
+
@_motion_handlers
|
19
|
+
@_periodic_timers
|
20
|
+
].freeze
|
21
|
+
private_constant :ALLOWED_NEW_IVARS_DURING_RENDER
|
22
|
+
|
14
23
|
def rerender!
|
15
24
|
instance_variable_set(RERENDER_MARKER_IVAR, true)
|
16
25
|
end
|
@@ -24,42 +33,42 @@ module Motion
|
|
24
33
|
# * If it doesn't change every time the component's state changes,
|
25
34
|
# things may fall out of sync unless you also call `#rerender!`
|
26
35
|
def render_hash
|
27
|
-
|
28
|
-
#
|
29
|
-
# Is something with Ruby's built-in `hash` Good Enough(TM)?
|
30
|
-
#
|
31
|
-
# instance_variables
|
32
|
-
# .map { |ivar| instance_variable_get(ivar).hash }
|
33
|
-
# .reduce(0, &:^)
|
34
|
-
|
35
|
-
key, _state = Motion.serializer.serialize(self)
|
36
|
-
key
|
36
|
+
Motion.serializer.weak_digest(self)
|
37
37
|
end
|
38
38
|
|
39
39
|
def render_in(view_context)
|
40
40
|
raise BlockNotAllowedError, self if block_given?
|
41
|
-
clear_awaiting_forced_rerender!
|
42
41
|
|
43
|
-
html =
|
42
|
+
html =
|
43
|
+
_run_action_callbacks(context: :render) {
|
44
|
+
_clear_awaiting_forced_rerender!
|
45
|
+
|
46
|
+
view_context.capture { _without_new_instance_variables { super } }
|
47
|
+
}
|
48
|
+
|
49
|
+
raise RenderAborted, self if html == false
|
44
50
|
|
45
51
|
Motion.markup_transformer.add_state_to_html(self, html)
|
46
52
|
end
|
47
53
|
|
48
54
|
private
|
49
55
|
|
50
|
-
def
|
56
|
+
def _clear_awaiting_forced_rerender!
|
51
57
|
return unless awaiting_forced_rerender?
|
52
58
|
|
53
59
|
remove_instance_variable(RERENDER_MARKER_IVAR)
|
54
60
|
end
|
55
61
|
|
56
|
-
def
|
62
|
+
def _without_new_instance_variables
|
57
63
|
existing_instance_variables = instance_variables
|
58
64
|
|
59
65
|
yield
|
60
66
|
ensure
|
61
|
-
(
|
62
|
-
|
67
|
+
(
|
68
|
+
instance_variables -
|
69
|
+
existing_instance_variables -
|
70
|
+
ALLOWED_NEW_IVARS_DURING_RENDER
|
71
|
+
).each(&method(:remove_instance_variable))
|
63
72
|
end
|
64
73
|
end
|
65
74
|
end
|
@@ -24,13 +24,13 @@ module Motion
|
|
24
24
|
timing("Connected") do
|
25
25
|
@render_hash = component.render_hash
|
26
26
|
|
27
|
-
component.
|
27
|
+
component.process_connect
|
28
28
|
end
|
29
29
|
end
|
30
30
|
|
31
31
|
def close
|
32
32
|
timing("Disconnected") do
|
33
|
-
component.
|
33
|
+
component.process_disconnect
|
34
34
|
end
|
35
35
|
|
36
36
|
true
|
@@ -64,6 +64,18 @@ module Motion
|
|
64
64
|
false
|
65
65
|
end
|
66
66
|
|
67
|
+
def process_periodic_timer(timer)
|
68
|
+
timing("Proccessed periodic timer #{timer}") do
|
69
|
+
component.process_periodic_timer timer
|
70
|
+
end
|
71
|
+
|
72
|
+
true
|
73
|
+
rescue => error
|
74
|
+
handle_error(error, "processing periodic timer #{timer}")
|
75
|
+
|
76
|
+
false
|
77
|
+
end
|
78
|
+
|
67
79
|
def if_render_required(&block)
|
68
80
|
timing("Rendered") do
|
69
81
|
next_render_hash = component.render_hash
|
@@ -83,6 +95,10 @@ module Motion
|
|
83
95
|
component.broadcasts
|
84
96
|
end
|
85
97
|
|
98
|
+
def periodic_timers
|
99
|
+
component.periodic_timers
|
100
|
+
end
|
101
|
+
|
86
102
|
private
|
87
103
|
|
88
104
|
attr_reader :log_helper
|
data/lib/motion/configuration.rb
CHANGED
@@ -59,18 +59,23 @@ module Motion
|
|
59
59
|
Rails.application.key_generator.generate_key("motion:secret")
|
60
60
|
end
|
61
61
|
|
62
|
+
option :revision_paths do
|
63
|
+
require "rails"
|
64
|
+
|
65
|
+
Rails.application.config.paths.dup.tap do |paths|
|
66
|
+
paths.add "bin", glob: "*"
|
67
|
+
paths.add "Gemfile.lock"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
62
71
|
option :revision do
|
63
|
-
|
64
|
-
Motion is automatically inferring the application's revision from git.
|
65
|
-
Depending on your deployment, this may not work for you in production.
|
66
|
-
If it does, add "config.revision = `git rev-parse HEAD`.chomp" to your
|
67
|
-
Motion initializer. If it does not, do something else (probably read an
|
68
|
-
env var or something).
|
69
|
-
MSG
|
70
|
-
|
71
|
-
`git rev-parse HEAD`.chomp
|
72
|
+
RevisionCalculator.new(revision_paths: revision_paths).perform
|
72
73
|
end
|
73
74
|
|
75
|
+
# TODO: Is this always the correct key?
|
76
|
+
WARDEN_ENV = "warden"
|
77
|
+
private_constant :WARDEN_ENV
|
78
|
+
|
74
79
|
option :renderer_for_connection_proc do
|
75
80
|
->(websocket_connection) do
|
76
81
|
require "rack"
|
@@ -89,13 +94,15 @@ module Motion
|
|
89
94
|
controller.renderer.new(
|
90
95
|
websocket_connection.env.slice(
|
91
96
|
Rack::HTTP_COOKIE,
|
92
|
-
Rack::RACK_SESSION
|
97
|
+
Rack::RACK_SESSION,
|
98
|
+
WARDEN_ENV
|
93
99
|
)
|
94
100
|
)
|
95
101
|
end
|
96
102
|
end
|
97
103
|
|
98
|
-
option(:
|
104
|
+
option(:error_notification_proc) { nil }
|
105
|
+
|
99
106
|
option(:key_attribute) { "data-motion-key" }
|
100
107
|
option(:state_attribute) { "data-motion-state" }
|
101
108
|
|
data/lib/motion/errors.rb
CHANGED
@@ -10,6 +10,7 @@ module Motion
|
|
10
10
|
|
11
11
|
def initialize(component, message = nil)
|
12
12
|
super(message)
|
13
|
+
|
13
14
|
@component = component
|
14
15
|
end
|
15
16
|
end
|
@@ -20,13 +21,13 @@ module Motion
|
|
20
21
|
attr_reader :motion
|
21
22
|
|
22
23
|
def initialize(component, motion)
|
23
|
-
super(
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
map_motion :#{motion}
|
29
|
-
|
24
|
+
super(
|
25
|
+
component,
|
26
|
+
"No component motion handler mapped for motion `#{motion}` in " \
|
27
|
+
"component `#{component.class}`.\n" \
|
28
|
+
"\n" \
|
29
|
+
"Hint: Consider adding `map_motion :#{motion}` to `#{component.class}`."
|
30
|
+
)
|
30
31
|
|
31
32
|
@motion = motion
|
32
33
|
end
|
@@ -34,20 +35,32 @@ module Motion
|
|
34
35
|
|
35
36
|
class BlockNotAllowedError < ComponentRenderingError
|
36
37
|
def initialize(component)
|
37
|
-
super(
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
38
|
+
super(
|
39
|
+
component,
|
40
|
+
"Motion does not support rendering with a block.\n" \
|
41
|
+
"\n" \
|
42
|
+
"Hint: Try wrapping a plain component with a motion component."
|
43
|
+
)
|
42
44
|
end
|
43
45
|
end
|
44
46
|
|
45
47
|
class MultipleRootsError < ComponentRenderingError
|
46
48
|
def initialize(component)
|
47
|
-
super(
|
48
|
-
|
49
|
+
super(
|
50
|
+
component,
|
51
|
+
"The template for #{component.class} can only have one root " \
|
52
|
+
"element.\n" \
|
53
|
+
"\n" \
|
54
|
+
"Hint: Wrap all elements in a single element, such as `<div>` or " \
|
55
|
+
"`<section>`."
|
56
|
+
)
|
57
|
+
end
|
58
|
+
end
|
49
59
|
|
50
|
-
|
60
|
+
class RenderAborted < ComponentRenderingError
|
61
|
+
def initialize(component)
|
62
|
+
super(component, <<~MSG)
|
63
|
+
Rendering #{component.class} was aborted by a callback.
|
51
64
|
MSG
|
52
65
|
end
|
53
66
|
end
|
@@ -56,18 +69,19 @@ module Motion
|
|
56
69
|
|
57
70
|
class UnrepresentableStateError < InvalidComponentStateError
|
58
71
|
def initialize(component, cause)
|
59
|
-
super(
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
more
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
72
|
+
super(
|
73
|
+
component,
|
74
|
+
"Some state prevented `#{component.class}` from being serialized " \
|
75
|
+
"into a string. Motion components must be serializable using " \
|
76
|
+
"`Marshal.dump`. Many types of objects are not serializable " \
|
77
|
+
"including procs, references to anonymous classes, and more. See the " \
|
78
|
+
"documentation for `Marshal.dump` for more information.\n" \
|
79
|
+
"\n" \
|
80
|
+
"The specific error from `Marshal.dump` was: #{cause}\n" \
|
81
|
+
"\n" \
|
82
|
+
"Hint: Ensure that any exotic state variables in " \
|
83
|
+
"`#{component.class}` are removed or replaced."
|
84
|
+
)
|
71
85
|
end
|
72
86
|
end
|
73
87
|
|
@@ -75,57 +89,64 @@ module Motion
|
|
75
89
|
|
76
90
|
class InvalidSerializedStateError < SerializedComponentError
|
77
91
|
def initialize
|
78
|
-
super(
|
79
|
-
The serialized state of your component is not valid
|
80
|
-
|
81
|
-
|
82
|
-
|
92
|
+
super(
|
93
|
+
"The serialized state of your component is not valid.\n" \
|
94
|
+
"\n" \
|
95
|
+
"Hint: Ensure that you have not tampered with the contents of data " \
|
96
|
+
"attributes added by Motion in the DOM or changed the value of " \
|
97
|
+
"`Motion.config.secret`."
|
98
|
+
)
|
83
99
|
end
|
84
100
|
end
|
85
101
|
|
86
|
-
class
|
87
|
-
attr_reader :
|
88
|
-
:
|
89
|
-
|
90
|
-
def initialize(
|
91
|
-
super(
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
102
|
+
class UpgradeNotImplementedError < ComponentError
|
103
|
+
attr_reader :previous_revision,
|
104
|
+
:current_revision
|
105
|
+
|
106
|
+
def initialize(component, previous_revision, current_revision)
|
107
|
+
super(
|
108
|
+
component,
|
109
|
+
"Cannot upgrade `#{component.class}` from a previous revision of the " \
|
110
|
+
"application (#{previous_revision}) to the current revision of the " \
|
111
|
+
"application (#{current_revision})\n" \
|
112
|
+
"\n" \
|
113
|
+
"By default, Motion does not allow components from other revisions " \
|
114
|
+
"of the application to be mounted because new code with old state " \
|
115
|
+
"can lead to unpredictable and unsafe behavior.\n" \
|
116
|
+
"\n" \
|
117
|
+
"Hint: If you would like to allow this component to surive " \
|
118
|
+
"deployments, consider providing an alternative implimentation for " \
|
119
|
+
"`#{component.class}.upgrade_from`."
|
120
|
+
)
|
121
|
+
|
122
|
+
@previous_revision = previous_revision
|
123
|
+
@current_revision = current_revision
|
106
124
|
end
|
107
125
|
end
|
108
126
|
|
109
127
|
class AlreadyConfiguredError < Error
|
110
128
|
def initialize
|
111
|
-
super(
|
112
|
-
Motion is already configured
|
113
|
-
|
114
|
-
|
115
|
-
|
129
|
+
super(
|
130
|
+
"Motion is already configured.\n" \
|
131
|
+
"\n" \
|
132
|
+
"Hint: Move all Motion config to `config/initializers/motion.rb`."
|
133
|
+
)
|
116
134
|
end
|
117
135
|
end
|
118
136
|
|
119
137
|
class IncompatibleClientError < Error
|
120
|
-
attr_reader :
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
138
|
+
attr_reader :server_version, :client_version
|
139
|
+
|
140
|
+
def initialize(server_version, client_version)
|
141
|
+
super(
|
142
|
+
"The client version (#{client_version}) is newer than the server " \
|
143
|
+
"version (#{server_version}). Please upgrade the Motion gem.\n" \
|
144
|
+
"\n" \
|
145
|
+
"Hint: Run `bundle add motion --version \">= #{client_version}\"`."
|
146
|
+
)
|
147
|
+
|
148
|
+
@server_version = server_version
|
149
|
+
@client_version = client_version
|
129
150
|
end
|
130
151
|
end
|
131
152
|
|
@@ -133,16 +154,26 @@ module Motion
|
|
133
154
|
attr_reader :minimum_bytes
|
134
155
|
|
135
156
|
def initialize(minimum_bytes)
|
136
|
-
super(
|
137
|
-
The secret that you provided is not long enough. It must
|
138
|
-
#{minimum_bytes} bytes.
|
139
|
-
|
157
|
+
super(
|
158
|
+
"The secret that you provided is not long enough. It must be at " \
|
159
|
+
"least #{minimum_bytes} bytes long."
|
160
|
+
)
|
140
161
|
end
|
141
162
|
end
|
142
163
|
|
143
164
|
class BadRevisionError < Error
|
144
165
|
def initialize
|
145
|
-
super("The revision cannot contain a NULL byte")
|
166
|
+
super("The revision cannot contain a NULL byte.")
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
class BadRevisionPathsError < Error
|
171
|
+
def initialize
|
172
|
+
super(
|
173
|
+
"Revision paths must be a `Rails::Paths::Root` object or an object " \
|
174
|
+
"that responds to `all_paths.flat_map(&:existent)` and returns an " \
|
175
|
+
"Array of strings representing full paths."
|
176
|
+
)
|
146
177
|
end
|
147
178
|
end
|
148
179
|
end
|
data/lib/motion/event.rb
CHANGED
@@ -34,8 +34,16 @@ module Motion
|
|
34
34
|
@target = Motion::Element.from_raw(raw["target"])
|
35
35
|
end
|
36
36
|
|
37
|
+
def current_target
|
38
|
+
return @current_target if defined?(@current_target)
|
39
|
+
|
40
|
+
@current_target = Motion::Element.from_raw(raw["currentTarget"])
|
41
|
+
end
|
42
|
+
|
43
|
+
alias element current_target
|
44
|
+
|
37
45
|
def form_data
|
38
|
-
|
46
|
+
element&.form_data
|
39
47
|
end
|
40
48
|
end
|
41
49
|
end
|
data/lib/motion/log_helper.rb
CHANGED
@@ -1,41 +1,32 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "nokogiri"
|
4
|
+
require "active_support/core_ext/object/blank"
|
4
5
|
|
5
6
|
require "motion"
|
6
7
|
|
7
8
|
module Motion
|
8
9
|
class MarkupTransformer
|
9
|
-
STIMULUS_CONTROLLER_ATTRIBUTE = "data-controller"
|
10
|
-
|
11
10
|
attr_reader :serializer,
|
12
|
-
:stimulus_controller_identifier,
|
13
11
|
:key_attribute,
|
14
12
|
:state_attribute
|
15
13
|
|
16
14
|
def initialize(
|
17
15
|
serializer: Motion.serializer,
|
18
|
-
stimulus_controller_identifier:
|
19
|
-
Motion.config.stimulus_controller_identifier,
|
20
16
|
key_attribute: Motion.config.key_attribute,
|
21
17
|
state_attribute: Motion.config.state_attribute
|
22
18
|
)
|
23
19
|
@serializer = serializer
|
24
|
-
@stimulus_controller_identifier = stimulus_controller_identifier
|
25
20
|
@key_attribute = key_attribute
|
26
21
|
@state_attribute = state_attribute
|
27
22
|
end
|
28
23
|
|
29
24
|
def add_state_to_html(component, html)
|
25
|
+
return if html.blank?
|
26
|
+
|
30
27
|
key, state = serializer.serialize(component)
|
31
28
|
|
32
29
|
transform_root(component, html) do |root|
|
33
|
-
root[STIMULUS_CONTROLLER_ATTRIBUTE] =
|
34
|
-
values(
|
35
|
-
stimulus_controller_identifier,
|
36
|
-
root[STIMULUS_CONTROLLER_ATTRIBUTE]
|
37
|
-
)
|
38
|
-
|
39
30
|
root[key_attribute] = key
|
40
31
|
root[state_attribute] = state
|
41
32
|
end
|
@@ -47,19 +38,13 @@ module Motion
|
|
47
38
|
fragment = Nokogiri::HTML::DocumentFragment.parse(html)
|
48
39
|
root, *unexpected_others = fragment.children
|
49
40
|
|
50
|
-
|
41
|
+
if !root || unexpected_others.any?(&:present?)
|
42
|
+
raise MultipleRootsError, component
|
43
|
+
end
|
51
44
|
|
52
45
|
yield root
|
53
46
|
|
54
47
|
fragment.to_html.html_safe
|
55
48
|
end
|
56
|
-
|
57
|
-
def values(*values, delimiter: " ")
|
58
|
-
values
|
59
|
-
.compact
|
60
|
-
.flat_map { |value| value.split(delimiter) }
|
61
|
-
.uniq
|
62
|
-
.join(delimiter)
|
63
|
-
end
|
64
49
|
end
|
65
50
|
end
|