From 7e4d04bfea59d1e146617d8416003e46b696ca2b Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 11 Jun 2024 14:51:58 +0300 Subject: [PATCH 01/25] tmp --- node_package/src/ReactOnRails.ts | 11 +++- .../src/serverRenderReactComponent.ts | 53 ++++++++++++++++++- node_package/src/types/index.ts | 2 + package.json | 8 +-- spec/dummy/config/webpack/alias.js | 1 + spec/dummy/config/webpack/webpackConfig.js | 1 + yarn.lock | 53 ++++++++----------- 7 files changed, 92 insertions(+), 37 deletions(-) diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index cc8006ea7..b6377bb34 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -1,10 +1,11 @@ import type { ReactElement } from 'react'; +import type { PassThrough } from 'stream'; import * as ClientStartup from './clientStartup'; import handleError from './handleError'; import ComponentRegistry from './ComponentRegistry'; import StoreRegistry from './StoreRegistry'; -import serverRenderReactComponent from './serverRenderReactComponent'; +import serverRenderReactComponent, { streamServerRenderedReactComponent } from './serverRenderReactComponent'; import buildConsoleReplay from './buildConsoleReplay'; import createReactOutput from './createReactOutput'; import Authenticity from './Authenticity'; @@ -247,6 +248,14 @@ ctx.ReactOnRails = { return serverRenderReactComponent(options); }, + /** + * Used by server rendering by Rails + * @param options + */ + streamServerRenderedReactComponent(options: RenderParams): PassThrough { + return streamServerRenderedReactComponent(options); + }, + /** * Used by Rails to catch errors in rendering * @param options diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index bbc1b53ef..ed38588fb 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -1,4 +1,6 @@ -import ReactDOMServer from 'react-dom/server'; +import React from 'react'; +import ReactDOMServer, { PipeableStream } from 'react-dom/server'; +import { PassThrough } from 'stream'; import type { ReactElement } from 'react'; import ComponentRegistry from './ComponentRegistry'; @@ -192,4 +194,53 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o } }; +const stringToStream = (str: string) => { + const stream = new PassThrough(); + stream.push(str); + stream.push(null); + return stream; +}; + +export const streamServerRenderedReactComponent = (options: RenderParams) => { + const { name, domNodeId, trace, props, railsContext, renderingReturnsPromises, throwJsErrors } = options; + + let renderResult: null | PassThrough = null; + + try { + const componentObj = ComponentRegistry.get(name); + if (componentObj.isRenderer) { + throw new Error(`\ +Detected a renderer while server rendering component '${name}'. \ +See https://github.com/shakacode/react_on_rails#renderer-functions`); + } + + const reactRenderingResult = createReactOutput({ + componentObj, + domNodeId, + trace, + props, + railsContext, + }); + + if (isServerRenderHash(reactRenderingResult) || isPromise(reactRenderingResult)) { + throw new Error('Server rendering of streams is not supported for server render hashes or promises.'); + } + + renderResult = new PassThrough(); + ReactDOMServer.renderToPipeableStream(reactRenderingResult as ReactElement).pipe(renderResult); + } catch (e: any) { + if (throwJsErrors) { + throw e; + } + + renderResult = stringToStream(handleError({ + e, + name, + serverSide: true, + })); + } + + return renderResult; +}; + export default serverRenderReactComponent; diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index d2e129db5..5d4d5dbec 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -1,4 +1,5 @@ import type { ReactElement, ReactNode, Component, ComponentType } from 'react'; +import type { PassThrough } from 'stream'; // Don't import redux just for the type definitions // See https://github.com/shakacode/react_on_rails/issues/1321 @@ -168,6 +169,7 @@ export interface ReactOnRails { ): RenderReturnType; getComponent(name: string): RegisteredComponent; serverRenderReactComponent(options: RenderParams): null | string | Promise; + streamServerRenderedReactComponent(options: RenderParams): PassThrough; handleError(options: ErrorOptions): string | undefined; buildConsoleReplay(): string; registeredComponents(): Map; diff --git a/package.json b/package.json index ad565cfb6..fc29157fe 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "@babel/preset-react": "^7.18.6", "@babel/types": "^7.20.7", "@types/jest": "^29.0.0", - "@types/react": "^17.0.0", - "@types/react-dom": "^17.0.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", "@types/turbolinks": "^5.2.2", "@types/webpack-env": "^1.18.4", "@typescript-eslint/eslint-plugin": "^6.18.1", @@ -39,8 +39,8 @@ "prettier": "^2.8.8", "prettier-eslint-cli": "^5.0.0", "prop-types": "^15.8.1", - "react": "^17.0.0", - "react-dom": "^17.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-transform-hmr": "^1.0.4", "redux": "^4.2.1", "ts-jest": "^29.1.0", diff --git a/spec/dummy/config/webpack/alias.js b/spec/dummy/config/webpack/alias.js index 5645c184a..e4bc61bcc 100644 --- a/spec/dummy/config/webpack/alias.js +++ b/spec/dummy/config/webpack/alias.js @@ -4,6 +4,7 @@ module.exports = { resolve: { alias: { Assets: resolve(__dirname, '..', '..', 'client', 'app', 'assets'), + stream: 'stream-browserify' }, }, }; diff --git a/spec/dummy/config/webpack/webpackConfig.js b/spec/dummy/config/webpack/webpackConfig.js index 75747b455..3f99331fe 100644 --- a/spec/dummy/config/webpack/webpackConfig.js +++ b/spec/dummy/config/webpack/webpackConfig.js @@ -4,6 +4,7 @@ const serverWebpackConfig = require('./serverWebpackConfig'); const webpackConfig = (envSpecific) => { const clientConfig = clientWebpackConfig(); const serverConfig = serverWebpackConfig(); + clientConfig.resolve.fallback = { stream: require.resolve('stream-browserify') }; if (envSpecific) { envSpecific(clientConfig, serverConfig); diff --git a/yarn.lock b/yarn.lock index b97d5894e..2d0d29ce3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1777,27 +1777,21 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== -"@types/react-dom@^17.0.0": - version "17.0.25" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.25.tgz#e0e5b3571e1069625b3a3da2b279379aa33a0cb5" - integrity sha512-urx7A7UxkZQmThYA4So0NelOVjx3V4rNFVJwp0WZlbIK5eM4rNJDiN3R/E9ix0MBh6kAEojk/9YL+Te6D9zHNA== +"@types/react-dom@^18.2.0": + version "18.3.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" + integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== dependencies: - "@types/react" "^17" + "@types/react" "*" -"@types/react@^17", "@types/react@^17.0.0": - version "17.0.74" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.74.tgz#ea93059a55e5cfc7a76e7712fe8db5317dd29ee3" - integrity sha512-nBtFGaeTMzpiL/p73xbmCi00SiCQZDTJUk9ZuHOLtil3nI+y7l269LHkHIAYpav99ZwGnPJzuJsJpfLXjiQ52g== +"@types/react@*", "@types/react@^18.2.0": + version "18.3.3" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f" + integrity sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw== dependencies: "@types/prop-types" "*" - "@types/scheduler" "*" csstype "^3.0.2" -"@types/scheduler@*": - version "0.16.2" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" - integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== - "@types/semver@^7.5.0": version "7.5.6" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339" @@ -5883,14 +5877,13 @@ react-deep-force-update@^1.0.0: resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.1.2.tgz#3d2ae45c2c9040cbb1772be52f8ea1ade6ca2ee1" integrity sha512-WUSQJ4P/wWcusaH+zZmbECOk7H5N2pOIl0vzheeornkIMhu+qrNdGFm0bDZLCb0hSF0jf/kH1SgkNGfBdTc4wA== -react-dom@^17.0.0: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" - integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== +react-dom@^18.2.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" - scheduler "^0.20.2" + scheduler "^0.23.2" react-is@^16.13.1: version "16.13.1" @@ -5918,13 +5911,12 @@ react-transform-hmr@^1.0.4: global "^4.3.0" react-proxy "^1.1.7" -react@^17.0.0: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" - integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== +react@^18.2.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" readdirp@~3.6.0: version "3.6.0" @@ -6221,13 +6213,12 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" - integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" semver@5.5.0: version "5.5.0" From 4fc054cde8d857723266987755686dbf11ebc9e4 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 17 Jun 2024 17:43:22 +0300 Subject: [PATCH 02/25] add support for streaming rendered component using renderToPipeableStream --- lib/react_on_rails/helper.rb | 62 ++++++++++++++++--- .../react_component/render_options.rb | 4 ++ .../ruby_embedded_java_script.rb | 6 ++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 97e0953b2..9026a1ab5 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -91,6 +91,16 @@ def react_component(component_name, options = {}) end end + def stream_react_component(component_name, options = {}) + options = options.merge(stream?: true) + result = internal_react_component(component_name, options) + build_react_component_result_for_server_streamed_content( + rendered_html_stream: result[:result], + component_specification_tag: result[:tag], + render_options: result[:render_options] + ) + end + # react_component_hash is used to return multiple HTML strings for server rendering, such as for # adding meta-tags to a page. # It is exactly like react_component except for the following: @@ -334,6 +344,10 @@ def generated_components_pack_path(component_name) "#{ReactOnRails::PackerUtils.packer_source_entry_path}/generated/#{component_name}.js" end + def get_content_tag_options_html_tag(render_options) + + end + def build_react_component_result_for_server_rendered_string( server_rendered_html: required("server_rendered_html"), component_specification_tag: required("component_specification_tag"), @@ -361,6 +375,33 @@ def build_react_component_result_for_server_rendered_string( prepend_render_rails_context(result) end + def build_react_component_result_for_server_streamed_content( + rendered_html_stream: required("rendered_html_stream"), + component_specification_tag: required("component_specification_tag"), + render_options: required("render_options") + ) + content_tag_options_html_tag = render_options.html_options[:tag] || 'div' + # The component_specification_tag is appended to the first chunk + # We need to pass it early with the first chunk because it's needed in hydration + # We need to make sure that client can hydrate the app early even before all components are streamed + is_first_chunk = true + + rendered_html_stream = rendered_html_stream.prepend { rails_context_if_not_already_rendered } + .prepend { "<#{content_tag_options_html_tag} id=\"#{render_options.dom_id}\">" } + .transform(&:html_safe) + + rendered_html_stream = rendered_html_stream.transform do |chunk| + is_first_chunk = false + if is_first_chunk + return "#{chunk}\n#{component_specification_tag}" + end + chunk + end + .append { "" } + .append { component_specification_tag } + # TODO: handle console logs + end + def build_react_component_result_for_server_rendered_hash( server_rendered_html: required("server_rendered_html"), component_specification_tag: required("component_specification_tag"), @@ -404,20 +445,22 @@ def compose_react_component_html_with_spec_and_console(component_specification_t HTML end - # prepend the rails_context if not yet applied - def prepend_render_rails_context(render_value) - return render_value if @rendered_rails_context + def rails_context_if_not_already_rendered + return "" if @rendered_rails_context data = rails_context(server_side: false) @rendered_rails_context = true - rails_context_content = content_tag(:script, - json_safe_and_pretty(data).html_safe, - type: "application/json", - id: "js-react-on-rails-context") + content_tag(:script, + json_safe_and_pretty(data).html_safe, + type: "application/json", + id: "js-react-on-rails-context") + end - "#{rails_context_content}\n#{render_value}".html_safe + # prepend the rails_context if not yet applied + def prepend_render_rails_context(render_value) + "#{rails_context_if_not_already_rendered}\n#{render_value}".html_safe end def internal_react_component(react_component_name, options = {}) @@ -520,6 +563,9 @@ def server_rendered_react_component(render_options) js_code: js_code) end + # TODO: handle errors for streams + return result if render_options.stream? + if result["hasErrors"] && render_options.raise_on_prerender_error # We caught this exception on our backtrace handler raise ReactOnRails::PrerenderError.new(component_name: react_component_name, diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index f73415bc8..8bb8536ed 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -107,6 +107,10 @@ def set_option(key, value) options[key] = value end + def stream? + options[:stream?] + end + private attr_reader :options diff --git a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb index d1e7212d8..2dcd3eb80 100644 --- a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +++ b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb @@ -92,6 +92,12 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil) end # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity + # TODO: merge with exec_server_render_js + def exec_server_render_streaming_js(js_code, render_options, js_evaluator = nil) + js_evaluator ||= self + js_evaluator.eval_streaming_js(js_code, render_options) + end + def trace_js_code_used(msg, js_code, file_name = "tmp/server-generated.js", force: false) return unless ReactOnRails.configuration.trace || force From 4f95d2d1b19f1c5f35898ae689d63b3f0b553dbc Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 18 Jun 2024 17:33:49 +0300 Subject: [PATCH 03/25] put ROR scripts after the first rendered chunk --- lib/react_on_rails/helper.rb | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 9026a1ab5..23da82e69 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -344,10 +344,6 @@ def generated_components_pack_path(component_name) "#{ReactOnRails::PackerUtils.packer_source_entry_path}/generated/#{component_name}.js" end - def get_content_tag_options_html_tag(render_options) - - end - def build_react_component_result_for_server_rendered_string( server_rendered_html: required("server_rendered_html"), component_specification_tag: required("component_specification_tag"), @@ -380,24 +376,24 @@ def build_react_component_result_for_server_streamed_content( component_specification_tag: required("component_specification_tag"), render_options: required("render_options") ) - content_tag_options_html_tag = render_options.html_options[:tag] || 'div' # The component_specification_tag is appended to the first chunk # We need to pass it early with the first chunk because it's needed in hydration # We need to make sure that client can hydrate the app early even before all components are streamed is_first_chunk = true - - rendered_html_stream = rendered_html_stream.prepend { rails_context_if_not_already_rendered } - .prepend { "<#{content_tag_options_html_tag} id=\"#{render_options.dom_id}\">" } - .transform(&:html_safe) - rendered_html_stream = rendered_html_stream.transform do |chunk| - is_first_chunk = false if is_first_chunk - return "#{chunk}\n#{component_specification_tag}" + is_first_chunk = false + next "#{chunk}\n#{component_specification_tag}" end chunk end - .append { "" } + + content_tag_options_html_tag = render_options.html_options[:tag] || "div" + rendered_html_stream = rendered_html_stream.prepend { rails_context_if_not_already_rendered } + .prepend { "<#{content_tag_options_html_tag} id=\"#{render_options.dom_id}\">" } + .transform(&:html_safe) + + rendered_html_stream.append { "" } .append { component_specification_tag } # TODO: handle console logs end From a65153503084f79c6327ccf9c1564430f04eedac Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 23 Jul 2024 15:09:44 +0300 Subject: [PATCH 04/25] remove log statements --- node_package/src/serverRenderReactComponent.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index ed38588fb..f795df92f 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -202,7 +202,7 @@ const stringToStream = (str: string) => { }; export const streamServerRenderedReactComponent = (options: RenderParams) => { - const { name, domNodeId, trace, props, railsContext, renderingReturnsPromises, throwJsErrors } = options; + const { name, domNodeId, trace, props, railsContext, throwJsErrors } = options; let renderResult: null | PassThrough = null; @@ -228,6 +228,9 @@ See https://github.com/shakacode/react_on_rails#renderer-functions`); renderResult = new PassThrough(); ReactDOMServer.renderToPipeableStream(reactRenderingResult as ReactElement).pipe(renderResult); + + // TODO: Add console replay script to the stream + // Ensure to avoid console messages leaking between different components rendering } catch (e: any) { if (throwJsErrors) { throw e; From 337c5c2b21f762456ff57c3ab109fc07f6c43ff1 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 20 Jun 2024 19:50:32 +0300 Subject: [PATCH 05/25] add stream_react_component_async helper function --- lib/react_on_rails/helper.rb | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 23da82e69..cf1c8044f 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -101,6 +101,16 @@ def stream_react_component(component_name, options = {}) ) end + def stream_react_component_async(component_name, options = {}) + Fiber.new do + stream = stream_react_component(component_name, options) + stream.each_chunk do |chunk| + Fiber.yield chunk + end + Fiber.yield nil + end + end + # react_component_hash is used to return multiple HTML strings for server rendering, such as for # adding meta-tags to a page. # It is exactly like react_component except for the following: @@ -376,6 +386,7 @@ def build_react_component_result_for_server_streamed_content( component_specification_tag: required("component_specification_tag"), render_options: required("render_options") ) + content_tag_options_html_tag = render_options.html_options[:tag] || "div" # The component_specification_tag is appended to the first chunk # We need to pass it early with the first chunk because it's needed in hydration # We need to make sure that client can hydrate the app early even before all components are streamed @@ -383,18 +394,16 @@ def build_react_component_result_for_server_streamed_content( rendered_html_stream = rendered_html_stream.transform do |chunk| if is_first_chunk is_first_chunk = false - next "#{chunk}\n#{component_specification_tag}" + next <<-HTML + #{rails_context_if_not_already_rendered} + #{component_specification_tag} + <#{content_tag_options_html_tag} id="#{render_options.dom_id}">#{chunk} + HTML end chunk end - content_tag_options_html_tag = render_options.html_options[:tag] || "div" - rendered_html_stream = rendered_html_stream.prepend { rails_context_if_not_already_rendered } - .prepend { "<#{content_tag_options_html_tag} id=\"#{render_options.dom_id}\">" } - .transform(&:html_safe) - - rendered_html_stream.append { "" } - .append { component_specification_tag } + rendered_html_stream.transform(&:html_safe) # TODO: handle console logs end From 849a0c569337b96cfe419439ee57453ad10eafb5 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 24 Jul 2024 16:01:22 +0300 Subject: [PATCH 06/25] add helper function to render a whole view --- lib/react_on_rails/helper.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index cf1c8044f..1b9923e1c 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -102,13 +102,23 @@ def stream_react_component(component_name, options = {}) end def stream_react_component_async(component_name, options = {}) - Fiber.new do + rendering_fiber = Fiber.new do stream = stream_react_component(component_name, options) stream.each_chunk do |chunk| Fiber.yield chunk end Fiber.yield nil end + + if @rorp_rendering_fibers.nil? + raise ReactOnRails::Error, "You must call stream_view_containing_react_components to render the view containing the react component" + end + @rorp_rendering_fibers << rendering_fiber + + # return the first chunk of the fiber + # It contains the initial html of the component + # all updates will be appended to the stream sent to browser + rendering_fiber.resume end # react_component_hash is used to return multiple HTML strings for server rendering, such as for From 7ca17d1e3bb36531e47c0b311258044f89a83725 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 28 Jul 2024 16:50:39 +0300 Subject: [PATCH 07/25] fix failing jest tests --- jest.config.js | 1 + node_package/src/serverRenderReactComponent.ts | 3 +-- node_package/tests/ReactOnRails.test.js | 13 +++++++++---- node_package/tests/jest.setup.js | 13 +++++++++++++ 4 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 node_package/tests/jest.setup.js diff --git a/jest.config.js b/jest.config.js index ee6fa2d66..09319b866 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { preset: 'ts-jest/presets/js-with-ts', testEnvironment: 'jsdom', + setupFiles: ['/node_package/tests/jest.setup.js'], }; diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index f795df92f..75d75ad65 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -1,5 +1,4 @@ -import React from 'react'; -import ReactDOMServer, { PipeableStream } from 'react-dom/server'; +import ReactDOMServer from 'react-dom/server'; import { PassThrough } from 'stream'; import type { ReactElement } from 'react'; diff --git a/node_package/tests/ReactOnRails.test.js b/node_package/tests/ReactOnRails.test.js index da3a56adc..afa6f4c27 100644 --- a/node_package/tests/ReactOnRails.test.js +++ b/node_package/tests/ReactOnRails.test.js @@ -19,10 +19,15 @@ describe('ReactOnRails', () => { }); ReactOnRails.register({ R1 }); - document.body.innerHTML = '
'; - // eslint-disable-next-line no-underscore-dangle - const actual = ReactOnRails.render('R1', {}, 'root')._reactInternals.type; - expect(actual).toEqual(R1); + const root = document.createElement('div'); + root.id = 'root'; + root.textContent = ' WORLD '; + + document.body.innerHTML = ''; + document.body.appendChild(root); + ReactOnRails.render('R1', {}, 'root'); + + expect(document.getElementById('root').textContent).toEqual(' WORLD '); }); it('accepts traceTurbolinks as an option true', () => { diff --git a/node_package/tests/jest.setup.js b/node_package/tests/jest.setup.js new file mode 100644 index 000000000..454efc9cb --- /dev/null +++ b/node_package/tests/jest.setup.js @@ -0,0 +1,13 @@ +// If jsdom environment is set and TextEncoder is not defined, then define TextEncoder and TextDecoder +// The current version of jsdom does not support TextEncoder and TextDecoder +// The following code will tell us when jsdom supports TextEncoder and TextDecoder +if (typeof window !== 'undefined' && typeof window.TextEncoder !== 'undefined') { + throw new Error('TextEncoder is already defined, remove the polyfill'); +} + +if (typeof window !== 'undefined') { + // eslint-disable-next-line global-require + const { TextEncoder, TextDecoder } = require('util'); + global.TextEncoder = TextEncoder; + global.TextDecoder = TextDecoder; +} From 8e1c42d5e9b482d0cb41d61061677a3e89c1dda1 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 28 Jul 2024 16:52:45 +0300 Subject: [PATCH 08/25] linting --- lib/react_on_rails/helper.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 1b9923e1c..8b757d598 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -111,8 +111,10 @@ def stream_react_component_async(component_name, options = {}) end if @rorp_rendering_fibers.nil? - raise ReactOnRails::Error, "You must call stream_view_containing_react_components to render the view containing the react component" + raise ReactOnRails::Error, + "You must call stream_view_containing_react_components to render the view containing the react component" end + @rorp_rendering_fibers << rendering_fiber # return the first chunk of the fiber From 66d1cc2132bd99b99776b4af71fa4579fde49abe Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 28 Jul 2024 16:54:19 +0300 Subject: [PATCH 09/25] linting --- spec/dummy/config/webpack/alias.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/dummy/config/webpack/alias.js b/spec/dummy/config/webpack/alias.js index e4bc61bcc..3dd27b046 100644 --- a/spec/dummy/config/webpack/alias.js +++ b/spec/dummy/config/webpack/alias.js @@ -4,7 +4,7 @@ module.exports = { resolve: { alias: { Assets: resolve(__dirname, '..', '..', 'client', 'app', 'assets'), - stream: 'stream-browserify' + stream: 'stream-browserify', }, }, }; From b0861230d4766eeccac7156e8cad305f4ebf536b Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 28 Jul 2024 17:05:11 +0300 Subject: [PATCH 10/25] remove redundant new line when context is not prepended --- lib/react_on_rails/helper.rb | 3 ++- node_package/src/serverRenderReactComponent.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 8b757d598..a0671f99c 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -406,11 +406,12 @@ def build_react_component_result_for_server_streamed_content( rendered_html_stream = rendered_html_stream.transform do |chunk| if is_first_chunk is_first_chunk = false - next <<-HTML + html_content = <<-HTML #{rails_context_if_not_already_rendered} #{component_specification_tag} <#{content_tag_options_html_tag} id="#{render_options.dom_id}">#{chunk} HTML + html_content.strip end chunk end diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts index 75d75ad65..414c0ae03 100644 --- a/node_package/src/serverRenderReactComponent.ts +++ b/node_package/src/serverRenderReactComponent.ts @@ -226,7 +226,7 @@ See https://github.com/shakacode/react_on_rails#renderer-functions`); } renderResult = new PassThrough(); - ReactDOMServer.renderToPipeableStream(reactRenderingResult as ReactElement).pipe(renderResult); + ReactDOMServer.renderToPipeableStream(reactRenderingResult).pipe(renderResult); // TODO: Add console replay script to the stream // Ensure to avoid console messages leaking between different components rendering From 5d29c97454ce34f12efc23da7986682c22011d04 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 28 Jul 2024 17:13:35 +0300 Subject: [PATCH 11/25] rename stream_react_component_async to stream_react_component --- lib/react_on_rails/helper.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index a0671f99c..f64b2b0e8 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -92,18 +92,8 @@ def react_component(component_name, options = {}) end def stream_react_component(component_name, options = {}) - options = options.merge(stream?: true) - result = internal_react_component(component_name, options) - build_react_component_result_for_server_streamed_content( - rendered_html_stream: result[:result], - component_specification_tag: result[:tag], - render_options: result[:render_options] - ) - end - - def stream_react_component_async(component_name, options = {}) rendering_fiber = Fiber.new do - stream = stream_react_component(component_name, options) + stream = stream_react_component_internal(component_name, options) stream.each_chunk do |chunk| Fiber.yield chunk end @@ -362,6 +352,16 @@ def load_pack_for_generated_component(react_component_name, render_options) private + def stream_react_component_internal(component_name, options = {}) + options = options.merge(stream?: true) + result = internal_react_component(component_name, options) + build_react_component_result_for_server_streamed_content( + rendered_html_stream: result[:result], + component_specification_tag: result[:tag], + render_options: result[:render_options] + ) + end + def generated_components_pack_path(component_name) "#{ReactOnRails::PackerUtils.packer_source_entry_path}/generated/#{component_name}.js" end From 80f463eddf44efff9f0f95f857fc06c90a8a3ba6 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 28 Jul 2024 18:16:45 +0300 Subject: [PATCH 12/25] fix error caused by process on browser --- spec/dummy/config/webpack/commonWebpackConfig.js | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/dummy/config/webpack/commonWebpackConfig.js b/spec/dummy/config/webpack/commonWebpackConfig.js index 998c0d023..c268f81f8 100644 --- a/spec/dummy/config/webpack/commonWebpackConfig.js +++ b/spec/dummy/config/webpack/commonWebpackConfig.js @@ -41,6 +41,7 @@ baseClientWebpackConfig.plugins.push( new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery', + process: 'process/browser', }), ); From 1d4a68eca9ca16df527b535d82157f45d682daba Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sun, 28 Jul 2024 18:30:36 +0300 Subject: [PATCH 13/25] remove new line appended to the page when has no rails context --- lib/react_on_rails/helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index f64b2b0e8..3446c08e1 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -478,7 +478,7 @@ def rails_context_if_not_already_rendered # prepend the rails_context if not yet applied def prepend_render_rails_context(render_value) - "#{rails_context_if_not_already_rendered}\n#{render_value}".html_safe + "#{rails_context_if_not_already_rendered}\n#{render_value}".strip.html_safe end def internal_react_component(react_component_name, options = {}) From d3a4c640e88d8fb3d73d373e8c64699b300a1dcf Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 30 Jul 2024 16:49:23 +0300 Subject: [PATCH 14/25] fix the problem of not updating the first streaming chunk --- lib/react_on_rails/helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 3446c08e1..74de369e3 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -411,7 +411,7 @@ def build_react_component_result_for_server_streamed_content( #{component_specification_tag} <#{content_tag_options_html_tag} id="#{render_options.dom_id}">#{chunk} HTML - html_content.strip + next html_content.strip end chunk end From 5b96a6a83c608a112541fae8f66eb0159857816c Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 19 Aug 2024 16:27:27 +0300 Subject: [PATCH 15/25] rename stream_react_component_internal to internal_stream_react_component --- lib/react_on_rails/helper.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 74de369e3..3c31502c1 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -93,7 +93,7 @@ def react_component(component_name, options = {}) def stream_react_component(component_name, options = {}) rendering_fiber = Fiber.new do - stream = stream_react_component_internal(component_name, options) + stream = internal_stream_react_component(component_name, options) stream.each_chunk do |chunk| Fiber.yield chunk end @@ -352,7 +352,7 @@ def load_pack_for_generated_component(react_component_name, render_options) private - def stream_react_component_internal(component_name, options = {}) + def internal_stream_react_component(component_name, options = {}) options = options.merge(stream?: true) result = internal_react_component(component_name, options) build_react_component_result_for_server_streamed_content( From f1c3dbf605e422c65bf8f1e052f21094d005af2c Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 19 Aug 2024 20:55:24 +0300 Subject: [PATCH 16/25] add unit tests for rails_context_if_not_already_rendered --- .../helpers/react_on_rails_helper_spec.rb | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb index fc05f73ab..d045801e9 100644 --- a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb +++ b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb @@ -7,6 +7,7 @@ class PlainReactOnRailsHelper include ReactOnRailsHelper + include ActionView::Helpers::TagHelper end # rubocop:disable Metrics/BlockLength @@ -365,5 +366,30 @@ class PlainReactOnRailsHelper expect { ob.send(:rails_context, server_side: false) }.not_to raise_error end end + + describe "#rails_context_if_not_already_rendered", :focus do + let(:helper) { PlainReactOnRailsHelper.new } + + before do + allow(helper).to receive(:rails_context).and_return({ some: "context" }) + end + + it "returns a script tag with rails context when not already rendered" do + result = helper.send(:rails_context_if_not_already_rendered) + expect(result).to include(' +``` + +## When to Use Streaming + +Streaming SSR is particularly valuable in specific scenarios. Here's when to consider it: + +### Ideal Use Cases + +1. **Data-Heavy Pages** + - Pages that fetch data from multiple sources + - Dashboard-style layouts where different sections can load independently + - Content that requires heavy processing or computation + +2. **Progressive Enhancement** + - When you want users to see and interact with parts of the page while others load + - For improving perceived performance on slower connections + - When different parts of your page have different priority levels + +3. **Large, Complex Applications** + - Applications with multiple independent widgets or components + - Pages where some content is critical and other content is supplementary + - When you need to optimize Time to First Byte (TTFB) + +### Best Practices for Streaming + +1. **Component Structure** + ```jsx + // Good: Independent sections that can stream separately + + }> +
+ + }> + + + }> + + + + + // Bad: Everything wrapped in a single Suspense boundary + }> +
+ + + + ``` + +2. **Data Loading Strategy** + - Prioritize critical data that should be included in the initial HTML + - Use streaming for supplementary data that can load progressively + - Consider implementing a waterfall strategy for dependent data From 2d49ce795c4cc19791d35904d191ec9eb236e96e Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 29 Oct 2024 13:25:23 +0300 Subject: [PATCH 25/25] update docs --- docs/guides/streaming-server-rendering.md | 30 +++++++++++++++++------ 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/guides/streaming-server-rendering.md b/docs/guides/streaming-server-rendering.md index 2833f8dc5..3586f9f0d 100644 --- a/docs/guides/streaming-server-rendering.md +++ b/docs/guides/streaming-server-rendering.md @@ -7,6 +7,7 @@ React on Rails Pro supports streaming server rendering using React 18's latest A - React on Rails Pro subscription - React 18 or higher (experimental version) - React on Rails v15.0.0-alpha.0 or higher +- React on Rails Pro v4.0.0.rc.5 or higher ## Benefits of Streaming Server Rendering @@ -29,23 +30,32 @@ First, ensure you're using React 18's experimental version in your package.json: } ``` +> Note: Check the React documentation for the latest release that supports streaming. + 2. **Prepare Your React Components** You can create async React components that return a promise. Then, you can use the `Suspense` component to render a fallback UI while the component is loading. ```jsx // app/javascript/components/MyStreamingComponent.jsx - import React, { Suspense } from 'react'; +const fetchData = async () => { + // Simulate API call + const response = await fetch('api/endpoint'); + return response.json(); +}; + const MyStreamingComponent = () => { return ( -
-

Streaming Server Rendering

-
- Loading...}> - - + <> +
+

Streaming Server Rendering

+
+ Loading...}> + + + ); }; @@ -55,9 +65,15 @@ const SlowDataComponent = async () => { }; export default MyStreamingComponent; +``` + +```jsx +// app/javascript/packs/registration.jsx +import MyStreamingComponent from '../components/MyStreamingComponent'; ReactOnRails.register({ MyStreamingComponent }); ``` + 3. **Add The Component To Your Rails View** ```erb