Skip to content

Commit

Permalink
Add support for streaming components
Browse files Browse the repository at this point in the history
Update CHANGELOG.md

tmp

add support for streaming rendered component using renderToPipeableStream

put ROR scripts after the first rendered chunk

tmp
  • Loading branch information
justin808 authored and AbanoubGhadban committed Jul 30, 2024
1 parent 9b1eb68 commit 71e3844
Show file tree
Hide file tree
Showing 12 changed files with 207 additions and 52 deletions.
17 changes: 12 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@ All notable changes to this project's source code will be documented in this fil
Migration instructions for the major updates can be found [here](https://www.shakacode.com/react-on-rails/docs/guides/upgrading-react-on-rails#upgrading-to-version-9.md). Some smaller migration information can be found here.

## Want to Save Time Updating?
If you would like help in migrating between React on Rails versions or help with implementing server rendering, please contact [[email protected]](mailto:[email protected]) for information about our [React on Rails Pro Support Options](https://www.shakacode.com/react-on-rails-pro).

We specialize in helping companies quickly and efficiently update client-side dependencies, allowing developers to focus on features and bug fixes rather than addressing tech-debt from old dependencies. ShakaCode also maintains Shakapacker, the successor to Webpacker.
If you need help upgrading `react_on_rails`, `webpacker` to `shakapacker`, or JS packages, contact [email protected]. We can upgrade your project and improve your development and customer experiences, allowing you to focus on building new features or fixing bugs instead.

For an overview of working with us, see our [Client Engagement Model](https://www.shakacode.com/blog/client-engagement-model/) article and [how we bill for time](https://www.shakacode.com/blog/shortcut-jira-trello-github-toggl-time-and-task-tracking/).

If you think ShakaCode can help your project, [click here](https://meetings.hubspot.com/justingordon/30-minute-consultation) to book a call with [Justin Gordon](mailto:[email protected]), the creator of React on Rails and Shakapacker.

## Contributors
Please follow the recommendations outlined at [keepachangelog.com](http://keepachangelog.com/). Please use the existing headings and styling as a guide, and add a link for the version diff at the bottom of the file. Also, please update the `Unreleased` link to compare to the latest release version.

## Versions
### [Unreleased]
Changes since the last non-beta release.
#### Added
- Pack Generation: Added functionality that will add an import statement, if missing, to the server bundle entrypoint even if the autobundle generated files still exist [PR 1610](https://github.com/shakacode/react_on_rails/pull/1610) by [judahmeek](https://github.com/judahmeek).

### [14.0.1] - 2024-05-16

#### Fixed
- Pack Generation: Added functionality that will add an import statement, if missing, to the server bundle entry point even if the auto-bundle generated files still exist [PR 1610](https://github.com/shakacode/react_on_rails/pull/1610) by [judahmeek](https://github.com/judahmeek).

### [14.0.0] - 2024-04-03
_Major bump because dropping support for Ruby 2.7 and deprecated `webpackConfigLoader.js`._
Expand Down Expand Up @@ -1122,7 +1128,8 @@ Best done with Object destructing:
##### Fixed
- Fix several generator-related issues.

[Unreleased]: https://github.com/shakacode/react_on_rails/compare/14.0.0...master
[Unreleased]: https://github.com/shakacode/react_on_rails/compare/14.0.1...master
[14.0.1]: https://github.com/shakacode/react_on_rails/compare/14.0.0...14.0.1
[14.0.0]: https://github.com/shakacode/react_on_rails/compare/13.4.0...14.0.0
[13.4.0]: https://github.com/shakacode/react_on_rails/compare/13.3.5...13.4.0
[13.3.5]: https://github.com/shakacode/react_on_rails/compare/13.3.4...13.3.5
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
react_on_rails (14.0.0)
react_on_rails (14.0.1)
addressable
connection_pool
execjs (~> 2.5)
Expand Down
67 changes: 59 additions & 8 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,26 @@ 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

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:
Expand Down Expand Up @@ -361,6 +381,32 @@ 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.transform do |chunk|
if is_first_chunk
is_first_chunk = false
next <<-HTML
#{rails_context_if_not_already_rendered}
#{component_specification_tag}
<#{content_tag_options_html_tag} id="#{render_options.dom_id}">#{chunk}</#{content_tag_options_html_tag}>
HTML
end
chunk
end

rendered_html_stream.transform(&:html_safe)
# 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"),
Expand Down Expand Up @@ -404,20 +450,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 = {})
Expand Down Expand Up @@ -512,6 +560,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,
Expand Down
4 changes: 4 additions & 0 deletions lib/react_on_rails/react_component/render_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ def set_option(key, value)
options[key] = value
end

def stream?
options[:stream?]
end

private

attr_reader :options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion node_package/src/ReactOnRails.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -241,6 +242,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
Expand Down
87 changes: 85 additions & 2 deletions node_package/src/serverRenderReactComponent.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -69,7 +71,34 @@ See https://github.com/shakacode/react_on_rails#renderer-functions`);

const processReactElement = () => {
try {
return ReactDOMServer.renderToString(reactRenderingResult as ReactElement);
// const readableStreamPromise = ReactDOMServer.renderToReadableStream(reactRenderingResult as ReactElement);
// return readableStreamPromise.then(async (readableStream) => {
// const reader = readableStream.getReader();
// let html = '';
// while (true) {
// const { done, value } = await reader.read();
// if (done) {
// break;
// }
// html += value;
// }
// return html;
// })
const pipeableStream = ReactDOMServer.renderToPipeableStream(reactRenderingResult as ReactElement);
return new Promise<string>((resolve, reject) => {
let html = '';
const stream = new PassThrough();
stream.on('data', (chunk) => {
html += chunk.toString();
});
stream.on('end', () => {
resolve(html);
});
stream.on('error', reject);
pipeableStream.pipe(stream);

});
// return ReactDOMServer.renderToString(reactRenderingResult as ReactElement);
} catch (error) {
console.error(`Invalid call to renderToString. Possibly you have a renderFunction, a function that already
calls renderToString, that takes one parameter. You need to add an extra unused parameter to identify this function
Expand All @@ -78,11 +107,15 @@ as a renderFunction and not a simple React Function Component.`);
}
};

console.log('\n\n\n\n\n\n\n\n\n\n\n\nserverRenderReactComponentInternal\n\n\n\n\n\n\n\n\n\n\n\n', React.version);
if (isServerRenderHash(reactRenderingResult)) {
console.log('isServerRenderHash');
renderResult = processServerRenderHash();
} else if (isPromise(reactRenderingResult)) {
console.log('isPromise');
renderResult = processPromise() as Promise<string>;
} else {
console.log('isReactElement', React.version);
renderResult = processReactElement();
}
} catch (e: any) {
Expand Down Expand Up @@ -165,4 +198,54 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o
console.history = [];
}
};

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;
2 changes: 2 additions & 0 deletions node_package/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -137,6 +138,7 @@ export interface ReactOnRails {
): RenderReturnType;
getComponent(name: string): RegisteredComponent;
serverRenderReactComponent(options: RenderParams): null | string | Promise<RenderResult>;
streamServerRenderedReactComponent(options: RenderParams): PassThrough;
handleError(options: ErrorOptions): string | undefined;
buildConsoleReplay(): string;
registeredComponents(): Map<string, RegisteredComponent>;
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions spec/dummy/config/webpack/alias.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module.exports = {
resolve: {
alias: {
Assets: resolve(__dirname, '..', '..', 'client', 'app', 'assets'),
stream: 'stream-browserify'
},
},
};
1 change: 1 addition & 0 deletions spec/dummy/config/webpack/webpackConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 71e3844

Please sign in to comment.