react_on_rails_pro 17.1.0.rc.1 → 17.1.0.rc.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 161dafc96a1df0f6a646aecb23c4a8f6e266ef7d08ba12ea96fdb54568d86fce
4
- data.tar.gz: d596bae3e825c5d7d436947dce7bb63d372e5a373b049b7f87fdcd190b4943c3
3
+ metadata.gz: af819b8f0320a216aaa75d812fa78f376694161a3225932570950158241bced4
4
+ data.tar.gz: b3d8ce02b32b91360b89b2278d05a5e5ba5716cbd7a5a909e2c000e89e0f93d5
5
5
  SHA512:
6
- metadata.gz: f462b4af0cd52ef87917277edb7d1a6b5e706ea7b0cdd63814a1c0304ab3dc8fac150783b24ed8ef1e2a01f13c5d1fbfae334982bc83a0d88cb9e7ec82d766d5
7
- data.tar.gz: 258b491c7b0d939e8c913388e631a96ab4cbbb433963e65e0e4a89e0b8644d2654f84e40f2ed4ce2501f6bf0dce2a46efef53d3d351ce62a84e741cf718bab9b
6
+ metadata.gz: ffdefae73ac6d2f573b4d2041c81484356d6aaf17968e1a33187b6fefdefd4017eda37a6471e6bdd2dbac82f6ed64ba85a7e0fee11208513137b439712c5af58
7
+ data.tar.gz: 3f53042c50b650ef0407db5a763a8378caa2ff03643f3047c690a4c601ed6d1b027ee150614ae903027ead086ff677d2b44b1a5c5715f4a6846718e749efe985
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- react_on_rails (17.1.0.rc.1)
12
+ react_on_rails (17.1.0.rc.2)
13
13
  addressable
14
14
  connection_pool
15
15
  execjs (~> 2.5)
@@ -20,14 +20,14 @@ PATH
20
20
  PATH
21
21
  remote: .
22
22
  specs:
23
- react_on_rails_pro (17.1.0.rc.1)
23
+ react_on_rails_pro (17.1.0.rc.2)
24
24
  async (>= 2.29)
25
25
  async-http (~> 0.95)
26
26
  execjs (~> 2.9)
27
27
  io-endpoint (~> 0.17.0)
28
28
  jwt (>= 2.5, < 4)
29
29
  nokogiri (>= 1.12, < 2)
30
- react_on_rails (= 17.1.0.rc.1)
30
+ react_on_rails (= 17.1.0.rc.2)
31
31
 
32
32
  GEM
33
33
  remote: https://rubygems.org/
@@ -95,16 +95,24 @@ module ReactOnRailsPro
95
95
  end
96
96
 
97
97
  def render_streaming_with_cache(prerender_cache_key, js_code, render_options)
98
- # Streaming path: try to serve from cache; otherwise wrap upstream stream
98
+ # Streaming path: try to serve from cache; otherwise wrap upstream stream.
99
+ # The cache key ignores random dom ids, so a cached stream may have been produced for
100
+ # another mount point; StreamCache rebinds it to this render's dom id (issue #4984).
99
101
  cache_options = render_options.internal_option(:cache_options)
100
- cached_stream = ReactOnRailsPro::StreamCache.fetch_stream(prerender_cache_key, cache_options:)
102
+ dom_node_id = render_options.dom_id
103
+ cached_stream = ReactOnRailsPro::StreamCache.fetch_stream(
104
+ prerender_cache_key,
105
+ cache_options:,
106
+ dom_node_id:
107
+ )
101
108
  return cached_stream if cached_stream
102
109
 
103
110
  upstream = render_on_pool(js_code, render_options)
104
111
  ReactOnRailsPro::StreamCache.wrap_and_cache(
105
112
  prerender_cache_key,
106
113
  upstream,
107
- cache_options:
114
+ cache_options:,
115
+ dom_node_id:
108
116
  )
109
117
  end
110
118
 
@@ -15,22 +15,38 @@
15
15
 
16
16
  module ReactOnRailsPro
17
17
  class StreamCache
18
+ CACHED_CHUNKS_KEY = "chunks"
19
+ CACHED_DOM_NODE_ID_KEY = "dom_node_id"
20
+
18
21
  class << self
19
22
  # Returns a stream-like object that responds to `each_chunk` and yields cached chunks
20
23
  # or nil if not present in cache. Pass the same cache_options given to wrap_and_cache
21
24
  # so key-altering options such as :namespace resolve to the written entry.
