Skip to content

Commit

Permalink
Enable server side rendering of HTML
Browse files Browse the repository at this point in the history
Superglue does not support server side rendering of HTML out of the box. The
functionality is available using [Humid] and is [documented].

Turbolinks + Stimulus has the upper hand here. With Superglue and React, there
are some upfront costs to SSR:

1. Setup costs. SSR is run on the server side, and Browser APIs like the dom,
window, or DOMParser are not available. Any Polyfill or browser specific
behavior has to be moved to `componentDidMount`. This method never executes
when used with ReactDOMServer.

2. Thoughtfulness. APIs differ depending on the JS runtime. Most JS packages are
build with NodeJS, but its possible to use a different runtime that has a very
different API. In this commit we are using [mini_racer] which only has a Least Common
Denominator API across different JS runtimes. Care must be taken with package
selection to ensure that they can run where they are used.

Generally the strategy is to build for the `browser` and polyfill where possible.
In this commit, we need to shim a `TextEncoder` and polyfill `path` using
[esbuild-plugin-polyfill-node].

Long term costs:
3. It is another layer of tech that can go wrong and you have to debug.

4. Added latency. SSR with React is CPU bound, expect an additional 7ms added
on production with React's production build, and 30ms during development.

[mini_racer]: https://github.com/rubyjs/mini_racer
[esbuild-plugin-polyfill-node]: https://github.com/cyco130/esbuild-plugin-polyfill-node
  • Loading branch information
jho406 committed Oct 8, 2023
1 parent 7d0cb9a commit cd931af
Show file tree
Hide file tree
Showing 15 changed files with 203 additions and 12 deletions.
1 change: 1 addition & 0 deletions Gemfile
Expand Up @@ -51,3 +51,4 @@ gem 'superglue', path: '/Users/johnyho/superglue/superglue_rails'

gem "form_props", "~> 0.0.3"
gem 'active_link_to'
gem 'humid'
7 changes: 7 additions & 0 deletions Gemfile.lock
Expand Up @@ -108,13 +108,17 @@ GEM
props_template (>= 0.23.0)
globalid (1.2.1)
activesupport (>= 6.1)
humid (0.0.6)
activesupport (>= 7.0)
mini_racer (>= 0.4)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jsbundling-rails (1.2.1)
railties (>= 6.0.0)
libv8-node (18.16.0.0-arm64-darwin)
listen (3.0.8)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
Expand All @@ -130,6 +134,8 @@ GEM
matrix (0.4.2)
method_source (1.0.0)
mini_mime (1.1.5)
mini_racer (0.8.0)
libv8-node (~> 18.16.0.0)
minitest (5.20.0)
msgpack (1.7.2)
net-imap (0.3.7)
Expand Down Expand Up @@ -239,6 +245,7 @@ DEPENDENCIES
cssbundling-rails (~> 1.3)
factory_bot (~> 5.0.0)
form_props (~> 0.0.3)
humid
jbuilder (~> 2.7)
jsbundling-rails (~> 1.2)
listen (>= 3.0.5, < 3.2)
Expand Down
2 changes: 1 addition & 1 deletion app/components/Dialog.js
@@ -1,5 +1,4 @@
import React from 'react'
import dialogPolyfill from "dialog-polyfill"

