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.
@@ -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
- # TODO: This implementation is trivially correct, but very wasteful.
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 = view_context.capture { without_new_instance_variables { super } }
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 clear_awaiting_forced_rerender!
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 without_new_instance_variables
62
+ def _without_new_instance_variables
57
63
  existing_instance_variables = instance_variables
58
64
 
59
65
  yield
60
66
  ensure
61
- (instance_variables - existing_instance_variables)
62
- .each(&method(:remove_instance_variable))
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.connected
27
+ component.process_connect
28
28
  end
29
29
  end
30
30
 
31
31
  def close
32
32
  timing("Disconnected") do
33
- component.disconnected
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
@@ -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
- warn <<~MSG # TODO: Better message (Focus on "How do I fix this?")
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(:stimulus_controller_identifier) { "motion" }
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
 
@@ -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(component, <<~MSG)
24
- No component motion handler mapped for motion '#{motion}' in component #{component.class}.
25
-
26
- Fix: Add the following to #{component.class}:
27
-
28
- map_motion :#{motion}
29
- MSG
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(component, <<~MSG)
38
- Motion does not support rendering with a block.
39
-
40
- Fix: Use a plain component and wrap with a motion component.
41
- MSG
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(component, <<~MSG)
48
- The template for #{component.class} can only have one root element.
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
- Fix: Wrap all elements in a single element, such as <div> or <section>.
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(component, <<~MSG)
60
- Some state prevented #{component.class} from being serialized into a
61
- string. Motion components must be serializable using Marshal.dump. Many
62
- types of objects are not serializable including procs, references to
63
- anonymous classes, and more. See the documentation for Marshal.dump for
64
- more information.
65
-
66
- Fix: Ensure that any exotic state variables in #{component.class} are
67
- removed or replaced.
68
-
69
- The specific (but probably useless) error from Marshal was: #{cause}
70
- MSG
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(<<~MSG)
79
- The serialized state of your component is not valid.
80
-
81
- Fix: Ensure that you have not tampered with the DOM.
82
- MSG
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 IncorrectRevisionError < SerializedComponentError
87
- attr_reader :expected_revision,
88
- :actual_revision
89
-
90
- def initialize(expected_revision, actual_revision)
91
- super(<<~MSG)
92
- Cannot mount a component from another version of the application.
93
-
94
- Expected revision `#{expected_revision}`;
95
- Got `#{actual_revision}`
96
-
97
- Read more: https://github.com/unabridged/motion/wiki/IncorrectRevisionError
98
-
99
- Fix:
100
- * Avoid tampering with Motion DOM elements and data attributes (e.g. data-motion-state).
101
- * In production, enforce a page refresh for pages with Motion components on deploy.
102
- MSG
103
-
104
- @expected_revision = expected_revision
105
- @actual_revision = actual_revision
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(<<~MSG)
112
- Motion is already configured.
113
-
114
- Fix: Move all Motion config to config/initializers/motion.rb.
115
- MSG
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 :expected_version,
121
- :actual_version
122
-
123
- def initialize(expected_version, actual_version)
124
- super(<<~MSG)
125
- Expected client version #{expected_version}, but got #{actual_version}.
126
-
127
- Fix: Run `bin/yarn add @unabridged/motion@#{expected_version}`
128
- MSG
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(<<~MSG)
137
- The secret that you provided is not long enough. It must have at least
138
- #{minimum_bytes} bytes.
139
- MSG
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
@@ -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
- target&.form_data
46
+ element&.form_data
39
47
  end
40
48
  end
41
49
  end
@@ -29,6 +29,8 @@ module Motion
29
29
  error_info = error ? ":\n#{indent(format_exception(error))}" : ""
30
30
 
31
31
  logger.error("[#{tag}] #{message}#{error_info}")
32
+
33
+ Motion.notify_error(error, message)
32
34
  end
33
35
 
34
36
  def info(message)
@@ -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
- raise MultipleRootsError, component if unexpected_others.any?(&:present?)
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