Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add configurable loading strategy for generated component packs #1712

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ jobs:
fi
- name: Increase the amount of inotify watchers
run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
- name: Set packer version environment variable
run: |
echo "CI_PACKER_VERSION=${{ matrix.versions }}" >> $GITHUB_ENV
- name: Main CI
if: steps.changed-files.outputs.any_changed == 'true'
run: bundle exec rake run_rspec:${{ matrix.versions == 'oldest' && 'web' || 'shaka' }}packer_examples
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ jobs:
git config user.name "Your Name"
git commit -am "stop generators from complaining about uncommitted code"
- run: cd spec/dummy && bundle info shakapacker
- name: Set packer version environment variable
run: |
echo "CI_PACKER_VERSION=${{ matrix.versions }}" >> $GITHUB_ENV
- name: Main CI
run: bundle exec rake run_rspec:all_dummy
- name: Store test results
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/rspec-package-specs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
git commit -am "stop generators from complaining about uncommitted code"
- name: Set packer version environment variable
run: |
echo "CI_PACKER_VERSION=${{ matrix.versions == 'oldest' && 'old' || 'new' }}" >> $GITHUB_ENV
echo "CI_PACKER_VERSION=${{ matrix.versions }}" >> $GITHUB_ENV
- name: Run rspec tests
run: bundle exec rspec spec/react_on_rails
- name: Store test results
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ After a release, please make sure to run `bundle exec rake update_changelog`. Th

Changes since the last non-beta release.

#### Added

