turbo_reflex 0.0.10 → 0.0.11
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/Gemfile +2 -0
- data/Gemfile.lock +23 -18
- data/README.md +1 -1
- data/Rakefile +7 -1
- data/app/assets/builds/turbo_reflex.js +1 -1
- data/app/assets/builds/turbo_reflex.js.map +4 -4
- data/app/controllers/concerns/turbo_reflex/controller.rb +14 -0
- data/app/javascript/drivers/window.js +12 -4
- data/app/javascript/elements.js +1 -1
- data/app/javascript/events.js +30 -0
- data/app/javascript/index.js +10 -5
- data/app/javascript/lifecycle.js +2 -33
- data/app/javascript/logger.js +6 -10
- data/app/javascript/meta.js +0 -21
- data/app/javascript/state/index.js +52 -0
- data/app/javascript/state/observable.js +36 -0
- data/app/javascript/turbo.js +15 -4
- data/lib/turbo_reflex/base.rb +25 -21
- data/lib/turbo_reflex/controller_pack.rb +1 -1
- data/lib/turbo_reflex/errors.rb +12 -0
- data/lib/turbo_reflex/runner.rb +39 -18
- data/lib/turbo_reflex/state.rb +114 -0
- data/lib/turbo_reflex/state_manager.rb +102 -0
- data/lib/turbo_reflex/version.rb +1 -1
- data/package.json +1 -1
- data/tags +2902 -2985
- data/yarn.lock +32 -43
- metadata +8 -4
- data/Dockerfile.orig +0 -49
- data/lib/turbo_reflex/ui_state.rb +0 -118
@@ -0,0 +1,52 @@
|
|
1
|
+
import meta from '../meta'
|
2
|
+
import observable from './observable'
|
3
|
+
import { stateEvents as events } from '../events'
|
4
|
+
|
5
|
+
let oldState, state, changedState
|
6
|
+
let observer
|
7
|
+
|
8
|
+
function loadState () {
|
9
|
+
const json = atob(meta.element.dataset.state)
|
10
|
+
changedState = {}
|
11
|
+
oldState = state = observable(JSON.parse(json))
|
12
|
+
}
|
13
|
+
|
14
|
+
function initObserver () {
|
15
|
+
if (observer) observer.disconnect()
|
16
|
+
observer = new MutationObserver(loadState)
|
17
|
+
observer.observe(meta.element, {
|
18
|
+
attributes: true,
|
19
|
+
attributeFilter: ['data-state']
|
20
|
+
})
|
21
|
+
}
|
22
|
+
|
23
|
+
addEventListener('DOMContentLoaded', loadState)
|
24
|
+
addEventListener('DOMContentLoaded', initObserver)
|
25
|
+
addEventListener('turbo:load', initObserver)
|
26
|
+
addEventListener('turbo:frame-load', initObserver)
|
27
|
+
|
28
|
+
addEventListener(
|
29
|
+
events.beforeStateChange,
|
30
|
+
event => (oldState = JSON.parse(JSON.stringify(state)))
|
31
|
+
)
|
32
|
+
|
33
|
+
addEventListener(events.stateChange, event => {
|
34
|
+
changedState = {}
|
35
|
+
for (const [key, value] of Object.entries(state))
|
36
|
+
if (oldState[key] !== value) changedState[key] = value
|
37
|
+
meta.element.dataset.state = btoa(JSON.stringify(state))
|
38
|
+
})
|
39
|
+
|
40
|
+
export { state }
|
41
|
+
export default {
|
42
|
+
events,
|
43
|
+
|
44
|
+
// The UI state changes are split into chunks and sent to the server in an HTTP request header.
|
45
|
+
// Max size for an HTTP header is around 4k or 4,000 bytes.
|
46
|
+
// A Base64 character is an 8-bit-padded ASCII character... or 1 byte
|
47
|
+
//
|
48
|
+
// SEE: lib/state.rb - for info on how `state` is serialized/deserialized
|
49
|
+
get payloadChunks () {
|
50
|
+
return btoa(JSON.stringify(changedState)).match(/.{1,2000}/g)
|
51
|
+
}
|
52
|
+
}
|
@@ -0,0 +1,36 @@
|
|
1
|
+
import meta from '../meta'
|
2
|
+
import { dispatch, stateEvents as events } from '../events'
|
3
|
+
|
4
|
+
let head
|
5
|
+
|
6
|
+
function observable (object, parent = null) {
|
7
|
+
if (!object || typeof object !== 'object') return object
|
8
|
+
|
9
|
+
const proxy = new Proxy(object, {
|
10
|
+
deleteProperty (target, key) {
|
11
|
+
dispatch(events.beforeStateChange, meta.element, { state: head })
|
12
|
+
delete target[key]
|
13
|
+
dispatch(events.stateChange, meta.element, { state: head })
|
14
|
+
return true
|
15
|
+
},
|
16
|
+
|
17
|
+
set (target, key, value, receiver) {
|
18
|
+
dispatch(events.beforeStateChange, meta.element, { state: head })
|
19
|
+
target[key] = observable(value, this)
|
20
|
+
dispatch(events.stateChange, meta.element, { state: head })
|
21
|
+
return true
|
22
|
+
}
|
23
|
+
})
|
24
|
+
|
25
|
+
if (Array.isArray(object)) {
|
26
|
+
object.forEach((value, index) => (object[index] = observable(value, proxy)))
|
27
|
+
} else if (typeof object === 'object') {
|
28
|
+
for (const [key, value] of Object.entries(object))
|
29
|
+
object[key] = observable(value, proxy)
|
30
|
+
}
|
31
|
+
|
32
|
+
if (!parent) head = proxy
|
33
|
+
return proxy
|
34
|
+
}
|
35
|
+
|
36
|
+
export default observable
|
data/app/javascript/turbo.js
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
import meta from './meta'
|
2
|
+
import state from './state'
|
2
3
|
import renderer from './renderer'
|
4
|
+
import { dispatch } from './events'
|
3
5
|
import lifecycle from './lifecycle'
|
4
6
|
|
5
7
|
const frameSources = {}
|
@@ -8,6 +10,8 @@ const frameSources = {}
|
|
8
10
|
addEventListener('turbo:before-fetch-request', event => {
|
9
11
|
const frame = event.target.closest('turbo-frame')
|
10
12
|
const { fetchOptions } = event.detail
|
13
|
+
|
14
|
+
// reflex invoked and busy
|
11
15
|
if (meta.busy) {
|
12
16
|
let acceptHeaders = [
|
13
17
|
'text/vnd.turbo-reflex.html',
|
@@ -17,12 +21,14 @@ addEventListener('turbo:before-fetch-request', event => {
|
|
17
21
|
.filter(entry => entry && entry.trim().length > 0)
|
18
22
|
.join(', ')
|
19
23
|
fetchOptions.headers['Accept'] = acceptHeaders
|
24
|
+
fetchOptions.headers['TurboReflex-Token'] = meta.token
|
20
25
|
}
|
21
|
-
|
22
|
-
|
26
|
+
|
27
|
+
// always send state
|
28
|
+
state.payloadChunks.forEach(
|
23
29
|
(chunk, i) =>
|
24
30
|
(fetchOptions.headers[
|
25
|
-
`TurboReflex-
|
31
|
+
`TurboReflex-State-${i.toString().padStart(4, '0')}`
|
26
32
|
] = chunk)
|
27
33
|
)
|
28
34
|
})
|
@@ -37,7 +43,12 @@ addEventListener('turbo:before-fetch-response', event => {
|
|
37
43
|
if (response.header('TurboReflex')) {
|
38
44
|
if (response.statusCode < 200 || response.statusCode > 399) {
|
39
45
|
const error = `Server returned a ${response.statusCode} status code! TurboReflex requires 2XX-3XX status codes.`
|
40
|
-
|
46
|
+
dispatch(
|
47
|
+
lifecycle.events.clientError,
|
48
|
+
document,
|
49
|
+
{ ...event.detail, error },
|
50
|
+
true
|
51
|
+
)
|
41
52
|
}
|
42
53
|
|
43
54
|
if (response.header('TurboReflex') === 'Append') {
|
data/lib/turbo_reflex/base.rb
CHANGED
@@ -6,18 +6,19 @@
|
|
6
6
|
# Reflexes are executed via a before_action in the Rails controller lifecycle.
|
7
7
|
# They have access to the following methods and properties.
|
8
8
|
#
|
9
|
-
# * dom_id
|
10
|
-
# * dom_id_selector
|
11
|
-
# * controller
|
12
|
-
# * element
|
13
|
-
# *
|
14
|
-
# *
|
15
|
-
# *
|
16
|
-
# *
|
17
|
-
# *
|
18
|
-
# *
|
19
|
-
# *
|
20
|
-
# *
|
9
|
+
# * dom_id ...................... The Rails dom_id helper
|
10
|
+
# * dom_id_selector ............. Returns a CSS selector for a dom_id
|
11
|
+
# * controller .................. The Rails controller processing the HTTP request
|
12
|
+
# * element ..................... A struct that represents the DOM element that triggered the reflex
|
13
|
+
# * morph ....................... Appends a Turbo Stream to morph a DOM element
|
14
|
+
# * params ...................... Reflex specific params (frame_id, element, etc.)
|
15
|
+
# * render ...................... Renders Rails templates, partials, etc. (doesn't halt controller request handling)
|
16
|
+
# * render_response ............. Renders a full controller response
|
17
|
+
# * renderer .................... An ActionController::Renderer
|
18
|
+
# * prevent_controller_action ... Prevents the rails controller/action from running (i.e. the reflex handles the response entirely)
|
19
|
+
# * turbo_stream ................ A Turbo Stream TagBuilder
|
20
|
+
# * turbo_streams ............... A list of Turbo Streams to append to the response (also aliased as streams)
|
21
|
+
# * state ....................... An object that stores ephemeral `state`
|
21
22
|
#
|
22
23
|
class TurboReflex::Base
|
23
24
|
class << self
|
@@ -64,13 +65,12 @@ class TurboReflex::Base
|
|
64
65
|
attr_reader :controller, :turbo_streams
|
65
66
|
alias_method :streams, :turbo_streams
|
66
67
|
|
67
|
-
delegate :dom_id, to: :"controller.view_context"
|
68
|
-
delegate :render, to: :renderer
|
68
|
+
delegate :dom_id, :render, to: :"controller.view_context"
|
69
69
|
delegate(
|
70
70
|
:controller_action_prevented?,
|
71
71
|
:render_response,
|
72
72
|
:turbo_stream,
|
73
|
-
:
|
73
|
+
:state,
|
74
74
|
to: :@runner
|
75
75
|
)
|
76
76
|
|
@@ -84,6 +84,10 @@ class TurboReflex::Base
|
|
84
84
|
"##{dom_id(...)}"
|
85
85
|
end
|
86
86
|
|
87
|
+
def morph(selector, html)
|
88
|
+
turbo_streams << turbo_stream.invoke("morph", args: [html], selector: selector)
|
89
|
+
end
|
90
|
+
|
87
91
|
# default reflex invoked when method not specified
|
88
92
|
def noop
|
89
93
|
end
|
@@ -96,22 +100,22 @@ class TurboReflex::Base
|
|
96
100
|
@element ||= begin
|
97
101
|
attributes = params[:element_attributes]
|
98
102
|
attrs = attributes.keys.each_with_object({}) do |key, memo|
|
103
|
+
memo[:aria] ||= {}
|
99
104
|
memo[:dataset] ||= {}
|
100
105
|
if key.start_with?("data_")
|
101
106
|
memo[:dataset][key[5..].parameterize.underscore.to_sym] = attributes[key]
|
107
|
+
elsif key.start_with?("aria_")
|
108
|
+
memo[:aria][key[5..].parameterize.underscore.to_sym] = attributes[key]
|
102
109
|
else
|
103
110
|
memo[key.parameterize.underscore.to_sym] = attributes[key]
|
104
111
|
end
|
105
112
|
end
|
106
|
-
attrs[:
|
107
|
-
|
113
|
+
attrs[:aria] = OpenStruct.new(attrs[:aria])
|
114
|
+
attrs[:dataset] = OpenStruct.new(attrs[:dataset])
|
115
|
+
OpenStruct.new attrs
|
108
116
|
end
|
109
117
|
end
|
110
118
|
|
111
|
-
def renderer
|
112
|
-
ActionController::Renderer.for controller.class, controller.request.env
|
113
|
-
end
|
114
|
-
|
115
119
|
def should_prevent_controller_action?(method_name)
|
116
120
|
self.class.should_prevent_controller_action? self, method_name
|
117
121
|
end
|
data/lib/turbo_reflex/runner.rb
CHANGED
@@ -1,16 +1,18 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "errors"
|
3
4
|
require_relative "sanitizer"
|
4
|
-
require_relative "
|
5
|
+
require_relative "state_manager"
|
5
6
|
|
6
7
|
class TurboReflex::Runner
|
7
|
-
attr_reader :controller, :
|
8
|
+
attr_reader :controller, :state_manager
|
9
|
+
alias_method :state, :state_manager
|
8
10
|
|
9
11
|
delegate_missing_to :controller
|
10
12
|
|
11
13
|
def initialize(controller)
|
12
14
|
@controller = controller
|
13
|
-
@
|
15
|
+
@state_manager = TurboReflex::StateManager.new(self)
|
14
16
|
end
|
15
17
|
|
16
18
|
def meta_tag
|
@@ -19,7 +21,7 @@ class TurboReflex::Runner
|
|
19
21
|
id: "turbo-reflex",
|
20
22
|
name: "turbo-reflex",
|
21
23
|
content: masked_token,
|
22
|
-
data: {busy: false,
|
24
|
+
data: {busy: false, state: state_manager.payload}
|
23
25
|
}
|
24
26
|
view_context.tag("meta", options).html_safe
|
25
27
|
end
|
@@ -30,9 +32,26 @@ class TurboReflex::Runner
|
|
30
32
|
|
31
33
|
def reflex_valid?
|
32
34
|
return false unless reflex_requested?
|
33
|
-
|
34
|
-
|
35
|
-
reflex_instance.
|
35
|
+
|
36
|
+
# validate class
|
37
|
+
unless reflex_instance.is_a?(TurboReflex::Base)
|
38
|
+
raise TurboReflex::InvalidClassError,
|
39
|
+
"`#{reflex_class_name}` is not a subclass of `TurboReflex::Base`!"
|
40
|
+
end
|
41
|
+
|
42
|
+
# validate method
|
43
|
+
unless reflex_instance.respond_to?(reflex_method_name)
|
44
|
+
raise TurboReflex::InvalidMethodError,
|
45
|
+
"`#{reflex_class_name}` does not define the public method `#{reflex_method_name}`!"
|
46
|
+
end
|
47
|
+
|
48
|
+
# validate csrf token
|
49
|
+
unless valid_client_token?
|
50
|
+
raise TurboReflex::InvalidTokenError,
|
51
|
+
"CSRF token mismatch! The request header `TurboReflex-Token: #{client_token}` does not match the expected value of `#{server_token}`."
|
52
|
+
end
|
53
|
+
|
54
|
+
true
|
36
55
|
end
|
37
56
|
|
38
57
|
def reflex_params
|
@@ -103,10 +122,12 @@ class TurboReflex::Runner
|
|
103
122
|
prevent_controller_action if should_prevent_controller_action?
|
104
123
|
rescue => error
|
105
124
|
@reflex_errored = true
|
125
|
+
raise error if controller_action_prevented?
|
106
126
|
prevent_controller_action error: error
|
107
127
|
end
|
108
128
|
|
109
129
|
def prevent_controller_action(error: nil)
|
130
|
+
return if controller_action_prevented?
|
110
131
|
@controller_action_prevented = true
|
111
132
|
|
112
133
|
if error
|
@@ -117,17 +138,18 @@ class TurboReflex::Runner
|
|
117
138
|
append_success_to_response
|
118
139
|
end
|
119
140
|
|
120
|
-
|
141
|
+
append_meta_tag_to_response_body # called before `set_cookie` so all state is emitted to the DOM
|
142
|
+
state_manager.set_cookie # truncates state to stay within cookie size limits (4k)
|
121
143
|
end
|
122
144
|
|
123
145
|
def update_response
|
146
|
+
return if controller_action_prevented?
|
124
147
|
return if @update_response_performed
|
125
148
|
@update_response_performed = true
|
126
149
|
|
127
|
-
append_meta_tag_to_response_body
|
128
|
-
|
150
|
+
append_meta_tag_to_response_body # called before `set_cookie` so all state is emitted to the DOM
|
151
|
+
state_manager.set_cookie # truncates state to stay within cookie size limits (4k)
|
129
152
|
append_success_to_response if reflex_succeeded?
|
130
|
-
ui_state.set_cookie
|
131
153
|
end
|
132
154
|
|
133
155
|
def render_response(html: "", status: nil, headers: {TurboReflex: :Append})
|
@@ -139,16 +161,16 @@ class TurboReflex::Runner
|
|
139
161
|
@turbo_stream ||= Turbo::Streams::TagBuilder.new(controller.view_context)
|
140
162
|
end
|
141
163
|
|
164
|
+
def message_verifier
|
165
|
+
ActiveSupport::MessageVerifier.new session.id.to_s, digest: "SHA256"
|
166
|
+
end
|
167
|
+
|
142
168
|
private
|
143
169
|
|
144
170
|
def parsed_reflex_params
|
145
171
|
@parsed_reflex_params ||= JSON.parse(params[:turbo_reflex])
|
146
172
|
end
|
147
173
|
|
148
|
-
def message_verifier
|
149
|
-
ActiveSupport::MessageVerifier.new Rails.application.secret_key_base, digest: "SHA256"
|
150
|
-
end
|
151
|
-
|
152
174
|
def content_sanitizer
|
153
175
|
TurboReflex::Sanitizer.instance
|
154
176
|
end
|
@@ -166,7 +188,6 @@ class TurboReflex::Runner
|
|
166
188
|
end
|
167
189
|
|
168
190
|
def valid_client_token?
|
169
|
-
return true # TODO: get this check working
|
170
191
|
return false unless client_token.present?
|
171
192
|
return false unless message_verifier.valid_message?(client_token)
|
172
193
|
unmasked_client_token = message_verifier.verify(client_token)
|
@@ -179,7 +200,7 @@ class TurboReflex::Runner
|
|
179
200
|
end
|
180
201
|
|
181
202
|
def response_status
|
182
|
-
return :multiple_choices if
|
203
|
+
return :multiple_choices if should_redirect?
|
183
204
|
:ok
|
184
205
|
end
|
185
206
|
|
@@ -209,7 +230,7 @@ class TurboReflex::Runner
|
|
209
230
|
|
210
231
|
def append_meta_tag_to_response_body
|
211
232
|
session[:turbo_reflex_token] = new_token
|
212
|
-
append_to_response_body turbo_stream.
|
233
|
+
append_to_response_body turbo_stream.invoke("morph", args: [meta_tag], selector: "#turbo-reflex")
|
213
234
|
end
|
214
235
|
|
215
236
|
def append_success_event_to_response_body
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class TurboReflex::State
|
4
|
+
class << self
|
5
|
+
def serialize_base64(data)
|
6
|
+
Base64.urlsafe_encode64 data.to_json, padding: false
|
7
|
+
end
|
8
|
+
|
9
|
+
def deserialize_base64(string)
|
10
|
+
return {} if string.blank?
|
11
|
+
JSON.parse Base64.urlsafe_decode64(string)
|
12
|
+
end
|
13
|
+
|
14
|
+
def serialize(data)
|
15
|
+
dump = Marshal.dump(data)
|
16
|
+
deflated = Zlib::Deflate.deflate(dump, Zlib::BEST_COMPRESSION)
|
17
|
+
Base64.urlsafe_encode64 deflated
|
18
|
+
end
|
19
|
+
|
20
|
+
def deserialize(string)
|
21
|
+
return {} if string.blank?
|
22
|
+
decoded = Base64.urlsafe_decode64(string)
|
23
|
+
inflated = Zlib::Inflate.inflate(decoded)
|
24
|
+
Marshal.load inflated
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def initialize(ordinal_payload = nil)
|
29
|
+
@internal_keys = []
|
30
|
+
@internal_data = {}.with_indifferent_access
|
31
|
+
|
32
|
+
self.class.deserialize(ordinal_payload).each do |(key, value)|
|
33
|
+
write key, value
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
delegate :size, to: :internal_data
|
38
|
+
delegate :include?, :has_key?, :key?, :member?, to: :internal_data
|
39
|
+
|
40
|
+
def cache_key
|
41
|
+
"turbo-reflex/ui-state/#{Base64.urlsafe_encode64 Digest::MD5.hexdigest(payload), padding: false}"
|
42
|
+
end
|
43
|
+
|
44
|
+
def read(*keys, default: nil)
|
45
|
+
value = internal_data[key_for(*keys)]
|
46
|
+
value = write(*keys, default) if value.nil? && default
|
47
|
+
value
|
48
|
+
end
|
49
|
+
|
50
|
+
def write(*keys, value)
|
51
|
+
key = key_for(*keys)
|
52
|
+
internal_keys.delete key if internal_keys.include?(key)
|
53
|
+
internal_keys << key
|
54
|
+
internal_data[key] = value
|
55
|
+
value
|
56
|
+
end
|
57
|
+
|
58
|
+
def payload
|
59
|
+
self.class.serialize_base64 internal_data
|
60
|
+
end
|
61
|
+
|
62
|
+
def ordinal_payload
|
63
|
+
self.class.serialize internal_list
|
64
|
+
end
|
65
|
+
|
66
|
+
def shrink!
|
67
|
+
@internal_data = shrink(internal_data).with_indifferent_access
|
68
|
+
@internal_keys = internal_keys & internal_data.keys
|
69
|
+
end
|
70
|
+
|
71
|
+
def prune!(max_bytesize: 2.kilobytes)
|
72
|
+
return if internal_keys.blank?
|
73
|
+
return if internal_data.blank?
|
74
|
+
|
75
|
+
percentage = max_bytesize > 0 ? ordinal_payload.bytesize / max_bytesize.to_f : 0
|
76
|
+
while percentage > 1
|
77
|
+
keys_to_keep = internal_keys.slice((internal_keys.length - (internal_keys.length / percentage).floor)..-1)
|
78
|
+
keys_to_remove = internal_keys - keys_to_keep
|
79
|
+
@internal_keys = keys_to_keep
|
80
|
+
keys_to_remove.each { |key| internal_data.delete key }
|
81
|
+
percentage = max_bytesize > 0 ? ordinal_payload.bytesize / max_bytesize.to_f : 0
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
attr_reader :internal_keys
|
88
|
+
attr_reader :internal_data
|
89
|
+
|
90
|
+
def internal_list
|
91
|
+
internal_keys.map { |key| [key, internal_data[key]] }
|
92
|
+
end
|
93
|
+
|
94
|
+
def key_for(*keys)
|
95
|
+
keys.map { |key| key.try(:cache_key) || key.to_s }.join("/")
|
96
|
+
end
|
97
|
+
|
98
|
+
def shrink(obj)
|
99
|
+
case obj
|
100
|
+
when Array
|
101
|
+
obj.each_with_object([]) do |value, memo|
|
102
|
+
value = shrink(value)
|
103
|
+
memo << value if value.present?
|
104
|
+
end
|
105
|
+
when Hash
|
106
|
+
obj.each_with_object({}.with_indifferent_access) do |(key, value), memo|
|
107
|
+
value = shrink(value)
|
108
|
+
memo[key] = value if value.present?
|
109
|
+
end
|
110
|
+
else
|
111
|
+
obj
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "state"
|
4
|
+
|
5
|
+
# Class used to hold ephemeral state related to the rendered UI.
|
6
|
+
#
|
7
|
+
# Examples:
|
8
|
+
#
|
9
|
+
# - Sidebar open/closed state
|
10
|
+
# - Tree view open/closed state
|
11
|
+
# - Accordion collapsed/expanded state
|
12
|
+
# - Customized layout / presentation
|
13
|
+
# - Applied data filters
|
14
|
+
# - Number of data rows to display etc.
|
15
|
+
#
|
16
|
+
class TurboReflex::StateManager
|
17
|
+
include ActiveModel::Dirty
|
18
|
+
|
19
|
+
class << self
|
20
|
+
attr_writer :cookie_max_bytesize
|
21
|
+
|
22
|
+
def cookie_max_bytesize
|
23
|
+
@cookie_max_bytesize ||= 2.kilobytes
|
24
|
+
end
|
25
|
+
|
26
|
+
def state_override_blocks
|
27
|
+
@state_overrides ||= {}
|
28
|
+
end
|
29
|
+
|
30
|
+
def add_state_override_block(controller_name, block)
|
31
|
+
state_override_blocks[controller_name] = block
|
32
|
+
end
|
33
|
+
|
34
|
+
def state_override_block(controller)
|
35
|
+
return nil if state_override_blocks.blank?
|
36
|
+
ancestor = controller.class.ancestors.find { |a| state_override_blocks[a.name] }
|
37
|
+
state_override_blocks[ancestor.name]
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# For ActiveModel::Dirty tracking
|
42
|
+
define_attribute_methods :state
|
43
|
+
|
44
|
+
delegate :request, :response, to: :"runner.controller"
|
45
|
+
|
46
|
+
def initialize(runner)
|
47
|
+
@runner = runner
|
48
|
+
@state = TurboReflex::State.new(cookie) # server state as stored in the cookie
|
49
|
+
|
50
|
+
# Apply server state overrides (i.e. state stored in databases like Redis, Postgres, etc...)
|
51
|
+
state_override_block = self.class.state_override_block(runner.controller)
|
52
|
+
if state_override_block
|
53
|
+
server_data = runner.controller.instance_eval(&state_override_block).with_indifferent_access
|
54
|
+
server_data.each { |key, val| self[key] = val }
|
55
|
+
end
|
56
|
+
|
57
|
+
# Merge client state into server state (i.e. optimistic state)
|
58
|
+
# NOTE: Client state HTTP headers are only sent if/when state has changed on the client (only the changes are sent).
|
59
|
+
# This prevents race conditions (state mismatch) caused when frame and XHR requests emit immediately
|
60
|
+
# before the <meta id="turbo-reflex"> has been updated with the latest state from the server.
|
61
|
+
client_data = TurboReflex::State.deserialize_base64(header).with_indifferent_access
|
62
|
+
client_data.each { |key, val| self[key] = val }
|
63
|
+
end
|
64
|
+
|
65
|
+
delegate :cache_key, :payload, to: :state
|
66
|
+
|
67
|
+
def [](*keys, default: nil)
|
68
|
+
state.read(*keys, default: default)
|
69
|
+
end
|
70
|
+
|
71
|
+
def []=(*keys, value)
|
72
|
+
state_will_change! if value != self[*keys]
|
73
|
+
state.write(*keys, value)
|
74
|
+
end
|
75
|
+
|
76
|
+
def set_cookie
|
77
|
+
return unless changed?
|
78
|
+
state.shrink!
|
79
|
+
state.prune! max_bytesize: TurboReflex::StateManager.cookie_max_bytesize
|
80
|
+
response.set_cookie "_turbo_reflex_state", value: state.ordinal_payload, path: "/", expires: 1.day.from_now
|
81
|
+
changes_applied
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
attr_reader :runner
|
87
|
+
attr_reader :state
|
88
|
+
|
89
|
+
def headers
|
90
|
+
request.headers.select { |(key, _)| key.match?(/TURBOREFLEX_STATE/i) }.sort
|
91
|
+
end
|
92
|
+
|
93
|
+
# State that exists on the client.
|
94
|
+
def header
|
95
|
+
headers.map(&:last).join
|
96
|
+
end
|
97
|
+
|
98
|
+
# State that the server last rendered with.
|
99
|
+
def cookie
|
100
|
+
request.cookies["_turbo_reflex_state"]
|
101
|
+
end
|
102
|
+
end
|
data/lib/turbo_reflex/version.rb
CHANGED
data/package.json
CHANGED