motion 0.2.0 → 0.4.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.
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "active_support/concern"
4
4
  require "active_support/core_ext/class/attribute"
5
+ require "active_support/core_ext/hash/except"
5
6
 
6
7
  require "motion"
7
8
 
@@ -10,6 +11,23 @@ module Motion
10
11
  module Motions
11
12
  extend ActiveSupport::Concern
12
13
 
14
+ # Analogous to `module_function` (available on both class and instance)
15
+ module ModuleFunctions
16
+ def map_motion(motion, handler = motion)
17
+ self._motion_handlers =
18
+ _motion_handlers.merge(motion.to_s => handler.to_sym).freeze
19
+ end
20
+
21
+ def unmap_motion(motion)
22
+ self._motion_handlers =
23
+ _motion_handlers.except(motion.to_s).freeze
24
+ end
25
+
26
+ def motions
27
+ _motion_handlers.keys
28
+ end
29
+ end
30
+
13
31
  included do
14
32
  class_attribute :_motion_handlers,
15
33
  instance_reader: false,
@@ -19,33 +37,25 @@ module Motion
19
37
  end
20
38
 
21
39
  class_methods do
22
- def map_motion(motion, handler = motion)
23
- self._motion_handlers =
24
- _motion_handlers.merge(motion.to_s => handler.to_sym).freeze
25
- end
40
+ include ModuleFunctions
26
41
  end
27
42
 
28
- def motions
29
- _motion_handlers.keys
30
- end
43
+ include ModuleFunctions
31
44
 
32
45
  def process_motion(motion, event = nil)
33
46
  unless (handler = _motion_handlers[motion])
34
47
  raise MotionNotMapped.new(self, motion)
35
48
  end
36
49
 
37
- if method(handler).arity.zero?
38
- send(handler)
39
- else
40
- 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
41
56
  end
42
57
  end
43
58
 
44
- def map_motion(motion, handler = motion)
45
- self._motion_handlers =
46
- _motion_handlers.merge(motion.to_s => handler.to_sym).freeze
47
- end
48
-
49
59
  private
50
60
 
51
61
  attr_writer :_motion_handlers
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+ require "active_support/core_ext/hash/except"
6
+
7
+ require "motion"
8
+
9
+ module Motion
10
+ module Component
11
+ module PeriodicTimers
12
+ extend ActiveSupport::Concern
13
+
14
+ # Analogous to `module_function` (available on both class and instance)
15
+ module ModuleFunctions
16
+ def every(interval, handler, name: handler)
17
+ periodic_timer(name, handler, every: interval)
18
+ end
19
+
20
+ def periodic_timer(name, handler = name, every:)
21
+ self._periodic_timers =
22
+ _periodic_timers.merge(name.to_s => [handler.to_sym, every]).freeze
23
+ end
24
+
25
+ def stop_periodic_timer(name)
26
+ self._periodic_timers =
27
+ _periodic_timers.except(name.to_s).freeze
28
+ end
29
+
30
+ def periodic_timers
31
+ _periodic_timers.transform_values { |_handler, interval| interval }
32
+ end
33
+ end
34
+
35
+ included do
36
+ class_attribute :_periodic_timers,
37
+ instance_reader: false,
38
+ instance_writer: false,
39
+ instance_predicate: false,
40
+ default: {}.freeze
41
+ end
42
+
43
+ class_methods do
44
+ include ModuleFunctions
45
+ end
46
+
47
+ include ModuleFunctions
48
+
49
+ def process_periodic_timer(name)
50
+ return unless (handler, _interval = _periodic_timers[name])
51
+
52
+ _run_action_callbacks(context: handler) do
53
+ send(handler)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ attr_writer :_periodic_timers
60
+
61
+ def _periodic_timers
62
+ return @_periodic_timers if defined?(@_periodic_timers)
63
+
64
+ self.class._periodic_timers
65
+ end
66
+ end
67
+ end
68
+ end
@@ -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,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