From cd931afb4e1a74729fa8962f1ca708fd50616df1 Mon Sep 17 00:00:00 2001 From: Johny Ho Date: Fri, 3 Jul 2020 22:31:47 -0400 Subject: [PATCH] Enable server side rendering of HTML 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 --- Gemfile | 1 + Gemfile.lock | 7 ++++ app/components/Dialog.js | 2 +- app/components/SeatingMap.js | 2 +- app/javascript/application.js | 5 +-- app/javascript/components/.keep | 0 app/javascript/server_rendering.js | 45 ++++++++++++++++++++++ app/views/seats/index.html.erb | 2 +- app/views/seats/show.html.erb | 5 ++- build-ssr.mjs | 24 ++++++++++++ config/initializers/humid.rb | 39 +++++++++++++++++++ config/puma.rb | 10 ++++- package.json | 12 ++++-- shim.js | 1 + yarn.lock | 60 ++++++++++++++++++++++++++++++ 15 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 app/javascript/components/.keep create mode 100644 app/javascript/server_rendering.js create mode 100644 build-ssr.mjs create mode 100644 config/initializers/humid.rb create mode 100644 shim.js diff --git a/Gemfile b/Gemfile index 766f4e08..8438e5e6 100644 --- a/Gemfile +++ b/Gemfile @@ -51,3 +51,4 @@ gem 'superglue', path: '/Users/johnyho/superglue/superglue_rails' gem "form_props", "~> 0.0.3" gem 'active_link_to' +gem 'humid' diff --git a/Gemfile.lock b/Gemfile.lock index acc3aa24..c831902e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,9 @@ 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) @@ -115,6 +118,7 @@ GEM 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) @@ -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) @@ -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) diff --git a/app/components/Dialog.js b/app/components/Dialog.js index 4a9565ae..064c1fc8 100644 --- a/app/components/Dialog.js +++ b/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) { @@ -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() diff --git a/app/components/SeatingMap.js b/app/components/SeatingMap.js index 404ae31e..786a2b2c 100644 --- a/app/components/SeatingMap.js +++ b/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' @@ -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, diff --git a/app/javascript/application.js b/app/javascript/application.js index fbfb90ae..ea03aff9 100644 --- a/app/javascript/application.js +++ b/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'; @@ -27,8 +27,7 @@ if (typeof window !== "undefined") { const location = window.location; if (appEl) { - const root = createRoot(appEl); - root.render( + hydrateRoot(appEl, { + 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( + + ) +}) diff --git a/app/views/seats/index.html.erb b/app/views/seats/index.html.erb index 1b5c9d9c..7f7693c7 100644 --- a/app/views/seats/index.html.erb +++ b/app/views/seats/index.html.erb @@ -4,4 +4,4 @@ window.SUPERGLUE_INITIAL_PAGE_STATE=<%= initial_state.html_safe %>; -
+
<%= Humid.render(initial_state).html_safe %>
diff --git a/app/views/seats/show.html.erb b/app/views/seats/show.html.erb index 97c6fbc2..b60e3408 100644 --- a/app/views/seats/show.html.erb +++ b/app/views/seats/show.html.erb @@ -4,5 +4,6 @@ window.SUPERGLUE_INITIAL_PAGE_STATE=<%= initial_state.html_safe %>; -
- +
+ <%= Humid.render(initial_state).html_safe %> +
diff --git a/build-ssr.mjs b/build-ssr.mjs new file mode 100644 index 00000000..eb34bfc0 --- /dev/null +++ b/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 + }), + ] +}) diff --git a/config/initializers/humid.rb b/config/initializers/humid.rb new file mode 100644 index 00000000..71b41dc6 --- /dev/null +++ b/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 diff --git a/config/puma.rb b/config/puma.rb index 5ed44377..2e058254 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -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 @@ -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 diff --git a/package.json b/package.json index be5a9f4d..d03c54a2 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/shim.js b/shim.js new file mode 100644 index 00000000..b11b9ca8 --- /dev/null +++ b/shim.js @@ -0,0 +1 @@ +export {TextEncoder, TextDecoder} from 'text-encoding' diff --git a/yarn.lock b/yarn.lock index 003e04d4..60a7f77f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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" @@ -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" @@ -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" @@ -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" @@ -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"