- Configuration option `generated_component_packs_loading_strategy` to control how generated component packs are loaded. It supports `sync`, `async`, and `defer` strategies. [PR 1712](https://github.com/shakacode/react_on_rails/pull/1712) by [AbanoubGhadban](https://github.com/AbanoubGhadban).

### Removed (Breaking Changes)

- Deprecated `defer_generated_component_packs` configuration option. You should use `generated_component_packs_loading_strategy` instead. [PR 1712](https://github.com/shakacode/react_on_rails/pull/1712) by [AbanoubGhadban](https://github.com/AbanoubGhadban).

### [15.0.0-alpha.2] - 2025-03-07

See [Release Notes](docs/release-notes/15.0.0.md) for full details.
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.development_dependencies
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

gem "shakapacker", "8.0.0"
gem "shakapacker", "8.2.0"
gem "bootsnap", require: false
gem "rails", "~> 7.1"

Expand Down
6 changes: 3 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,8 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
semantic_range (3.0.0)
shakapacker (8.0.0)
semantic_range (3.1.0)
shakapacker (8.2.0)
activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1)
Expand Down Expand Up @@ -431,7 +431,7 @@ DEPENDENCIES
scss_lint
sdoc
selenium-webdriver (= 4.9.0)
shakapacker (= 8.0.0)
shakapacker (= 8.2.0)
spring (~> 4.0)
sprockets (~> 4.0)
sqlite3 (~> 1.6)
Expand Down
22 changes: 20 additions & 2 deletions docs/release-notes/15.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,27 @@ Major improvements to component and store hydration:
- Can use `async` scripts in the page with no fear of race condition
- No need to use `defer` anymore

### Enhanced Script Loading Strategies

- New configuration option `generated_component_packs_loading_strategy` replaces `defer_generated_component_packs`
- Supports three loading strategies:
- `:async` - Loads scripts asynchronously (default for Shakapacker ≥ 8.2.0)
- `:defer` - Defers script execution until after page load (doesn't work well with Streamed HTML as it will wait for the full page load before hydrating the components)
- `:sync` - Loads scripts synchronously (default for Shakapacker < 8.2.0) (better to upgrade to Shakapacker 8.2.0 and use `:async` strategy)
- Improves page performance by optimizing how component packs are loaded

## Breaking Changes

### Component Hydration Changes

- The `defer_generated_component_packs` and `force_load` configurations now default to `false` and `true` respectively. This means components will hydrate early without waiting for the full page load. This improves performance by eliminating unnecessary delays in hydration.
- The `defer_generated_component_packs` configuration has been deprecated. Use `generated_component_packs_loading_strategy` instead.
- The `generated_component_packs_loading_strategy` defaults to `:async` for Shakapacker ≥ 8.2.0 and `:sync` for Shakapacker < 8.2.0.
- The `force_load` configuration now defaults to `true`.
- The new default values of `generated_component_packs_loading_strategy: :async` and `force_load: true` work together to optimize component hydration. Components now hydrate as soon as their code and server-rendered HTML are available, without waiting for the full page to load. This parallel processing significantly improves time-to-interactive by eliminating the traditional waterfall of waiting for page load before beginning hydration (It's critical for streamed HTML).

- The previous need for deferring scripts to prevent race conditions has been eliminated due to improved hydration handling. Making scripts not defer is critical to execute the hydration scripts early before the page is fully loaded.
- The `force_load` configuration makes `react-on-rails` hydrate components immediately as soon as their server-rendered HTML reaches the client, without waiting for the full page load.
- If you want to keep the previous behavior, you can set `defer_generated_component_packs: true` or `force_load: false` in your `config/initializers/react_on_rails.rb` file.
- If you want to keep the previous behavior, you can set `generated_component_packs_loading_strategy: :defer` or `force_load: false` in your `config/initializers/react_on_rails.rb` file.
- You can also keep it for individual components by passing `force_load: false` to `react_component` or `stream_react_component`.
- Redux store now supports `force_load` option, which defaults to `config.force_load` (and so to `true` if that isn't set). If `true`, the Redux store will hydrate immediately as soon as its server-side data reaches the client.
- You can override this behavior for individual Redux stores by calling the `redux_store` helper with `force_load: false`, same as `react_component`.
Expand All @@ -50,6 +62,12 @@ Major improvements to component and store hydration:

- If you call it in a `turbolinks:load` listener to work around the issue documented in [Turbolinks](https://www.shakacode.com/react-on-rails/docs/rails/turbolinks/#async-script-loading), the listener can be safely removed.

### Script Loading Strategy Migration

- If you were previously using `defer_generated_component_packs: true`, use `generated_component_packs_loading_strategy: :defer` instead
- If you were previously using `defer_generated_component_packs: false`, use `generated_component_packs_loading_strategy: :sync` instead
- For optimal performance with Shakapacker ≥ 8.2.0, consider using `generated_component_packs_loading_strategy: :async`

## Store Dependencies for Components

When using Redux stores with multiple components, you need to explicitly declare store dependencies to optimize hydration. Here's how:
Expand Down
49 changes: 44 additions & 5 deletions lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ def self.configuration
# Maximum time in milliseconds to wait for client-side component registration after page load.
# If exceeded, an error will be thrown for server-side rendered components not registered on the client.
# Set to 0 to disable the timeout and wait indefinitely for component registration.
component_registry_timeout: DEFAULT_COMPONENT_REGISTRY_TIMEOUT
component_registry_timeout: DEFAULT_COMPONENT_REGISTRY_TIMEOUT,
generated_component_packs_loading_strategy: nil
)
end

Expand All @@ -60,23 +61,23 @@ class Configuration
:generated_assets_dirs, :generated_assets_dir, :components_subdirectory,
:webpack_generated_files, :rendering_extension, :build_test_command,
:build_production_command, :i18n_dir, :i18n_yml_dir, :i18n_output_format,
:i18n_yml_safe_load_options,
:i18n_yml_safe_load_options, :defer_generated_component_packs,
:server_render_method, :random_dom_id, :auto_load_bundle,
:same_bundle_for_client_and_server, :rendering_props_extension,
:make_generated_server_bundle_the_entrypoint,
:defer_generated_component_packs, :force_load, :rsc_bundle_js_file,
:generated_component_packs_loading_strategy, :force_load, :rsc_bundle_js_file,
:react_client_manifest_file, :component_registry_timeout

# rubocop:disable Metrics/AbcSize
def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil,
replay_console: nil, make_generated_server_bundle_the_entrypoint: nil,
trace: nil, development_mode: nil,
trace: nil, development_mode: nil, defer_generated_component_packs: nil,
logging_on_server: nil, server_renderer_pool_size: nil,
server_renderer_timeout: nil, raise_on_prerender_error: true,
skip_display_none: nil, generated_assets_dirs: nil,
generated_assets_dir: nil, webpack_generated_files: nil,
rendering_extension: nil, build_test_command: nil,
build_production_command: nil, defer_generated_component_packs: nil,
build_production_command: nil, generated_component_packs_loading_strategy: nil,
same_bundle_for_client_and_server: nil,
i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, i18n_yml_safe_load_options: nil,
random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil,
Expand Down Expand Up @@ -124,6 +125,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
self.make_generated_server_bundle_the_entrypoint = make_generated_server_bundle_the_entrypoint
self.defer_generated_component_packs = defer_generated_component_packs
self.force_load = force_load
self.generated_component_packs_loading_strategy = generated_component_packs_loading_strategy
end
# rubocop:enable Metrics/AbcSize

Expand All @@ -139,6 +141,7 @@ def setup_config_values
# check_deprecated_settings
adjust_precompile_task
check_component_registry_timeout
validate_generated_component_packs_loading_strategy
end

private
Expand All @@ -151,6 +154,42 @@ def check_component_registry_timeout
raise ReactOnRails::Error, "component_registry_timeout must be a positive integer"
end

# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def validate_generated_component_packs_loading_strategy
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity

if defer_generated_component_packs
if %i[async sync].include?(generated_component_packs_loading_strategy)
Rails.logger.warn "**WARNING** ReactOnRails: config.defer_generated_component_packs is " \
"superseded by config.generated_component_packs_loading_strategy"
else
Rails.logger.warn "[DEPRECATION] ReactOnRails: Use config." \
"generated_component_packs_loading_strategy = :defer rather than " \
"defer_generated_component_packs"
self.generated_component_packs_loading_strategy ||= :defer
end
end

msg = <<~MSG
ReactOnRails: Your current version of #{ReactOnRails::PackerUtils.packer_type.upcase_first} \
does not support async script loading, which may cause performance issues. Please either:
1. Use :sync or :defer loading strategy instead of :async
2. Upgrade to Shakapacker v8.2.0 or above to enable async script loading
MSG
if PackerUtils.shakapacker_version_requirement_met?([8, 2, 0])
self.generated_component_packs_loading_strategy ||= :async
elsif generated_component_packs_loading_strategy.nil?
Rails.logger.warn("**WARNING** #{msg}")
self.generated_component_packs_loading_strategy = :sync
elsif generated_component_packs_loading_strategy == :async
raise ReactOnRails::Error, "**ERROR** #{msg}"
end

return if %i[async defer sync].include?(generated_component_packs_loading_strategy)

raise ReactOnRails::Error, "generated_component_packs_loading_strategy must be either :async, :defer, or :sync"
end

def check_autobundling_requirements
raise_missing_components_subdirectory if auto_load_bundle && !components_subdirectory.present?
return unless components_subdirectory.present?
Expand Down
6 changes: 4 additions & 2 deletions lib/react_on_rails/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ module Controller
#
# Be sure to include view helper `redux_store_hydration_data` at the end of your layout or view
# or else there will be no client side hydration of your stores.
def redux_store(store_name, props: {})
def redux_store(store_name, props: {}, force_load: nil)
force_load = ReactOnRails.configuration.force_load if force_load.nil?
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change should be part of #1644 PR, but seems we missed it

redux_store_data = { store_name: store_name,
props: props }
props: props,
force_load: force_load }
@registered_stores_defer_render ||= []
@registered_stores_defer_render << redux_store_data
end
Expand Down
9 changes: 7 additions & 2 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,13 @@ def load_pack_for_generated_component(react_component_name, render_options)
is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name))
raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present
end
append_javascript_pack_tag("generated/#{react_component_name}",
defer: ReactOnRails.configuration.defer_generated_component_packs)

options = { defer: ReactOnRails.configuration.generated_component_packs_loading_strategy == :defer }
# Old versions of Shakapacker don't support async script tags.
# ReactOnRails.configure already validates if async loading is supported by the installed Shakapacker version.
# Therefore, we only need to pass the async option if the loading strategy is explicitly set to :async
options[:async] = true if ReactOnRails.configuration.generated_component_packs_loading_strategy == :async
append_javascript_pack_tag("generated/#{react_component_name}", **options)
append_stylesheet_pack_tag("generated/#{react_component_name}")
end

Expand Down
6 changes: 3 additions & 3 deletions spec/dummy/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
semantic_range (3.0.0)
shakapacker (8.0.0)
semantic_range (3.1.0)
shakapacker (8.2.0)
activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1)
Expand Down Expand Up @@ -423,7 +423,7 @@ DEPENDENCIES
scss_lint
sdoc
selenium-webdriver (= 4.9.0)
shakapacker (= 8.0.0)
shakapacker (= 8.2.0)
spring (~> 4.0)
sprockets (~> 4.0)
sqlite3 (~> 1.6)
Expand Down
2 changes: 1 addition & 1 deletion spec/dummy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"sass": "^1.43.4",
"sass-loader": "^12.3.0",
"sass-resources-loader": "^2.1.0",
"shakapacker": "8.0.0",
"shakapacker": "8.2.0",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "5.3.1",
"url-loader": "^4.0.0",
Expand Down
52 changes: 51 additions & 1 deletion spec/dummy/spec/helpers/react_on_rails_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,60 @@ class PlainReactOnRailsHelper
allow(helper).to receive(:append_javascript_pack_tag)
allow(helper).to receive(:append_stylesheet_pack_tag)
expect { helper.load_pack_for_generated_component("component_name", render_options) }.not_to raise_error
expect(helper).to have_received(:append_javascript_pack_tag).with("generated/component_name", { defer: false })