22
- def fetch_stream(cache_key, cache_options: nil)
23
- cached_chunks = Rails.cache.read(cache_key, cache_options)
24
- return nil unless cached_chunks.is_a?(Array)
25
+ #
26
+ # `dom_node_id` is the mount point of the render being served. The prerender cache key
27
+ # deliberately ignores random dom ids (see ProRendering.without_random_values) so one
28
+ # cached render serves every mount point, but the cached chunks embed the dom id of the
29
+ # render that produced them (RSC payload keys, console replay scripts). Replaying them
30
+ # unchanged under another mount id leaves the browser without an embedded payload for
31
+ # its own node, so cached chunks are rebound to the current dom id on the way out.
32
+ # See https://github.com/shakacode/react_on_rails/issues/4984.
33
+ def fetch_stream(cache_key, cache_options: nil, dom_node_id: nil)
34
+ entry = cached_entry(Rails.cache.read(cache_key, cache_options))
35
+ return nil unless entry
25
36
 
26
- build_stream_from_chunks(cached_chunks)
37
+ chunks = DomNodeIdRewriter.rewrite(
38
+ entry.fetch(CACHED_CHUNKS_KEY),
39
+ from: entry[CACHED_DOM_NODE_ID_KEY],
40
+ to: dom_node_id
41
+ )
42
+ build_stream_from_chunks(chunks)
27
43
  end
28
44
 
29
45
  # Wraps an upstream stream (responds to `each_chunk`), yields chunks downstream while
30
- # buffering them, and writes the chunks array to Rails.cache on successful completion.
31
- # Returns a stream-like object that responds to `each_chunk`.
32
- def wrap_and_cache(cache_key, upstream_stream, cache_options: nil)
33
- component = CachingComponent.new(upstream_stream, cache_key, cache_options)
46
+ # buffering them, and writes the chunks array plus the producing dom id to Rails.cache on
47
+ # successful completion. Returns a stream-like object that responds to `each_chunk`.
48
+ def wrap_and_cache(cache_key, upstream_stream, cache_options: nil, dom_node_id: nil)
49
+ component = CachingComponent.new(upstream_stream, cache_key, cache_options, dom_node_id)
34
50
  ReactOnRailsPro::StreamDecorator.new(component)
35
51
  end
36
52
 
@@ -39,6 +55,97 @@ module ReactOnRailsPro
39
55
  component = CachedChunksComponent.new(chunks)
40
56
  ReactOnRailsPro::StreamDecorator.new(component)
41
57
  end
58
+
59
+ private
60
+
61
+ # Entries written before the producing dom id was retained were bare chunk arrays. They
62
+ # cannot be rebound safely, so they are treated as a miss and re-rendered into the current
63
+ # shape. (The base cache key also embeds the Pro version, so such entries are not normally
64
+ # reachable after an upgrade.)
65
+ def cached_entry(cached)
66
+ return nil unless cached.is_a?(Hash) && cached[CACHED_CHUNKS_KEY].is_a?(Array)
67
+
68
+ cached
69
+ end
70
+ end
71
+
72
+ # Rebinds the dom id embedded in cached chunks to the mount point of the current render.
73
+ # Chunks are either Strings or renderer Hashes whose "html" and "consoleReplayScript" values
74
+ # carry the markup. The html is rewritten as one document and re-split at the original chunk
75
+ # boundaries, so an id that straddles two chunks is still replaced and the frame count is kept.
76
+ module DomNodeIdRewriter
77
+ HTML_KEY = "html"
78
+ CONSOLE_REPLAY_SCRIPT_KEY = "consoleReplayScript"
79
+
80
+ module_function
81
+
82
+ def rewrite(chunks, from:, to:)
83
+ return chunks if from.to_s.empty? || to.to_s.empty? || from == to
84
+
85
+ html_pieces = chunks.map { |chunk| markup_of(chunk) }
86
+ rewritten_html = resplit(
87
+ html_pieces.join, html_pieces.map(&:bytesize), from, to,
88
+ landing_index: chunks.index { |chunk| markup?(chunk) } || 0
89
+ )
90
+ chunks.each_with_index.map { |chunk, index| rebind_chunk(chunk, rewritten_html[index], from, to) }
91
+ end
92
+
93
+ def markup?(chunk)
94
+ chunk.is_a?(String) || (chunk.is_a?(Hash) && chunk[HTML_KEY].is_a?(String))
95
+ end
96
+
97
+ # Every chunk contributes its markup to one joined document: a String chunk is the markup
98
+ # itself, a Hash chunk contributes its "html", anything else contributes nothing.
99
+ def markup_of(chunk)
100
+ return chunk if chunk.is_a?(String)
101
+ return chunk[HTML_KEY] if chunk.is_a?(Hash) && chunk[HTML_KEY].is_a?(String)
102
+
103
+ ""
104
+ end
105
+
106
+ def rebind_chunk(chunk, rewritten_html, from, to)
107
+ return rewritten_html if chunk.is_a?(String)
108
+ return chunk unless chunk.is_a?(Hash)
109
+
110
+ rebind_hash(chunk, rewritten_html, from, to)
111
+ end
112
+
113
+ def rebind_hash(chunk, rewritten_html, from, to)
114
+ rebound = chunk.dup
115
+ rebound[HTML_KEY] = rewritten_html if chunk[HTML_KEY].is_a?(String)
116
+ if chunk[CONSOLE_REPLAY_SCRIPT_KEY].is_a?(String)
117
+ rebound[CONSOLE_REPLAY_SCRIPT_KEY] = replace_literal(chunk[CONSOLE_REPLAY_SCRIPT_KEY], from, to)
118
+ end
119
+ rebound
120
+ end
121
+
122
+ # Splits `document` back into pieces sized like `sizes` (in bytes). Random dom ids share one
123
+ # format, so the substitution keeps every byte offset and the original boundaries are exact.
124
+ # If the ids ever differ in length, byte offsets would no longer align (and could cut a
125
+ # multibyte character), so the whole rewritten document is delivered in the first piece that
126
+ # carries markup (`landing_index`) and every other piece is empty; the concatenation is
127
+ # identical either way.
128
+ def resplit(document, sizes, from, to, landing_index: 0)
129
+ rewritten = replace_literal(document, from, to)
130
+ if from.bytesize != to.bytesize
131
+ pieces = Array.new(sizes.length, "")
132
+ pieces[landing_index] = rewritten unless pieces.empty?
133
+ return pieces
134
+ end
135
+
136
+ offset = 0
137
+ sizes.each_with_index.map do |size, index|
138
+ piece = index == sizes.length - 1 ? rewritten.byteslice(offset..) : rewritten.byteslice(offset, size)
139
+ offset += size
140
+ piece || ""
141
+ end
142
+ end
143
+
144
+ # The block form keeps `to` literal: a String replacement would interpret backreference
145
+ # sequences such as `\0` or `\\`.
146
+ def replace_literal(text, from, to)
147
+ text.gsub(from) { to }
148
+ end
42
149
  end
