Skip to content

Commit 3837812

Browse files
committed
Merge branch 'feat/react18-createroot' into 'edge'
Backport the React 18 createRoot path to edge + add the React 18 cell (hyperstack-org#51) See merge request ru/hyperstack!66
2 parents 0aad1ef + f1c0c35 commit 3837812

12 files changed

Lines changed: 304 additions & 36 deletions

File tree

‎.gitlab-ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ stages:
135135
# `rake hyperstack:matrix:check` fails if this list and the table disagree. (#51)
136136
parallel:
137137
matrix:
138-
- HYPERSTACK_CELL: [rails61-react16, rails61-react17]
138+
- HYPERSTACK_CELL: [rails61-react16, rails61-react17, rails61-react18]
139139

140140
before_script:
141141
# - sudo sed -i -e '/local.*peer/s/postgres/all/' -e 's/peer\|md5/trust/g' /etc/postgresql/*/main/pg_hba.conf
@@ -196,7 +196,7 @@ stages:
196196
# `rake hyperstack:matrix:check` fails if this list and the table disagree. (#51)
197197
parallel:
198198
matrix:
199-
- HYPERSTACK_CELL: [rails61-react16, rails61-react17]
199+
- HYPERSTACK_CELL: [rails61-react16, rails61-react17, rails61-react18]
200200

201201
# language: ruby
202202
# cache:

‎ruby/hyper-component/lib/hyper-component.rb‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,34 @@
3939
require 'hyperstack/internal/component/while_loading_wrapper'
4040

4141
require 'hyperstack/component/version'
42+
43+
# React 18/19 (#39): on the sprockets path (react/react-source-browser UMD),
44+
# react-rails' react_ujs and Hyperstack's own mount path (react_api.rb) call the
45+
# top-level `ReactDOM.createRoot`. React 18+ logs a benign console.error
46+
# deprecation on every access ("You are importing createRoot from react-dom
47+
# which is not supported. You should instead import it from react-dom/client"),
48+
# but the UMD bundle exposes no separate `react-dom/client` global to import
49+
# from — the top-level entry is the only one available, so the warning is
50+
# unavoidable on this path (the esbuild build avoids it by exposing
51+
# react-dom/client's createRoot as window.ReactDOM.createRoot). Drop just that
52+
# one message client-side so it does not masquerade as a real error — e.g. specs
53+
# that assert on the count of SEVERE console errors. Installed once.
54+
%x{
55+
if (typeof console !== 'undefined' && console.error && !console.error.__hyperstackCreateRootFilter) {
56+
var __hyperstackOrigConsoleError = console.error;
57+
var __hyperstackFilteredError = function() {
58+
var msg = arguments[0];
59+
if (typeof msg === 'string' &&
60+
msg.indexOf('which is not supported. You should instead import it from') !== -1 &&
61+
(msg.indexOf('createRoot') !== -1 || msg.indexOf('hydrateRoot') !== -1)) {
62+
return;
63+
}
64+
return __hyperstackOrigConsoleError.apply(console, arguments);
65+
};
66+
__hyperstackFilteredError.__hyperstackCreateRootFilter = true;
67+
console.error = __hyperstackFilteredError;
68+
}
69+
}
4270
else
4371
require 'opal'
4472
require 'hyper-state'

‎ruby/hyper-component/lib/hyperstack/component.rb‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,33 @@ def _run_before_render_callbacks
205205

206206
def _render_wrapper
207207
_run_before_render_callbacks
208+
# React 18/19 (#39): createRoot's concurrent renderer "recovers" from an
209+
# error thrown during a render by re-rendering the whole root BEFORE
210+
# escalating to the nearest error boundary (componentDidCatch); it only
211+
# calls the boundary if a re-render ALSO throws. A rescue whose error
212+
# condition is consumed on the first raise (the common
213+
# `raise_error!`/`check_error` idiom, and any transient error) therefore
214+
# never reaches its `rescues` block — on React 17's legacy root the throw
215+
# went straight to the boundary, so this regressed. Persist the error and
216+
# re-raise it on every subsequent render so React's recovery re-render(s)
217+
# also throw and React escalates to the boundary, restoring the React-17
218+
# contract. Persist until the boundary actually handles it
219+
# (RescueWrapper#after_error clears @__hyperstack_pending_render_error before
220+
# force_update!) rather than for a fixed number of retries, because React's
221+
# DEVELOPMENT build additionally re-invokes a throwing render to replay the
222+
# error — so the number of re-renders before the boundary fires is not
223+
# fixed (1 in production, 2+ in development). NotQuiet (the while-loading
224+
# signal) is < Exception not StandardError, is idempotent, and is
225+
# intentionally left to propagate on its own.
226+
raise @__hyperstack_pending_render_error if @__hyperstack_pending_render_error
208227
observing(rendering: true) do
209-
element = Hyperstack::Internal::Component::RenderingContext.render(nil) do
210-
render || ""
228+
begin
229+
element = Hyperstack::Internal::Component::RenderingContext.render(nil) do
230+
render || ""
231+
end
232+
rescue StandardError => e
233+
@__hyperstack_pending_render_error = e
234+
raise e
211235
end
212236
@__hyperstack_component_waiting_on_resources =
213237
element.waiting_on_resources if element.respond_to? :waiting_on_resources

‎ruby/hyper-component/lib/hyperstack/component/element.rb‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ class Element
3535
def set_native_attributes(native_element)
3636
@key = `native_element.key`
3737
@props = `native_element.props`
38-
@ref = `native_element.ref`
38+
# React 19 removed `element.ref` (it warns + is gone): ref is now a regular
39+
# prop, so read it from `props.ref`. React <= 18 still keeps it off `props`,
40+
# on `element.ref`. Branch on the major version so 19 never touches the
41+
# removed getter (which logs a console.error even on access).
42+
@ref = `parseInt(React.version, 10) >= 19 ? (native_element.props ? native_element.props.ref : null) : native_element.ref`
3943
@type = `native_element.type`
4044
@_owner = `native_element._owner`
4145
@_props_as_hash = Hyperstack::Internal::Component.native_to_hash(@props)
@@ -85,7 +89,34 @@ def ref # this will not conflict with React's on ref attribute okay because its
8589
end
8690

8791
def dom_node
88-
`typeof #{ref}.$dom_node == 'function'` ? ref.dom_node : ref
92+
r = ref
93+
if `typeof #{r}.$dom_node == 'function'`
94+
r.dom_node
95+
elsif `#{r} !== null && #{r} !== undefined && #{r}.nodeType === undefined`
96+
# React 18 (#18): r is a foreign class-component instance — no Hyperstack
97+
# dom_node, and not a DOM node. React deprecated findDOMNode (removed in 19)
98+
# but still exposes the component's fiber on the instance (_reactInternals);
99+
# walk it to the first host (DOM) node — the same result findDOMNode returns,
100+
# without the hard-coded deprecation console.error. Fall back to findDOMNode if
101+
# the fiber field ever changes shape.
102+
%x{
103+
var inst = #{r};
104+
var fiber = inst._reactInternals || inst._reactInternalFiber;
105+
var walk = function(node) {
106+
for (var n = node; n; n = n.sibling) {
107+
if (n.stateNode && n.stateNode.nodeType) { return n.stateNode; }
108+
var deep = n.child ? walk(n.child) : null;
109+
if (deep) { return deep; }
110+
}
111+
return null;
112+
};
113+
var found = fiber ? walk(fiber.child) : null;
114+
if (found) { return found; }
115+
return (typeof ReactDOM !== 'undefined' && ReactDOM.findDOMNode) ? ReactDOM.findDOMNode(inst) : inst;
116+
}
117+
else
118+
r
119+
end
89120
end
90121

91122
# Attach event handlers. skip false, nil and blank event names

‎ruby/hyper-component/lib/hyperstack/component/react_api.rb‎

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,28 +74,70 @@ def self.create_element(type, *properties, &block)
7474
# end
7575

7676
def self.render(element, container)
77-
raise "ReactDOM.render is not defined. In React >= v15 you must import it with ReactDOM" if (`typeof ReactDOM === 'undefined'`)
77+
raise "ReactDOM is not defined. In React >= v15 you must import it with ReactDOM" if (`typeof ReactDOM === 'undefined'`)
7878

7979
container = `container.$$class ? container[0] : container`
8080

81-
if block_given?
82-
cb = %x{
83-
function(){
84-
setTimeout(function(){
85-
#{yield}
86-
}, 0)
87-
}
81+
cb = if block_given?
82+
%x{ function(){ setTimeout(function(){ #{yield} }, 0) } }
83+
else
84+
`null`
85+
end
86+
87+
if `typeof ReactDOM.createRoot === 'function'`
88+
# React 18+: ReactDOM.render is gone. Use createRoot (one root per
89+
# container, kept on the node for re-render + unmount). createRoot.render()
90+
# returns nothing, but Hyperstack's mount contract needs the component
91+
# instance — capture it via a ref and force a synchronous commit with
92+
# flushSync so the ref fires before we return. (#18)
93+
native = %x{
94+
(function(){
95+
var root = container.__hyperstackReactRoot;
96+
if (!root) {
97+
// React 19 (#19): an uncaught error during render is reported to the
98+
// root's onUncaughtError callback instead of propagating synchronously
99+
// out of render/flushSync (the React <= 18 behavior Hyperstack's mount
100+
// contract relies on — e.g. a NoMethodError raised in render must reach
101+
// the caller). Stash it on the container and re-throw below. On React
102+
// <= 18 the option is ignored and flushSync re-throws as before.
103+
root = ReactDOM.createRoot(container, {
104+
onUncaughtError: function(error){ container.__hyperstackRenderError = error; }
105+
});
106+
container.__hyperstackReactRoot = root;
107+
}
108+
container.__hyperstackRenderError = null;
109+
var captured = null;
110+
var elem = React.cloneElement(#{element.to_n}, { ref: function(inst){ if (inst) { captured = inst; } } });
111+
var flush = ReactDOM.flushSync || function(f){ f(); };
112+
flush(function(){ root.render(elem); });
113+
if (container.__hyperstackRenderError) {
114+
var err = container.__hyperstackRenderError;
115+
container.__hyperstackRenderError = null;
116+
throw err;
117+
}
118+
var cb = #{cb};
119+
if (cb) { cb(); }
120+
return captured;
121+
})()
88122
}
89-
native = `ReactDOM.render(#{element.to_n}, container, cb)`
123+
elsif block_given?
124+
native = `ReactDOM.render(#{element.to_n}, container, #{cb})`
90125
else
91126
native = `ReactDOM.render(#{element.to_n}, container)`
92127
end
93128

94-
return unless `#{native} !== null`
129+
return unless `#{native} !== null && #{native} !== undefined`
95130

96131
if `#{native}.__opalInstance !== undefined && #{native}.__opalInstance !== null`
97132
`#{native}.__opalInstance`
98-
elsif `ReactDOM.findDOMNode !== undefined && #{native}.nodeType === undefined`
133+
elsif `#{native}.nodeType !== undefined`
134+
native # already a DOM node (e.g. a host-element ref)
135+
elsif `container.firstChild !== null && container.firstChild !== undefined`
136+
# React 18 (#18): the mounted root's DOM node, without ReactDOM.findDOMNode
137+
# (deprecated in 18, removed in 19). Hyperstack mounts a single element into
138+
# `container`, so its firstChild is that component's top DOM node.
139+
`container.firstChild`
140+
elsif `ReactDOM.findDOMNode !== undefined` # React < 18 fallback
99141
`ReactDOM.findDOMNode(#{native})`
100142
else
101143
native
@@ -130,10 +172,15 @@ def self.render_to_static_markup(element)
130172
end
131173

132174
def self.unmount_component_at_node(node)
133-
if !(`typeof ReactDOM === 'undefined'`)
134-
`ReactDOM.unmountComponentAtNode(node.$$class ? node[0] : node)` # v0.15+
175+
raise "ReactDOM is not defined. In React >= v15 you must import it with ReactDOM" if `typeof ReactDOM === 'undefined'`
176+
node = `node.$$class ? node[0] : node`
177+
if `node.__hyperstackReactRoot` # React 18+: unmount the createRoot we made
178+
%x{ node.__hyperstackReactRoot.unmount(); delete node.__hyperstackReactRoot; }
179+
true
180+
elsif `typeof ReactDOM.unmountComponentAtNode === 'function'` # React < 18
181+
`ReactDOM.unmountComponentAtNode(node)`
135182
else
136-
raise "unmountComponentAtNode is not defined. In React >= v15 you must import it with ReactDOM"
183+
false
137184
end
138185
end
139186

‎ruby/hyper-component/lib/hyperstack/internal/component/instance_methods.rb‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,27 @@ def props
2424
end
2525

2626
def dom_node
27-
`ReactDOM.findDOMNode(#{self}.__hyperstack_component_native)` # react >= v0.15.0
27+
# React 18 (#18): resolve the DOM node by walking the component's own React
28+
# fiber (`_reactInternals`) to its first host node. This is robust whenever the
29+
# component is mounted — independent of ref-callback timing (the earlier
30+
# ref-chain approach raised "instance not mounted yet" in transient states like
31+
# while-loading) — and avoids the deprecated findDOMNode console.error. Falls
32+
# back to findDOMNode if the fiber shape is unknown / pre-18.
33+
%x{
34+
var native = #{self}.__hyperstack_component_native;
35+
var fiber = native._reactInternals || native._reactInternalFiber;
36+
var walk = function(node) {
37+
for (var n = node; n; n = n.sibling) {
38+
if (n.stateNode && n.stateNode.nodeType) { return n.stateNode; }
39+
var deep = n.child ? walk(n.child) : null;
40+
if (deep) { return deep; }
41+
}
42+
return null;
43+
};
44+
var found = fiber ? walk(fiber.child) : null;
45+
if (found) { return found; }
46+
return (typeof ReactDOM !== 'undefined' && ReactDOM.findDOMNode) ? ReactDOM.findDOMNode(native) : null;
47+
}
2848
end
2949

3050
def jq_node
@@ -45,8 +65,23 @@ def pluralize(count, singular, plural = nil)
4565
"#{count || 0} #{word}"
4666
end
4767

68+
# React 18 (#18): forceUpdate/setState are async (automatic batching), so a
69+
# synchronous read after force_update! would see the pre-render value.
70+
# flushSync (when present) restores Hyperstack's synchronous-update contract.
4871
def force_update!
49-
`#{self}.__hyperstack_component_native.forceUpdate()`
72+
%x{
73+
var native = #{self}.__hyperstack_component_native;
74+
// React 18 (#18): flushSync gives the synchronous-update contract, but React
75+
// warns and refuses to flush when called while it is already rendering or
76+
// committing (force_update! from a lifecycle like after_mount, or mutate during
77+
// render). There, fall back to a plain forceUpdate (applied in the current
78+
// cycle); use flushSync only when called from outside React (e.g. event handlers).
79+
if (typeof ReactDOM !== 'undefined' && ReactDOM.flushSync && !(Opal.global.__hyperstack_in_react > 0)) {
80+
ReactDOM.flushSync(function(){ native.forceUpdate(); });
81+
} else {
82+
native.forceUpdate();
83+
}
84+
}
5085
self
5186
end
5287

@@ -56,7 +91,19 @@ def set_state(state, &block)
5691

5792
def set_state!(state, &block)
5893
set_or_replace_state_or_prop(state, 'setState', &block)
59-
`#{self}.__hyperstack_component_native.forceUpdate()`
94+
%x{
95+
var native = #{self}.__hyperstack_component_native;
96+
// React 18 (#18): flushSync gives the synchronous-update contract, but React
97+
// warns and refuses to flush when called while it is already rendering or
98+
// committing (force_update! from a lifecycle like after_mount, or mutate during
99+
// render). There, fall back to a plain forceUpdate (applied in the current
100+
// cycle); use flushSync only when called from outside React (e.g. event handlers).
101+
if (typeof ReactDOM !== 'undefined' && ReactDOM.flushSync && !(Opal.global.__hyperstack_in_react > 0)) {
102+
ReactDOM.flushSync(function(){ native.forceUpdate(); });
103+
} else {
104+
native.forceUpdate();
105+
}
106+
}
60107
end
61108

62109
# https://github.com/hyperstack-org/hyperstack/issues/363

‎ruby/hyper-component/lib/hyperstack/internal/component/rails.rb‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
if defined?(Rails)
22
require 'action_view'
33
require 'react-rails'
4+
# #29 connection_pool shim removed: react-rails 3.3+ builds its server-rendering
5+
# pool with keyword args natively (`ConnectionPool.new(**options)`), so the
6+
# positional-hash workaround for react-rails 2.7.1 is no longer needed.
47
require 'hyperstack/internal/component/rails'
58
require 'hyperstack/internal/component/rails/server_rendering/hyper_asset_container'
69
require 'hyperstack/internal/component/rails/server_rendering/contextual_renderer'

‎ruby/hyper-component/lib/hyperstack/internal/component/rails/server_rendering/contextual_renderer.rb‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ def initialize(options = {})
1717
raise "Hyperstack prerendering only works with MiniRacer. Add 'mini_racer' to your Gemfile"
1818
end
1919

20-
super({ files: ['hyperstack-prerender-loader.js'] }.merge(options))
20+
# honor Hyperstack.prerendering_files (default ['hyperstack-prerender-loader.js'])
21+
# so apps can prepend a server React bundle (e.g. esbuild output, #19),
22+
# loaded raw + in order before the Opal prerender loader.
23+
super({ files: Hyperstack.prerendering_files }.merge(options))
2124
ComponentLoader.new(v8_context).load
2225
end
2326

‎ruby/hyper-component/lib/hyperstack/internal/component/rails/server_rendering/hyper_asset_container.rb‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
require 'react/server_rendering/environment_container'
22
require 'react/server_rendering/manifest_container'
3-
require 'react/server_rendering/webpacker_manifest_container'
3+
begin
4+
# react-rails 2.x ships a Webpacker container; 3.x dropped it along with
5+
# Webpacker support. Both majors are supported now (a matrix cell selects
6+
# which -- see supported_versions.yml), so require it optionally rather than
7+
# forcing one. (#51)
8+
require 'react/server_rendering/webpacker_manifest_container'
9+
rescue LoadError
10+
nil
11+
end
412

513
module Hyperstack
614
module Internal
@@ -23,7 +31,8 @@ def initialize
2331
else
2432
@ass_containers << React::ServerRendering::EnvironmentContainer.new if ::Rails.application.assets
2533
end
26-
if React::ServerRendering::WebpackerManifestContainer.compatible?
34+
if defined?(React::ServerRendering::WebpackerManifestContainer) &&
35+
React::ServerRendering::WebpackerManifestContainer.compatible?
2736
@ass_containers << React::ServerRendering::WebpackerManifestContainer.new
2837
end
2938
@ass_containers << HyperTestAssetContainer.new

0 commit comments

Comments
 (0)