if ENV["CI_PACKER_VERSION"] == "oldest"
expect(helper).to have_received(:append_javascript_pack_tag).with("generated/component_name", { defer: false })
else
expect(helper).to have_received(:append_javascript_pack_tag)
.with("generated/component_name", { defer: false, async: true })
end
expect(helper).to have_received(:append_stylesheet_pack_tag).with("generated/component_name")
end

context "when async loading is enabled" do
before do
allow(ReactOnRails.configuration).to receive(:generated_component_packs_loading_strategy).and_return(:async)
end

it "appends the async attribute to the script tag" do
original_append_javascript_pack_tag = helper.method(:append_javascript_pack_tag)
begin
# Temporarily redefine append_javascript_pack_tag to handle the async keyword argument.
# This is needed because older versions of Shakapacker (which may be used during testing)
# don't support async loading, but we still want to test that the async option is passed
# correctly when enabled.
def helper.append_javascript_pack_tag(name, **options)
original_append_javascript_pack_tag.call(name, **options)
end

allow(helper).to receive(:append_javascript_pack_tag)
allow(helper).to receive(:append_stylesheet_pack_tag)
expect { helper.load_pack_for_generated_component("component_name", render_options) }.not_to raise_error
expect(helper).to have_received(:append_javascript_pack_tag).with(
"generated/component_name",
{ defer: false, async: true }
)
expect(helper).to have_received(:append_stylesheet_pack_tag).with("generated/component_name")
ensure
helper.define_singleton_method(:append_javascript_pack_tag, original_append_javascript_pack_tag)
end
end
end

