motion 0.1.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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