43
150
 
44
151
  class CachedChunksComponent
@@ -54,10 +161,11 @@ module ReactOnRailsPro
54
161
  end
55
162
 
56
163
  class CachingComponent
57
- def initialize(upstream_stream, cache_key, cache_options)
164
+ def initialize(upstream_stream, cache_key, cache_options, dom_node_id = nil)
58
165
  @upstream_stream = upstream_stream
59
166
  @cache_key = cache_key
60
167
  @cache_options = cache_options
168
+ @dom_node_id = dom_node_id
61
169
  end
62
170
 
63
171
  def each_chunk(&block)
@@ -85,7 +193,11 @@ module ReactOnRailsPro
85
193
  # See https://github.com/shakacode/react_on_rails/issues/4581.
86
194
  return if stream_has_errors
87
195
 
88
- Rails.cache.write(@cache_key, buffered_chunks, @cache_options || {})
196
+ Rails.cache.write(
197
+ @cache_key,
198
+ { CACHED_DOM_NODE_ID_KEY => @dom_node_id, CACHED_CHUNKS_KEY => buffered_chunks },
199
+ @cache_options || {}
200
+ )
89
201
  end
90
202
 
91
203
  private
@@ -14,6 +14,6 @@
14
14
  # https://github.com/shakacode/react_on_rails/blob/main/REACT-ON-RAILS-PRO-LICENSE.md
15
15
 
16
16
  module ReactOnRailsPro
17
- VERSION = "17.1.0.rc.1"
17
+ VERSION = "17.1.0.rc.2"
18
18
  PROTOCOL_VERSION = "2.0.0"
19
19
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: react_on_rails_pro
3
3
  version: !ruby/object:Gem::Version
4
- version: 17.1.0.rc.1
4
+ version: 17.1.0.rc.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Gordon
@@ -111,14 +111,14 @@ dependencies:
111
111
  requirements:
112
112
  - - '='
113
113
  - !ruby/object:Gem::Version
114
- version: 17.1.0.rc.1
114
+ version: 17.1.0.rc.2
115
115
  type: :runtime
116
116
  prerelease: false
117
117
  version_requirements: !ruby/object:Gem::Requirement
118
118
  requirements:
119
119
  - - '='
120
120
  - !ruby/object:Gem::Version
121
- version: 17.1.0.rc.1
121
+ version: 17.1.0.rc.2
122
122
  - !ruby/object:Gem::Dependency
123
123
  name: bundler
124
124
  requirement: !ruby/object:Gem::Requirement