context "when defer loading is enabled" do
before do
allow(ReactOnRails.configuration).to receive(:generated_component_packs_loading_strategy).and_return(:defer)
end

it "appends the defer attribute to the script tag" do
allow(helper).to receive(:append_javascript_pack_tag)
allow(helper).to receive(:append_stylesheet_pack_tag)
expect { helper.load_pack_for_generated_component("component_name", render_options) }.not_to raise_error
expect(helper).to have_received(:append_javascript_pack_tag).with("generated/component_name", { defer: true })
expect(helper).to have_received(:append_stylesheet_pack_tag).with("generated/component_name")
end
end

it "throws an error in development if generated component isn't found" do
allow(Rails.env).to receive(:development?).and_return(true)
expect { helper.load_pack_for_generated_component("nonexisting_component", render_options) }
Expand Down
8 changes: 4 additions & 4 deletions spec/dummy/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6068,10 +6068,10 @@ sha.js@^2.4.0, sha.js@^2.4.8:
inherits "^2.0.1"
safe-buffer "^5.0.1"

shakapacker@8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/shakapacker/-/shakapacker-8.0.0.tgz#f29537c19078af7318758c92e7a1bca4cee96bdd"
integrity sha512-HCdpITzIKXzGEyUWQhKzPbpwwOsgTamaPH+0kXdhM59VQxZ3NWnT5cL3DlJdAT3sGsWCJskEl3eMkQlnh9DjhA==
shakapacker@8.2.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/shakapacker/-/shakapacker-8.2.0.tgz#c7bed87b8be2ae565cfe616f68552be545c77e14"
integrity sha512-Ct7BFqJVnKbxdqCzG+ja7Q6LPt/PlB7sSVBfG5jsAvmVCADM05cuoNwEgYNjFGKbDzHAxUqy5XgoI9Y030+JKQ==
dependencies:
js-yaml "^4.1.0"
path-complete-extname "^1.0.0"
Expand Down
Loading
Loading