export default class extends React.Component {
constructor(props) {
Expand All @@ -8,6 +7,7 @@ export default class extends React.Component {
}

componentDidMount() {
const dialogPolyfill = require('dialog-polyfill')
dialogPolyfill.registerDialog(this.dialog.current)
this.dialog.current.open = this.props.open
this.dialog.current.showModal()
Expand Down
2 changes: 1 addition & 1 deletion app/components/SeatingMap.js
@@ -1,5 +1,4 @@
import React from 'react'
import svgPanZoom from 'svg-pan-zoom'
import SvgZoomControls from './SvgZoomControls'
import SVG from 'react-inlinesvg';
import loadingSvg from '../assets/images/icons/loader.svg'
Expand Down Expand Up @@ -66,6 +65,7 @@ export default class extends React.Component {
}

componentDidMount() {
const svgPanZoom = require('svg-pan-zoom')
this.map = svgPanZoom(this.svgRef.current, {
center: true,
fit: true,
Expand Down
5 changes: 2 additions & 3 deletions app/javascript/application.js
@@ -1,7 +1,7 @@
import React from 'react';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { createRoot } from 'react-dom/client';
import { hydrateRoot } from 'react-dom/client';
import { ApplicationBase } from '@thoughtbot/superglue';
import { buildVisitAndRemote } from './application_visit';
import { pageIdentifierToPageComponent } from './page_to_page_mapping';
Expand All @@ -27,8 +27,7 @@ if (typeof window !== "undefined") {
const location = window.location;

if (appEl) {
const root = createRoot(appEl);
root.render(
hydrateRoot(appEl,
<Application
appEl={appEl}
// The base url prefixed to all calls made by the `visit`
Expand Down
Empty file added app/javascript/components/.keep
Empty file.
45 changes: 45 additions & 0 deletions app/javascript/server_rendering.js
@@ -0,0 +1,45 @@
import React from 'react';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { createRoot } from 'react-dom/client';
import { ApplicationBase } from '@thoughtbot/superglue';
import { pageIdentifierToPageComponent } from './page_to_page_mapping';
import { buildStore } from './store'
import { renderToString } from 'react-dom/server';

require("source-map-support").install({
retrieveSourceMap: filename => {
return {
url: filename,
map: readSourceMap(filename)
};
}
});

class Application extends ApplicationBase {
mapping() {
return pageIdentifierToPageComponent;
}

visitAndRemote(navRef, store) {
return {visit: () => {}, remote: () => {}}
}

buildStore(initialState, { superglue, pages}) {
return buildStore(initialState, superglue, pages);
}
}

setHumidRenderer((json) => {
const initialState = JSON.parse(json)
return renderToString(
<Application
// baseUrl={origin}
// The global var SUPERGLUE_INITIAL_PAGE_STATE is set by your erb
// template, e.g., index.html.erb
initialPage={initialState}
// The initial path of the page, e.g., /foobar
// path={path}
/>
)
})
2 changes: 1 addition & 1 deletion app/views/seats/index.html.erb
Expand Up @@ -4,4 +4,4 @@
window.SUPERGLUE_INITIAL_PAGE_STATE=<%= initial_state.html_safe %>;
</script>

<div id="app"></div>
<div id="app"><%= Humid.render(initial_state).html_safe %></div>
5 changes: 3 additions & 2 deletions app/views/seats/show.html.erb
Expand Up @@ -4,5 +4,6 @@
window.SUPERGLUE_INITIAL_PAGE_STATE=<%= initial_state.html_safe %>;
</script>

<div id="app"></div>

<div id="app">
<%= Humid.render(initial_state).html_safe %>
</div>
24 changes: 24 additions & 0 deletions build-ssr.mjs
@@ -0,0 +1,24 @@
import * as esbuild from 'esbuild'
import { polyfillNode } from "esbuild-plugin-polyfill-node";

await esbuild.build({
entryPoints: ['app/javascript/server_rendering.js'],
bundle: true,
platform: "browser",
define: {
"process.env.NODE_ENV": '"production"'
},
sourcemap: true,
outfile: 'app/assets/builds/server_rendering.js',
logLevel: "info",
loader: {
".js": "jsx",
".svg": "dataurl"
},
inject: ["./shim.js"],
plugins: [
polyfillNode({
globals: false
}),
]
})
39 changes: 39 additions & 0 deletions config/initializers/humid.rb
@@ -0,0 +1,39 @@
Humid.configure do |config|
# Path to your build file located in `app/assets/build/`. You should use a
# separate build apart from your `application.js`.
#
# Required
config.application_path = Rails.root.join('app', 'assets', 'builds', 'server_rendering.js')

# Path to your source map file
#
# Optional
config.source_map_path = Rails.root.join('app', 'assets', 'builds', 'server_rendering.js.map')

# Raise errors if JS rendering failed. If false, the error will be
# logged out to Rails log and Humid.render will return an empty string
#
# Defaults to true.
config.raise_render_errors = Rails.env.development? || Rails.env.test?

# The logger instance.
# `console.log` and friends (`warn`, `error`) are delegated to
# the respective logger levels on the ruby side.
#
# Defaults to `Logger.new(STDOUT)`
config.logger = Rails.logger

# Options passed to mini_racer.
#
# Defaults to empty `{}`.
config.context_options = {
timeout: 1000,
ensure_gc_after_idle: 2000
}
end

# Common development options
if Rails.env.development? || Rails.env.test?
# Use single_threaded mode for Spring and other forked envs.
MiniRacer::Platform.set_flags! :single_threaded
end
10 changes: 9 additions & 1 deletion config/puma.rb
Expand Up @@ -25,7 +25,7 @@
# Workers do not work on JRuby or Windows (both of which do not support
# processes).
#
# workers ENV.fetch("WEB_CONCURRENCY") { 2 }
workers ENV.fetch("WEB_CONCURRENCY") { 1 }

# Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code
Expand All @@ -36,3 +36,11 @@

# Allow puma to be restarted by `rails restart` command.
plugin :tmp_restart

on_worker_boot do
Humid.create_context
end

on_worker_shutdown do
Humid.dispose
end
12 changes: 9 additions & 3 deletions package.json
Expand Up @@ -5,19 +5,25 @@
"@reduxjs/toolkit": "^1.9.5",
"@thoughtbot/superglue": "^0.50.0",
"breakpoint-sass": "^3.0.0",
"esbuild": "^0.19.3",
"dialog-polyfill": "^0.5.6",
"esbuild": "^0.19.3",
"esbuild-plugin-polyfill-node": "^0.3.0",
"esbuild-plugins-node-modules-polyfill": "^1.6.1",
"history": "^5.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.69.0",
"react-inlinesvg": "^4.0.3",
"react-redux": "^8.1.2",
"svg-pan-zoom": "^3.6.1"
"source-map-support": "^0.5.19",
"svg-pan-zoom": "^3.6.1",
"text-encoding": "^0.7.0"
},
"version": "0.1.0",
"scripts": {
"build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --loader:.js=jsx --loader:.svg=dataurl --public-path=/assets",
"build": "yarn run build:web && yarn run build:ssr",
"build:web": "esbuild app/javascript/application.js --bundle --sourcemap --outdir=app/assets/builds --loader:.js=jsx --loader:.svg=dataurl --public-path=/assets",
"build:ssr": "node ./build-ssr.mjs",
"build:css": "sass ./app/assets/stylesheets/application.sass.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules"
}
}
1 change: 1 addition & 0 deletions shim.js
@@ -0,0 +1 @@
export {TextEncoder, TextDecoder} from 'text-encoding'
60 changes: 60 additions & 0 deletions yarn.lock
Expand Up @@ -119,6 +119,11 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.3.tgz#e5036be529f757e58d9a7771f2f1b14782986a74"
integrity sha512-FbUN+0ZRXsypPyWE2IwIkVjDkDnJoMJARWOcFZn4KPPli+QnKqF0z1anvfaYe3ev5HFCpRDLLBDHyOALLppWHw==

"@jspm/core@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@jspm/core/-/core-2.0.1.tgz#3f08c59c60a5f5e994523ed6b0b665ec80adc94e"
integrity sha512-Lg3PnLp0QXpxwLIAuuJboLeRaIhrgJjeuh797QADg3xz8wGLugQOS5DpsE8A6i6Adgzf+bacllkKZG3J0tGfDw==

"@reduxjs/toolkit@^1.9.5":
version "1.9.5"
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.5.tgz#d3987849c24189ca483baa7aa59386c8e52077c4"
Expand Down Expand Up @@ -205,6 +210,11 @@ breakpoint-sass@^3.0.0:
resolved "https://registry.yarnpkg.com/breakpoint-sass/-/breakpoint-sass-3.0.0.tgz#c24c4c627782dae5f4da4e884461bd0fb9befe41"
integrity sha512-qxJqSfTaOHI+RCGzvKWVRwwC2hMIaS0KV1b+asqWUFxdLv/yKNADF7AtT1uNnkt2VxSMZ2csM22CSc+Hez+EIg==

buffer-from@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==

"chokidar@>=3.0.0 <4.0.0":
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
Expand All @@ -230,6 +240,23 @@ dialog-polyfill@^0.5.6:
resolved "https://registry.yarnpkg.com/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz#7507b4c745a82fcee0fa07ce64d835979719599a"
integrity sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w==

esbuild-plugin-polyfill-node@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/esbuild-plugin-polyfill-node/-/esbuild-plugin-polyfill-node-0.3.0.tgz#e7e3804b8272df51ae4f8ebfb7445a03712504cb"
integrity sha512-SHG6CKUfWfYyYXGpW143NEZtcVVn8S/WHcEOxk62LuDXnY4Zpmc+WmxJKN6GMTgTClXJXhEM5KQlxKY6YjbucQ==
dependencies:
"@jspm/core" "^2.0.1"
import-meta-resolve "^3.0.0"

esbuild-plugins-node-modules-polyfill@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/esbuild-plugins-node-modules-polyfill/-/esbuild-plugins-node-modules-polyfill-1.6.1.tgz#9fe01118ac2c54674aa370128caec195aefee4a3"
integrity sha512-6sAwI24PV8W0zxeO+i4BS5zoQypS3SzEGwIdxpzpy65riRuK8apMw8PN0aKVLCTnLr0FgNIxUMRd9BsreBrtog==
dependencies:
"@jspm/core" "^2.0.1"
local-pkg "^0.4.3"
resolve.exports "^2.0.2"

esbuild@^0.19.3:
version "0.19.3"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.3.tgz#d9268cd23358eef9d76146f184e0c55ff8da7bb6"
Expand Down Expand Up @@ -301,6 +328,11 @@ immutable@^4.0.0:
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f"
integrity sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==

import-meta-resolve@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/import-meta-resolve/-/import-meta-resolve-3.0.0.tgz#94a6aabc623874fbc2f3525ec1300db71c6cbc11"
integrity sha512-4IwhLhNNA8yy445rPjD/lWh++7hMDOml2eHtd58eG7h+qK3EryMuuRbsHGPikCoAgIkkDnckKfWSk2iDla/ejg==

is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
Expand Down Expand Up @@ -330,6 +362,11 @@ is-number@^7.0.0:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==

local-pkg@^0.4.3:
version "0.4.3"
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963"
integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==

loose-envify@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
Expand Down Expand Up @@ -435,6 +472,11 @@ reselect@^4.1.8:
resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524"
integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==

resolve.exports@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800"
integrity sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==

sass@^1.69.0:
version "1.69.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.0.tgz#5195075371c239ed556280cf2f5944d234f42679"
Expand All @@ -456,11 +498,29 @@ scheduler@^0.23.0:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==

source-map-support@^0.5.19:
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"

source-map@^0.6.0:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==

svg-pan-zoom@^3.6.1:
version "3.6.1"
resolved "https://registry.yarnpkg.com/svg-pan-zoom/-/svg-pan-zoom-3.6.1.tgz#f880a1bb32d18e9c625d7715350bebc269b450cf"
integrity sha512-JaKkGHHfGvRrcMPdJWkssLBeWqM+Isg/a09H7kgNNajT1cX5AztDTNs+C8UzpCxjCTRrG34WbquwaovZbmSk9g==

text-encoding@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.7.0.tgz#f895e836e45990624086601798ea98e8f36ee643"
integrity sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==

to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
Expand Down

0 comments on commit cd931af

Please sign in to comment.