motion 0.2.1 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63f815dc74ec8255bd6b7b07fae885691253e7903397ec2b6f1e79cc675c7261
4
- data.tar.gz: 758239f08e068912a6e213773c0cc3d86338ecec6f738650f19f1a9d83656995
3
+ metadata.gz: 00d428a43d143191e5faba15d7b7aa25376bf375820eacc283c0aeb4148653e1
4
+ data.tar.gz: 3471947a7f72af5df84b13a2d995998201102c8174fd7a7dfee6a27a08a86a79
5
5
  SHA512:
6
- metadata.gz: dfc01de3b79448af437bc4a0386ab2a9800e75ecb7d0231dc565d2bb6e3a21699fc9d7e6b58932824f0dc9ced1c59ae4426e40b9486198f7d082dcf0a5d7ee68
7
- data.tar.gz: 86223b046b0957a69d4d7301a0a6c5d85a51cdacd4b088eaa4b65fdd57643043bde5970aaa5de55fd093257b66f5cd2e06c320b891c02f7ec255ba1b3d7a824b
6
+ metadata.gz: ea47401782fe8331ee73c8201263de1ad75853842d14c57f68a416d821ae92416ac3c9bd5e6859bd6a10dc8785f8ecfe71a8e84417f02461afef3a8fda69ab0f
7
+ data.tar.gz: 8ce1dc79f7344bbf14b8537c806ff1b66e2dd6cfdddfc995925081edb2e7fdbc3ca10a9fdd5b04d9addbd78a8ba1333e7c324bb0a50644d71fbd515548d11f3b
@@ -17,7 +17,15 @@ export default createClient({
17
17
  // made available at `Motion::Event#extra_data`:
18
18
  //
19
19
  // getExtraDataForEvent(event) {},
20
+
21
+ // By default, the Motion client automatically disconnects all components when
22
+ // it detects the browser navigating away to a new page. This is done to
23
+ // prevent flashes of new content in components with broadcasts because of
24
+ // some action being taken by the controller that the user is navigating to
25
+ // (like submitting a form). If you do not want or need this functionally, you
26
+ // can turn it off:
20
27
  //
28
+ // shutdownBeforeUnload: false,
21
29
 
22
30
  // The data attributes used by Motion can be customized, but these values must
23
31
  // also be updated in the Ruby initializer:
@@ -25,6 +33,5 @@ export default createClient({
25
33
  // keyAttribute: "data-motion-key",
26
34
  // stateAttribute: "data-motion-state",
27
35
  // motionAttribute: "data-motion",
28
- //
29
36
 
30
37
  });
@@ -5,13 +5,16 @@ Motion.configure do |config|
5
5
  # version of your application. By default, the commit hash from git is used,
6
6
  # but depending on your deployment, this may not be available in production.
7
7
  #
8
- # If you are sure that git is available in your production enviorment, you can
9
- # uncomment this line:
8
+ # Motion automatically calculates your revision by hashing the contents of
9
+ # files in `revision_paths` The defaults revision paths are:
10
+ # rails paths, bin, and Gemfile.lock.
10
11
  #
11
- # config.revision = `git rev-parse HEAD`.chomp
12
+ # To change or add to your revision paths, uncomment this line:
12
13
  #
13
- # If git is not available in your production enviorment, you must identify
14
- # your application version some other way:
14
+ # config.revision_paths += w(additional_path another_path)
15
+ #
16
+ # If you prefer to use git or an environmental variable for the revision
17
+ # in production, define the revision directly below.
15
18
  #
16
19
  # config.revision =
17
20
  # ENV.fetch("MY_DEPLOYMENT_NUMBER") { `git rev-parse HEAD`.chomp }
@@ -27,14 +30,29 @@ Motion.configure do |config|
27
30
  # config.renderer_for_connection_proc = ->(websocket_connection) do
28
31
  # ApplicationController.renderer.new(
29
32
  # websocket_connection.env.slice(
30
- # Rack::HTTP_COOKIE,
31
- # Rack::RACK_SESSION,
33
+ # Rack::HTTP_COOKIE, # Cookies
34
+ # Rack::RACK_SESSION, # Session
35
+ # 'warden' # Warden (needed for `current_user` in Devise)
32
36
  # )
33
37
  # )
34
38
  # end
35
39
 
40
+ # This proc will be invoked by Motion when an unhandled error occurs. By
41
+ # default, an error is logged to the application's default logger but no
42
+ # additional action is taken. If you are using an error tracking tool like
43
+ # Bugsnag, Sentry, Honeybadger, or Rollbar, you can provide a proc which
44
+ # notifies that as well:
45
+ #
46
+ # config.error_notification_proc = ->(error, message) do
47
+ # Bugsnag.notify(error) do |report|
48
+ # report.add_tab(:motion, {
49
+ # message: message
50
+ # })
51
+ # end
52
+ # end
53
+
36
54
  # The data attributes used by Motion can be customized, but these values must
37
- # also be updated in the Ruby initializer:
55
+ # also be updated in the JavaScript client configuration:
38
56
  #
39
57
  # config.key_attribute = "data-motion-key"
40
58
  # config.state_attribute = "data-motion-state"
@@ -5,6 +5,7 @@ require "motion/errors"
5
5
 
6
6
  module Motion
7
7
  autoload :ActionCableExtentions, "motion/action_cable_extentions"
8
+ autoload :Callback, "motion/callback"
8
9
  autoload :Channel, "motion/channel"
9
10
  autoload :Component, "motion/component"
10
11
  autoload :ComponentConnection, "motion/component_connection"
@@ -14,6 +15,7 @@ module Motion
14
15
  autoload :LogHelper, "motion/log_helper"
15
16
  autoload :MarkupTransformer, "motion/markup_transformer"
16
17
  autoload :Railtie, "motion/railtie"
18
+ autoload :RevisionCalculator, "motion/revision_calculator"
17
19
  autoload :Serializer, "motion/serializer"
18
20
  autoload :TestHelpers, "motion/test_helpers"
19
21
 
@@ -41,6 +43,10 @@ module Motion
41
43
  config.renderer_for_connection_proc.call(websocket_connection)
42
44
  end
43
45
 
46
+ def self.notify_error(error, message)
47
+ config.error_notification_proc&.call(error, message)
48
+ end
49
+
44
50
  # This method only exists for testing. Changing configuration while Motion is
45
51
  # in use is not supported. It is only safe to call this method when no
46
52
  # components are currently mounted.
@@ -62,7 +62,7 @@ module Motion
62
62
  #
63
63
  # See `ActionCable::Channel::PeriodicTimers` for details.
64
64
  def _setup_declarative_notifcation_timer(notification, interval)
65
- return if connection.is_a?(ActionCable::Channel::ConnectionStub) ||
65
+ return if _stubbed_connection? ||
66
66
  @_declarative_notifications_timers.include?(notification)
67
67
 
68
68
  callback = proc do
@@ -77,6 +77,11 @@ module Motion
77
77
  active_periodic_timers << timer
78
78
  end
79
79
 
80
+ def _stubbed_connection?
81
+ defined?(ActionCable::Channel::ConnectionStub) &&
82
+ connection.is_a?(ActionCable::Channel::ConnectionStub)
83
+ end
84
+
80
85
  def _shutdown_declarative_notifcation_timer(notification, *)
81
86
  timer = @_declarative_notifications_timers.delete(notification)
82
87
  return unless timer
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "motion"
4
+
5
+ module Motion
6
+ class Callback
7
+ attr_reader :broadcast
8
+
9
+ NAMESPACE = "motion:callback"
10
+ private_constant :NAMESPACE
11
+
12
+ def self.broadcast_for(component, method)
13
+ [
14
+ NAMESPACE,
15
+ component.stable_instance_identifier_for_callbacks,
16
+ method
17
+ ].join(":")
18
+ end
19
+
20
+ def initialize(component, method)
21
+ @broadcast = self.class.broadcast_for(component, method)
22
+
23
+ component.stream_from(broadcast, method)
24
+ end
25
+
26
+ def ==(other)
27
+ other.is_a?(Callback) &&
28
+ other.broadcast == broadcast
29
+ end
30
+
31
+ def call(message = nil)
32
+ ActionCable.server.broadcast(broadcast, message)
33
+ end
34
+ end
35
+ end
@@ -65,15 +65,15 @@ module Motion
65
65
  private
66
66
 
67
67
  def synchronize
68
+ component_connection.if_render_required do |component|
69
+ transmit(renderer.render(component))
70
+ end
71
+
68
72
  streaming_from component_connection.broadcasts,
69
73
  to: :process_broadcast
70
74
 
71
75
  periodically_notify component_connection.periodic_timers,
72
76
  via: :process_periodic_timer
73
-
74
- component_connection.if_render_required do |component|
75
- transmit(renderer.render(component))
76
- end
77
77
  end
78
78
 
79
79
  def handle_error(error, context)
@@ -5,6 +5,7 @@ require "active_support/concern"
5
5
  require "motion"
6
6
 
7
7
  require "motion/component/broadcasts"
8
+ require "motion/component/callbacks"
8
9
  require "motion/component/lifecycle"
9
10
  require "motion/component/motions"
10
11
  require "motion/component/periodic_timers"
@@ -15,6 +16,7 @@ module Motion
15
16
  extend ActiveSupport::Concern
16
17
 
17
18
  include Broadcasts
19
+ include Callbacks
18
20
  include Lifecycle
19
21
  include Motions
20
22
  include PeriodicTimers
@@ -75,10 +75,12 @@ module Motion
75
75
  def process_broadcast(broadcast, message)
76
76
  return unless (handler = _broadcast_handlers[broadcast])
77
77
 
78
- if method(handler).arity.zero?
79
- send(handler)
80
- else
81
- send(handler, message)
78
+ _run_action_callbacks(context: handler) do
79
+ if method(handler).arity.zero?
80
+ send(handler)
81
+ else
82
+ send(handler, message)
83
+ end
82
84
  end
83
85
  end
84
86
 
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require "motion"
6
+
7
+ module Motion
8
+ module Component
9
+ module Callbacks
10
+ def bind(method)
11
+ Callback.new(self, method)
12
+ end
13
+
14
+ def stable_instance_identifier_for_callbacks
15
+ @_stable_instance_identifier_for_callbacks ||= SecureRandom.uuid
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/callbacks"
3
4
  require "active_support/concern"
5
+ require "active_support/deprecation"
4
6
 
5
7
  require "motion"
6
8
 
@@ -9,22 +11,102 @@ module Motion
9
11
  module Lifecycle
10
12
  extend ActiveSupport::Concern
11
13
 
14
+ include ActiveSupport::Callbacks
15
+
16
+ included do
17
+ define_callbacks :action, :connect, :disconnect
18
+
19
+ # The built-in triggers defined on the target class will override ours.
20
+ remove_method(:_run_action_callbacks)
21
+ end
22
+
12
23
  class_methods do
13
- # TODO: "IncorrectRevisionError" doesn't make sense for this anymore.
14
- # It should probably be something like "CannotUpgrade" and the error
15
- # message should focus on how to handle deployments gracefully.
16
- def upgrade_from(previous_revision, _instance)
17
- raise IncorrectRevisionError.new(
18
- Motion.config.revision,
19
- previous_revision
24
+ def upgrade_from(previous_revision, instance)
25
+ raise UpgradeNotImplementedError.new(
26
+ instance,
27
+ previous_revision,
28
+ Motion.config.revision
20
29
  )
21
30
  end
31
+
32
+ def before_action(*methods, **options, &block)
33
+ set_action_callback(:before, *methods, **options, &block)
34
+ end
35
+
36
+ def around_action(*methods, **options, &block)
37
+ set_action_callback(:around, *methods, **options, &block)
38
+ end
39
+
40
+ def after_action(*methods, **options, &block)
41
+ set_action_callback(:after, *methods, **options, &block)
42
+ end
43
+
44
+ def after_connect(*methods, **options, &block)
45
+ set_callback(:connect, :after, *methods, **options, &block)
46
+ end
47
+
48
+ def after_disconnect(*methods, **options, &block)
49
+ set_callback(:disconnect, :after, *methods, **options, &block)
50
+ end
51
+
52
+ private
53
+
54
+ def set_action_callback(kind, *methods, **options, &block)
55
+ filters = Array(options.delete(:if))
56
+
57
+ if (only = Array(options.delete(:only))).any?
58
+ filters << action_callback_context_filter(only)
59
+ end
60
+
61
+ if (except = Array(options.delete(:except))).any?
62
+ filters << action_callback_context_filter(except, invert: true)
63
+ end
64
+
65
+ set_callback(:action, kind, *methods, if: filters, **options, &block)
66
+ end
67
+
68
+ def action_callback_context_filter(contexts, invert: false)
69
+ proc { contexts.include?(@_action_callback_context) ^ invert }
70
+ end
71
+ end
72
+
73
+ def process_connect
74
+ _run_connect_callbacks
75
+
76
+ # TODO: Remove at next minor release
77
+ if respond_to?(:connected)
78
+ ActiveSupport::Deprecation.warn(
79
+ "The `connected` lifecycle method is being replaced by the " \
80
+ "`after_connect` callback and will no longer be automatically " \
81
+ "invoked in the next **minor release** of Motion."
82
+ )
83
+
84
+ send(:connected)
85
+ end
22
86
  end
23
87
 
24
- def connected
88
+ def process_disconnect
89
+ _run_disconnect_callbacks
90
+
91
+ # TODO: Remove at next minor release
92
+ if respond_to?(:disconnected)
93
+ ActiveSupport::Deprecation.warn(
94
+ "The `disconnected` lifecycle method is being replaced by the " \
95
+ "`after_disconnect` callback and will no longer be automatically " \
96
+ "invoked in the next **minor release** of Motion."
97
+ )
98
+
99
+ send(:disconnected)
100
+ end
25
101
  end
26
102
 
27
- def disconnected
103
+ def _run_action_callbacks(context:, &block)
104
+ @_action_callback_context = context
105
+
106
+ run_callbacks(:action, &block)
107
+ ensure
108
+ # `@_action_callback_context = nil` would still appear in the state
109
+ remove_instance_variable(:@_action_callback_context)
28
110
  end
29
111
  end
30
112
  end
@@ -47,10 +47,12 @@ module Motion
47
47
  raise MotionNotMapped.new(self, motion)
48
48
  end
49
49
 
50
- if method(handler).arity.zero?
51
- send(handler)
52
- else
53
- send(handler, event)
50
+ _run_action_callbacks(context: handler) do
51
+ if method(handler).arity.zero?
52
+ send(handler)
53
+ else
54
+ send(handler, event)
55
+ end
54
56
  end
55
57
  end
56
58
 
@@ -49,7 +49,9 @@ module Motion
49
49
  def process_periodic_timer(name)
50
50
  return unless (handler, _interval = _periodic_timers[name])
51
51
 
52
- send(handler)
52
+ _run_action_callbacks(context: handler) do
53
+ send(handler)
54
+ end
53
55
  end
54
56
 
55
57
  private
@@ -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
@@ -29,28 +38,37 @@ module Motion
29
38
 
30
39
  def render_in(view_context)
31
40
  raise BlockNotAllowedError, self if block_given?
32
- clear_awaiting_forced_rerender!
33
41
 
34
- 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
35
50
 
36
51
  Motion.markup_transformer.add_state_to_html(self, html)
37
52
  end
38
53
 
39
54
  private
40
55
 
41
- def clear_awaiting_forced_rerender!
56
+ def _clear_awaiting_forced_rerender!
42
57
  return unless awaiting_forced_rerender?
43
58
 
44
59
  remove_instance_variable(RERENDER_MARKER_IVAR)
45
60
  end
46
61
 
47
- def without_new_instance_variables
62
+ def _without_new_instance_variables
48
63
  existing_instance_variables = instance_variables
49
64
 
50
65
  yield
51
66
  ensure
52
- (instance_variables - existing_instance_variables)
53
- .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))
54
72
  end
55
73
  end
56
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
@@ -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,12 +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
 
104
+ option(:error_notification_proc) { nil }
105
+
98
106
  option(:key_attribute) { "data-motion-key" }
99
107
  option(:state_attribute) { "data-motion-state" }
100
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,44 +89,48 @@ 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
 
@@ -120,12 +138,12 @@ module Motion
120
138
  attr_reader :server_version, :client_version
121
139
 
122
140
  def initialize(server_version, client_version)
123
- super(<<~MSG)
124
- The client version (#{client_version}) is newer than the server version
125
- (#{server_version}). Please upgrade the Motion gem.
126
-
127
- Fix: Run `bundle update motion`
128
- MSG
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
+ )
129
147
 
130
148
  @server_version = server_version
131
149
  @client_version = client_version
@@ -136,16 +154,26 @@ module Motion
136
154
  attr_reader :minimum_bytes
137
155
 
138
156
  def initialize(minimum_bytes)
139
- super(<<~MSG)
140
- The secret that you provided is not long enough. It must have at least
141
- #{minimum_bytes} bytes.
142
- MSG
157
+ super(
158
+ "The secret that you provided is not long enough. It must be at " \
159
+ "least #{minimum_bytes} bytes long."
160
+ )
143
161
  end
144
162
  end
145
163
 
146
164
  class BadRevisionError < Error
147
165
  def initialize
148
- 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
+ )
149
177
  end
150
178
  end
151
179
  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,6 +1,7 @@
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
 
@@ -21,7 +22,7 @@ module Motion
21
22
  end
22
23
 
23
24
  def add_state_to_html(component, html)
24
- return html if html.blank?
25
+ return if html.blank?
25
26
 
26
27
  key, state = serializer.serialize(component)
27
28
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "motion"
5
+
6
+ module Motion
7
+ class RevisionCalculator
8
+ attr_reader :revision_paths
9
+
10
+ def initialize(revision_paths:)
11
+ @revision_paths = revision_paths
12
+ end
13
+
14
+ def perform
15
+ derive_file_hash
16
+ end
17
+
18
+ private
19
+
20
+ def derive_file_hash
21
+ digest = Digest::MD5.new
22
+
23
+ files.each do |file|
24
+ digest << file # include filename as well as contents
25
+ digest << File.read(file)
26
+ end
27
+
28
+ digest.hexdigest
29
+ end
30
+
31
+ def existent_paths
32
+ @existent_paths ||=
33
+ begin
34
+ revision_paths.all_paths.flat_map(&:existent)
35
+ rescue
36
+ raise BadRevisionPathsError
37
+ end
38
+ end
39
+
40
+ def existent_files(path)
41
+ Dir["#{path}/**/*", path].reject { |f| File.directory?(f) }.uniq
42
+ end
43
+
44
+ def files
45
+ @files ||= existent_paths.flat_map { |path| existent_files(path) }.sort
46
+ end
47
+ end
48
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "lz4-ruby"
4
5
  require "active_support/message_encryptor"
5
6
 
6
7
  require "motion"
@@ -37,7 +38,7 @@ module Motion
37
38
  end
38
39
 
39
40
  def serialize(component)
40
- state = dump(component)
41
+ state = deflate(dump(component))
41
42
  state_with_revision = "#{revision}#{NULL_BYTE}#{state}"
42
43
 
43
44
  [
@@ -49,7 +50,7 @@ module Motion
49
50
  def deserialize(serialized_component)
50
51
  state_with_revision = decrypt_and_verify(serialized_component)
51
52
  serialized_revision, state = state_with_revision.split(NULL_BYTE, 2)
52
- component = load(state)
53
+ component = load(inflate(state))
53
54
 
54
55
  if revision == serialized_revision
55
56
  component
@@ -70,6 +71,14 @@ module Motion
70
71
  Marshal.load(state)
71
72
  end
72
73
 
74
+ def deflate(dumped_component)
75
+ LZ4.compress(dumped_component)
76
+ end
77
+
78
+ def inflate(deflated_state)
79
+ LZ4.uncompress(deflated_state)
80
+ end
81
+
73
82
  def encrypt_and_sign(cleartext)
74
83
  encryptor.encrypt_and_sign(cleartext)
75
84
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Motion
4
- VERSION = "0.2.1"
4
+ VERSION = "0.4.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: motion
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alec Larsen
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2020-07-03 00:00:00.000000000 Z
12
+ date: 2020-09-02 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: nokogiri
@@ -39,6 +39,20 @@ dependencies:
39
39
  - - ">="
40
40
  - !ruby/object:Gem::Version
41
41
  version: '5.2'
42
+ - !ruby/object:Gem::Dependency
43
+ name: lz4-ruby
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 0.3.3
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 0.3.3
42
56
  description: |
43
57
  Motion extends Github's `view_component` to allow you to build reactive,
44
58
  real-time frontend UI components in your Rails application using pure Ruby.
@@ -59,9 +73,11 @@ files:
59
73
  - lib/motion/action_cable_extentions/declarative_streams.rb
60
74
  - lib/motion/action_cable_extentions/log_suppression.rb
61
75
  - lib/motion/action_cable_extentions/synchronization.rb
76
+ - lib/motion/callback.rb
62
77
  - lib/motion/channel.rb
63
78
  - lib/motion/component.rb
64
79
  - lib/motion/component/broadcasts.rb
80
+ - lib/motion/component/callbacks.rb
65
81
  - lib/motion/component/lifecycle.rb
66
82
  - lib/motion/component/motions.rb
67
83
  - lib/motion/component/periodic_timers.rb
@@ -74,6 +90,7 @@ files:
74
90
  - lib/motion/log_helper.rb
75
91
  - lib/motion/markup_transformer.rb
76
92
  - lib/motion/railtie.rb
93
+ - lib/motion/revision_calculator.rb
77
94
  - lib/motion/serializer.rb
78
95
  - lib/motion/test_helpers.rb
79
96
  - lib/motion/version.rb
@@ -85,7 +102,7 @@ metadata:
85
102
  source_code_uri: https://github.com/unabridged/motion
86
103
  post_install_message: |
87
104
  Friendly reminder: When updating the motion gem, don't forget to update the
88
- NPM package as well (`bin/yarn add '@unabridged/motion@0.2.1'`).
105
+ NPM package as well (`bin/yarn add '@unabridged/motion@0.4.2'`).
89
106
  rdoc_options: []
90
107
  require_paths:
91
108
  - lib