diff --git a/Appraisals b/Appraisals index 632a1b6a9..80f923750 100644 --- a/Appraisals +++ b/Appraisals @@ -47,23 +47,29 @@ end appraise "rails-main" do ruby "~> 4.0" - gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" - gem "rails", github: "rails/rails", branch: "main" + gem "rack", git: "https://github.com/rack/rack", ref: "a9833c8f3bd6b6d1e0ab35de00a1f1a16b5095f5" + gem "rails", github: "rails/rails", ref: "7a569bc6cbf10cb07023ac63a838fd0a6b39cad4" + # Ractor support, https://github.com/ruby-i18n/i18n/pull/741 + gem "i18n", github: "Shopify/i18n", ref: "58aa1dcd3e04c34831e1c2ec06c83391559c7c91" group :development, :test do gem "turbo-rails", "~> 2" - gem "rspec-rails", "~> 8" + # Compatibility with frozen default_url_options, unreleased as of 8.0.4 + gem "rspec-rails", github: "rspec/rspec-rails", ref: "4832492d8a1683bab4b74dc218576f64b0ff988c" end end appraise "rails-main_head" do ruby "4.1.0.dev" - gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" - gem "rails", github: "rails/rails", branch: "main" + gem "rack", git: "https://github.com/rack/rack", ref: "a9833c8f3bd6b6d1e0ab35de00a1f1a16b5095f5" + gem "rails", github: "rails/rails", ref: "7a569bc6cbf10cb07023ac63a838fd0a6b39cad4" + # Ractor support, https://github.com/ruby-i18n/i18n/pull/741 + gem "i18n", github: "Shopify/i18n", ref: "58aa1dcd3e04c34831e1c2ec06c83391559c7c91" group :development, :test do gem "turbo-rails", "~> 2" - gem "rspec-rails", "~> 8" + # Compatibility with frozen default_url_options, unreleased as of 8.0.4 + gem "rspec-rails", github: "rspec/rspec-rails", ref: "4832492d8a1683bab4b74dc218576f64b0ff988c" end end diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3fa443e9e..732df4940 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,14 @@ nav_order: 6 ## main +* Support rendering compiled components, including slots, collections, variants, inheritance, and sidecar translations, in non-main Ractors under Rails' experimental `Rails.application.ractorize!`. Methods ViewComponent generates are now defined with Ractor-shareable procs, the compile cache is shareable, and class-level state read while rendering is made shareable before the application is ractorized. Sidecar translations also require an i18n release with Ractor support. + + *Joel Hawksley* + +* Support Rails main's `ActionView::TemplateDetails::Requested`, which no longer exposes `locale`, `formats`, `variants` or `handlers`, in `MissingTemplateError`. Use `I18n.reserved_keys` when available, since newer i18n deprecates `I18n::RESERVED_KEYS`. + + *Joel Hawksley* + * Only register the executor hook that watches component templates when Action View doesn't cache templates at boot. With template caching on, as in production, the hook did nothing but still read `ActionView::Base.cache_template_loading` on every request. That value lives in a class variable, which non-main Ractors can't read on Ruby 4.0, so under Rails' experimental `Rails.application.ractorize!` every request failed. *Mikael Henriksson* diff --git a/gemfiles/rails_main.gemfile b/gemfiles/rails_main.gemfile index b72509c60..8b1049409 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -4,8 +4,9 @@ source "https://rubygems.org" ruby "~> 4.0" -gem "rails", github: "rails/rails", branch: "main" -gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" +gem "rails", github: "rails/rails", ref: "7a569bc6cbf10cb07023ac63a838fd0a6b39cad4" +gem "rack", git: "https://github.com/rack/rack", ref: "a9833c8f3bd6b6d1e0ab35de00a1f1a16b5095f5" +gem "i18n", github: "Shopify/i18n", ref: "58aa1dcd3e04c34831e1c2ec06c83391559c7c91" group :development, :test do gem "appraisal", "~> 2" @@ -31,7 +32,7 @@ group :development, :test do gem "rails-dom-testing", "~> 2.3.0" gem "reactionview" gem "redis" - gem "rspec-rails", "~> 8" + gem "rspec-rails", github: "rspec/rspec-rails", ref: "4832492d8a1683bab4b74dc218576f64b0ff988c" gem "rubocop-md", "~> 2" gem "selenium-webdriver", "~> 4" gem "simplecov-console", "< 1" diff --git a/gemfiles/rails_main.gemfile.lock b/gemfiles/rails_main.gemfile.lock index 976847651..7e5b2443a 100644 --- a/gemfiles/rails_main.gemfile.lock +++ b/gemfiles/rails_main.gemfile.lock @@ -1,14 +1,22 @@ +GIT + remote: https://github.com/Shopify/i18n.git + revision: 58aa1dcd3e04c34831e1c2ec06c83391559c7c91 + ref: 58aa1dcd3e04c34831e1c2ec06c83391559c7c91 + specs: + i18n (1.15.2) + concurrent-ruby (~> 1.0) + GIT remote: https://github.com/rack/rack - revision: 8a4475a9f416a72e5b02bd7817e4a8ed684f29b0 - ref: 8a4475a9f416a72e5b02bd7817e4a8ed684f29b0 + revision: a9833c8f3bd6b6d1e0ab35de00a1f1a16b5095f5 + ref: a9833c8f3bd6b6d1e0ab35de00a1f1a16b5095f5 specs: - rack (3.1.1) + rack (3.2.0) GIT remote: https://github.com/rails/rails.git - revision: 9cc62ec2a5c4ac56f7571f05489da9d27c6df69d - branch: main + revision: 7a569bc6cbf10cb07023ac63a838fd0a6b39cad4 + ref: 7a569bc6cbf10cb07023ac63a838fd0a6b39cad4 specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -52,11 +60,12 @@ GIT activesupport (= 8.2.0.alpha) builder (~> 3.1) erubi (~> 1.11) + herb (>= 0.10) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.7) activejob (8.2.0.alpha) activesupport (= 8.2.0.alpha) - globalid (>= 0.3.6) + globalid (>= 1.4.0) activemodel (8.2.0.alpha) activesupport (= 8.2.0.alpha) activerecord (8.2.0.alpha) @@ -68,7 +77,7 @@ GIT activejob (= 8.2.0.alpha) activerecord (= 8.2.0.alpha) activesupport (= 8.2.0.alpha) - marcel (~> 1.0) + marcel (~> 2.0) activesupport (8.2.0.alpha) base64 bigdecimal @@ -80,6 +89,7 @@ GIT logger (>= 1.4.2) minitest (>= 5.1) psych (>= 4) + ractor-dispatch (>= 0.3.0) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) @@ -107,6 +117,20 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +GIT + remote: https://github.com/rspec/rspec-rails.git + revision: 4832492d8a1683bab4b74dc218576f64b0ff988c + ref: 4832492d8a1683bab4b74dc218576f64b0ff988c + specs: + rspec-rails (8.1.0.pre) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + PATH remote: .. specs: @@ -149,7 +173,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.6) cuprite (0.17) @@ -175,7 +199,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) - globalid (1.3.0) + globalid (1.4.0) activesupport (>= 6.1) haml (7.2.0) temple (>= 0.8.2) @@ -190,8 +214,6 @@ GEM herb (0.10.1-x86_64-darwin) herb (0.10.1-x86_64-linux-gnu) herb (0.10.1-x86_64-linux-musl) - i18n (1.14.8) - concurrent-ruby (~> 1.0) io-console (0.8.2) irb (1.18.0) pp (>= 0.6.0) @@ -216,11 +238,10 @@ GEM net-imap net-pop net-smtp - marcel (1.1.0) + marcel (2.1.0) matrix (0.4.3) method_source (1.1.0) mini_mime (1.1.5) - mini_portile2 (2.8.9) minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) @@ -240,9 +261,6 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.3) - mini_portile2 (~> 2.8.2) - racc (~> 1.4) nokogiri (1.19.3-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.19.3-aarch64-linux-musl) @@ -285,6 +303,7 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) + ractor-dispatch (0.3.0) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -317,14 +336,6 @@ GEM rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.4) - actionpack (>= 7.2) - activesupport (>= 7.2) - railties (>= 7.2) - rspec-core (>= 3.13.0, < 5.0.0) - rspec-expectations (>= 3.13.0, < 5.0.0) - rspec-mocks (>= 3.13.0, < 5.0.0) - rspec-support (>= 3.13.0, < 5.0.0) rspec-support (3.13.7) rubocop (1.84.2) json (~> 2.3) @@ -456,6 +467,7 @@ DEPENDENCIES dry-initializer erb_lint haml (~> 7) + i18n! jbuilder (~> 2) m (~> 1) method_source (~> 1) @@ -471,7 +483,7 @@ DEPENDENCIES rake (~> 13) reactionview redis - rspec-rails (~> 8) + rspec-rails! rubocop-md (~> 2) selenium-webdriver (~> 4) simplecov (< 1) @@ -511,7 +523,7 @@ CHECKSUMS bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef - concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d cuprite (0.17) sha256=b140d5dc70d08b97ad54bcf45cd95d0bd430e291e9dffe76fff851fddd57c12b @@ -524,7 +536,7 @@ CHECKSUMS erb_lint (0.9.0) sha256=dfb5e40ad839e8d1f0d56ca85ec9a7ac4c9cd966ec281138282f35b323ca7c31 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 ferrum (0.17.2) sha256=2c2540a850b211a46f4d81de21bfd62048f507e4c327d1807225c3823c17e6ee - globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a herb (0.10.1) sha256=e7b64076e275a2e97fc5dd5e8ab9ad1bd55840871395ea8d3e481de73a82d32f herb (0.10.1-aarch64-linux-gnu) sha256=fd9f4f8e0bcd9a421de55ff8810066550e9bcba8c8bf53737b32a04eb2fcbc49 @@ -535,7 +547,7 @@ CHECKSUMS herb (0.10.1-x86_64-darwin) sha256=dc266814a63215eefb82cf4f55ce723bc20218395b13c1631c11ca7cfc782d07 herb (0.10.1-x86_64-linux-gnu) sha256=c7762d411a6d2e1a032cf403b0429155c01ab48b3b5be2a0aab1e921da6b8948 herb (0.10.1-x86_64-linux-musl) sha256=a5bb7c82bbc30a9eb80cb833fea749f543a0709d9f858f7db366a3c2616d969b - i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + i18n (1.15.2) io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 @@ -546,11 +558,10 @@ CHECKSUMS loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 m (1.7.0) sha256=058f793da8150c51353cc59366ffae7774683e868bede3c78f81d920fb9d633a mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 - marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee + marcel (2.1.0) sha256=f8b0327111dc7e516331eeefe052e981ce50e2bd826c1042a53cdf047fcc3512 matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 minitest-memory (1.1.0) sha256=1251476a6e636a9b3bf4f02cd50a2edd6837db53afb81c5e78474e7144f1b747 minitest-mock (5.27.0) sha256=7040ed7185417a966920987eaa6eaf1be4ea1fc5b25bb03ff4703f98564a55b0 @@ -560,7 +571,6 @@ CHECKSUMS net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.3) sha256=78312cbac32a40c812780d9678221b79d51288eec00054c1a8d15f7ce05960e8 nokogiri (1.19.3-aarch64-linux-gnu) sha256=46b89e5d7b9e844c2ee360794240c6ea2a4e6fa0c5892a4ed487db621224b639 nokogiri (1.19.3-aarch64-linux-musl) sha256=8392dfdcd21be7a94dbbe9ccc138dea01b97b24cb2dc02a114ca98bfb1d9a0b7 nokogiri (1.19.3-arm-linux-gnu) sha256=3919d5ffc334ad778a4a9eb88fda7dcb8b1fb58c8a52ac640c6dcd2f038e774f @@ -579,10 +589,11 @@ CHECKSUMS public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (8.0.1) sha256=7b94e50c07655718c1fb8ae41a11fc06c7d61293208b3aa608ff71a46d3ad37c racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.1.1) + rack (3.2.0) rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + ractor-dispatch (0.3.0) sha256=cd2d46743303e00fe500df168b4dde85080160fd9f04539cada4b4dd5e588f68 rails (8.2.0.alpha) rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 @@ -599,7 +610,7 @@ CHECKSUMS rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 - rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df + rspec-rails (8.1.0.pre) rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 @@ -657,4 +668,4 @@ RUBY VERSION ruby 4.0.1 BUNDLED WITH - 4.1.0.dev + 4.0.16 diff --git a/gemfiles/rails_main_head.gemfile b/gemfiles/rails_main_head.gemfile index 2aa084506..220b1d3f6 100644 --- a/gemfiles/rails_main_head.gemfile +++ b/gemfiles/rails_main_head.gemfile @@ -4,8 +4,9 @@ source "https://rubygems.org" ruby "4.1.0.dev" -gem "rails", github: "rails/rails", branch: "main" -gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" +gem "rails", github: "rails/rails", ref: "7a569bc6cbf10cb07023ac63a838fd0a6b39cad4" +gem "rack", git: "https://github.com/rack/rack", ref: "a9833c8f3bd6b6d1e0ab35de00a1f1a16b5095f5" +gem "i18n", github: "Shopify/i18n", ref: "58aa1dcd3e04c34831e1c2ec06c83391559c7c91" group :development, :test do gem "appraisal", "~> 2" @@ -31,7 +32,7 @@ group :development, :test do gem "rails-dom-testing", "~> 2.3.0" gem "reactionview" gem "redis" - gem "rspec-rails", "~> 8" + gem "rspec-rails", github: "rspec/rspec-rails", ref: "4832492d8a1683bab4b74dc218576f64b0ff988c" gem "rubocop-md", "~> 2" gem "selenium-webdriver", "~> 4" gem "simplecov-console", "< 1" diff --git a/gemfiles/rails_main_head.gemfile.lock b/gemfiles/rails_main_head.gemfile.lock index 410c817a5..49de85a65 100644 --- a/gemfiles/rails_main_head.gemfile.lock +++ b/gemfiles/rails_main_head.gemfile.lock @@ -1,14 +1,22 @@ +GIT + remote: https://github.com/Shopify/i18n.git + revision: 58aa1dcd3e04c34831e1c2ec06c83391559c7c91 + ref: 58aa1dcd3e04c34831e1c2ec06c83391559c7c91 + specs: + i18n (1.15.2) + concurrent-ruby (~> 1.0) + GIT remote: https://github.com/rack/rack - revision: 8a4475a9f416a72e5b02bd7817e4a8ed684f29b0 - ref: 8a4475a9f416a72e5b02bd7817e4a8ed684f29b0 + revision: a9833c8f3bd6b6d1e0ab35de00a1f1a16b5095f5 + ref: a9833c8f3bd6b6d1e0ab35de00a1f1a16b5095f5 specs: - rack (3.1.1) + rack (3.2.0) GIT remote: https://github.com/rails/rails.git - revision: 11417bac09d616fdbc1fdbfe8bedf7faf8db5fc3 - branch: main + revision: 7a569bc6cbf10cb07023ac63a838fd0a6b39cad4 + ref: 7a569bc6cbf10cb07023ac63a838fd0a6b39cad4 specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -52,11 +60,12 @@ GIT activesupport (= 8.2.0.alpha) builder (~> 3.1) erubi (~> 1.11) + herb (>= 0.10) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.7) activejob (8.2.0.alpha) activesupport (= 8.2.0.alpha) - globalid (>= 0.3.6) + globalid (>= 1.4.0) activemodel (8.2.0.alpha) activesupport (= 8.2.0.alpha) activerecord (8.2.0.alpha) @@ -68,7 +77,7 @@ GIT activejob (= 8.2.0.alpha) activerecord (= 8.2.0.alpha) activesupport (= 8.2.0.alpha) - marcel (~> 1.0) + marcel (~> 2.0) activesupport (8.2.0.alpha) base64 bigdecimal @@ -80,6 +89,7 @@ GIT logger (>= 1.4.2) minitest (>= 5.1) psych (>= 4) + ractor-dispatch (>= 0.3.0) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) @@ -107,6 +117,20 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +GIT + remote: https://github.com/rspec/rspec-rails.git + revision: 4832492d8a1683bab4b74dc218576f64b0ff988c + ref: 4832492d8a1683bab4b74dc218576f64b0ff988c + specs: + rspec-rails (8.1.0.pre) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + PATH remote: .. specs: @@ -149,7 +173,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.8) connection_pool (3.0.2) crass (1.0.6) cuprite (0.17) @@ -175,15 +199,13 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) - globalid (1.3.0) + globalid (1.4.0) activesupport (>= 6.1) haml (7.2.0) temple (>= 0.8.2) thor tilt herb (0.10.1) - i18n (1.14.8) - concurrent-ruby (~> 1.0) io-console (0.8.2) irb (1.18.0) pp (>= 0.6.0) @@ -208,7 +230,7 @@ GEM net-imap net-pop net-smtp - marcel (1.1.0) + marcel (2.1.0) matrix (0.4.3) method_source (1.1.0) mini_mime (1.1.5) @@ -261,6 +283,7 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) + ractor-dispatch (0.3.0) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -293,14 +316,6 @@ GEM rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.4) - actionpack (>= 7.2) - activesupport (>= 7.2) - railties (>= 7.2) - rspec-core (>= 3.13.0, < 5.0.0) - rspec-expectations (>= 3.13.0, < 5.0.0) - rspec-mocks (>= 3.13.0, < 5.0.0) - rspec-support (>= 3.13.0, < 5.0.0) rspec-support (3.13.7) rubocop (1.84.2) json (~> 2.3) @@ -421,6 +436,7 @@ DEPENDENCIES dry-initializer erb_lint haml (~> 7) + i18n! jbuilder (~> 2) m (~> 1) method_source (~> 1) @@ -436,7 +452,7 @@ DEPENDENCIES rake (~> 13) reactionview redis - rspec-rails (~> 8) + rspec-rails! rubocop-md (~> 2) selenium-webdriver (~> 4) simplecov (< 1) @@ -476,7 +492,7 @@ CHECKSUMS bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef - concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d cuprite (0.17) sha256=b140d5dc70d08b97ad54bcf45cd95d0bd430e291e9dffe76fff851fddd57c12b @@ -489,10 +505,10 @@ CHECKSUMS erb_lint (0.9.0) sha256=dfb5e40ad839e8d1f0d56ca85ec9a7ac4c9cd966ec281138282f35b323ca7c31 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 ferrum (0.17.2) sha256=2c2540a850b211a46f4d81de21bfd62048f507e4c327d1807225c3823c17e6ee - globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a herb (0.10.1) sha256=e7b64076e275a2e97fc5dd5e8ab9ad1bd55840871395ea8d3e481de73a82d32f - i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + i18n (1.15.2) io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jbuilder (2.15.0) sha256=fe36cd45b47dd88cb2cbebc3adc4348f041825f580d503578594e8255817f889 @@ -503,7 +519,7 @@ CHECKSUMS loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 m (1.7.0) sha256=058f793da8150c51353cc59366ffae7774683e868bede3c78f81d920fb9d633a mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 - marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee + marcel (2.1.0) sha256=f8b0327111dc7e516331eeefe052e981ce50e2bd826c1042a53cdf047fcc3512 matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef @@ -528,10 +544,11 @@ CHECKSUMS public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (8.0.1) sha256=7b94e50c07655718c1fb8ae41a11fc06c7d61293208b3aa608ff71a46d3ad37c racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.1.1) + rack (3.2.0) rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + ractor-dispatch (0.3.0) sha256=cd2d46743303e00fe500df168b4dde85080160fd9f04539cada4b4dd5e588f68 rails (8.2.0.alpha) rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 @@ -548,7 +565,7 @@ CHECKSUMS rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 - rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df + rspec-rails (8.1.0.pre) rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 81947954a..ebcf13e54 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -214,7 +214,7 @@ def render_parent_to_string @__vc_parent_render_level ||= 0 # ensure a good starting value begin - target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level] + target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level].instance_method(:render_template_for) @__vc_parent_render_level += 1 target_render.bind_call(self, @__vc_requested_details) @@ -223,18 +223,21 @@ def render_parent_to_string end end + EMPTY_SAFE_BUFFER = Ractor.make_shareable("".html_safe) + private_constant :EMPTY_SAFE_BUFFER + # Optional content to be returned before the rendered template. # # @return [String] def output_preamble - @@default_output_preamble ||= "".html_safe + EMPTY_SAFE_BUFFER end # Optional content to be returned after the rendered template. # # @return [String] def output_postamble - @@default_output_postamble ||= "".html_safe + EMPTY_SAFE_BUFFER end # Called before rendering the component. Override to perform operations that @@ -427,6 +430,12 @@ def with_captured_virtual_path(captured_path) attr_reader :view_context + # Called by the compiled `render_template_for`. + def __vc_render_compiled_template(template, safe_call) + @current_template = template + instance_exec(&safe_call) + end + def __vc_render_in_block_provided? defined?(@view_context) && @view_context && @__vc_render_in_block end @@ -666,8 +675,8 @@ def render_template_for(requested_details) # has been re-defined by the consuming application, likely in ApplicationComponent. # We use `base_label` method here instead of `label` to avoid cases where the method # owner is included in a prefix like `ApplicationComponent.inherited`. - child.identifier = caller_locations(1, 10).reject { |l| l.base_label == "inherited" }[0].path - child.virtual_path = child.name&.underscore + child.identifier = -caller_locations(1, 10).reject { |l| l.base_label == "inherited" }[0].path + child.virtual_path = child.name&.underscore&.freeze # Set collection parameter to the extended component child.with_collection_parameter(__vc_provided_collection_parameter) @@ -675,8 +684,8 @@ def render_template_for(requested_details) if method_defined?(:render_template_for, false) vc_ancestor_calls = defined?(@__vc_ancestor_calls) ? @__vc_ancestor_calls.dup : [] - vc_ancestor_calls.unshift(instance_method(:render_template_for)) - child.instance_variable_set(:@__vc_ancestor_calls, vc_ancestor_calls) + vc_ancestor_calls.unshift(self) + child.instance_variable_set(:@__vc_ancestor_calls, vc_ancestor_calls.freeze) end super @@ -684,7 +693,21 @@ def render_template_for(requested_details) # @private def __vc_compiled? - __vc_compiler.compiled? + CompileCache.compiled?(self) + end + + # @private + # Makes class-level state read while rendering Ractor-shareable, so that + # compiled components can render in non-main Ractors. Called before + # `Rails.application.ractorize!` freezes the application. + def __vc_make_ractor_shareable! + Ractor.make_shareable(registered_slots) + __vc_i18n_scope if virtual_path + + if (i18n_backend = __vc_i18n_backend) + i18n_backend.eager_load! + Ractor.make_shareable(i18n_backend) + end end # Hook called by the compiler after a component is compiled. diff --git a/lib/view_component/compile_cache.rb b/lib/view_component/compile_cache.rb index 649282563..2394cb1cd 100644 --- a/lib/view_component/compile_cache.rb +++ b/lib/view_component/compile_cache.rb @@ -4,35 +4,51 @@ module ViewComponent # Keeps track of which templates have already been compiled # This isn't part of the public API module CompileCache - mattr_accessor :cache, instance_reader: false, instance_accessor: false do - Set.new - end - - module_function - - def register(klass) - cache << klass - end - - def compiled?(klass) - cache.include? klass - end - - def invalidate_class!(klass) - cache.delete(klass) - end - - def invalidate! - cache.clear - end - - # Invalidates the cache when a template or a translation file is added, - # changed, or removed. Changes to Ruby files reload the application instead. - def template_watcher(file_watcher) - extensions = ActionView::Template::Handlers.extensions.map(&:to_s) + %w[yml yaml] - directories = ActiveSupport::Dependencies.autoload_paths.index_with(extensions) - - file_watcher.new([], directories) { invalidate! } + # The cache is a frozen, Ractor-shareable Set stored in a module instance + # variable, so it can be read from non-main Ractors. Writes, which only + # happen in the main Ractor while compiling or reloading, replace it with an + # updated copy under a lock. + @cache = Ractor.make_shareable(Set.new) # audition:disable class-level-state + LOCK = Mutex.new # audition:disable mutable-constants + private_constant :LOCK + + class << self + attr_reader :cache + + def cache=(set) + update { set } + end + + def register(klass) + update { |cache| cache | [klass] } + end + + def compiled?(klass) + @cache.include?(klass) + end + + def invalidate_class!(klass) + update { |cache| cache - [klass] } + end + + def invalidate! + update { Set.new } + end + + # Invalidates the cache when a template or a translation file is added, + # changed, or removed. Changes to Ruby files reload the application instead. + def template_watcher(file_watcher) + extensions = ActionView::Template::Handlers.extensions.map(&:to_s) + %w[yml yaml] + directories = ActiveSupport::Dependencies.autoload_paths.index_with(extensions) + + file_watcher.new([], directories) { invalidate! } + end + + private + + def update + LOCK.synchronize { @cache = Ractor.make_shareable(yield(@cache)) } # audition:disable class-level-state + end end end end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 6d6718cef..119384b59 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -63,7 +63,12 @@ def compile(raise_errors: false, force: false) # # @return all matching compiled templates, in priority order based on the requested details from LookupContext def find_templates_for(requested_details) - filtered_templates = @templates.select do |template| + self.class.find_templates_for(@templates, requested_details) + end + + # @private + def self.find_templates_for(templates, requested_details) + filtered_templates = templates.select do |template| template.details.matches?(requested_details) end @@ -76,6 +81,32 @@ def find_templates_for(requested_details) filtered_templates end + # `render_template_for` is defined with procs built here, in a class method, + # so that they (and the templates they capture) can be made Ractor-shareable, + # allowing compiled components to render in non-main Ractors. + # + # @private + def self.render_single_template_proc(template) + safe_call = Ractor.make_shareable(template.safe_method_name_call) + + Ractor.make_shareable( + proc { |_| __vc_render_compiled_template(template, safe_call) } + ) + end + + # @private + def self.render_multiple_templates_proc(templates) + Ractor.make_shareable( + proc do |details| + if (template = Compiler.find_templates_for(templates, details).first) + __vc_render_compiled_template(template, template.safe_method_name_call) + else + raise MissingTemplateError.new(self.class.name, details) + end + end + ) + end + private attr_reader :templates @@ -87,23 +118,16 @@ def define_render_template_for @component.silence_redefinition_of_method(:render_template_for) - if @templates.one? - template = @templates.first - safe_call = template.safe_method_name_call - @component.define_method(:render_template_for) do |_| - @current_template = template - instance_exec(&safe_call) - end - else - compiler = self - @component.define_method(:render_template_for) do |details| - if (@current_template = compiler.find_templates_for(details).first) - instance_exec(&@current_template.safe_method_name_call) - else - raise MissingTemplateError.new(self.class.name, details) - end + @templates = Ractor.make_shareable(@templates) + + @component.define_method( + :render_template_for, + if @templates.one? + self.class.render_single_template_proc(@templates.first) + else + self.class.render_multiple_templates_proc(@templates) end - end + ) end def template_errors diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index d2ea1bea3..5feec90c9 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -99,6 +99,15 @@ class Engine < Rails::Engine # :nodoc: end end + initializer "view_component.ractorize" do + ActiveSupport.on_load(:before_ractorize) do + # I18n memoizes the separator in a class ivar on first use, which non-main + # Ractors can't set, so read it before the application is frozen. + I18n.default_separator + [ViewComponent::Base, *ViewComponent::Base.descendants].each(&:__vc_make_ractor_shareable!) + end + end + initializer "compiler mode" do |_app| ViewComponent::Compiler.__vc_development_mode = (Rails.env.development? || Rails.env.test?) end @@ -107,18 +116,21 @@ class Engine < Rails::Engine # :nodoc: options = app.config.view_component if options.previews.enabled + # Avoid capturing local variables, so the block stays Ractor-shareable. app.routes.prepend do - preview_controller = options.previews.controller.sub(/Controller$/, "").underscore + previews_options = Rails.application.config.view_component.previews + preview_controller = previews_options.controller.sub(/Controller$/, "").underscore + preview_route = previews_options.route get( - options.previews.route, + preview_route, to: "#{preview_controller}#index", as: :preview_view_components, internal: true ) get( - "#{options.previews.route}/*path", + "#{preview_route}/*path", to: "#{preview_controller}#previews", as: :preview_view_component, internal: true diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb index af3059d75..f1f4d0975 100644 --- a/lib/view_component/errors.rb +++ b/lib/view_component/errors.rb @@ -44,12 +44,10 @@ class MissingTemplateError < StandardError "To fix this issue, provide a suitable template.".freeze def initialize(component, request_detail) - detail = { - locale: request_detail.locale, - formats: request_detail.formats, - variants: request_detail.variants, - handlers: request_detail.handlers - } + detail = {locale: :locale_idx, formats: :formats_idx, variants: :variants_idx, handlers: :handlers_idx}.to_h do |name, idx| + # Rails 8.2+ only exposes the requested details as index hashes. + [name, request_detail.respond_to?(name) ? request_detail.public_send(name) : request_detail.public_send(idx).keys.compact] + end super(MESSAGE.gsub("COMPONENT", component).gsub("DETAIL", detail.inspect)) end end diff --git a/lib/view_component/slotable.rb b/lib/view_component/slotable.rb index c60debd37..ed519b307 100644 --- a/lib/view_component/slotable.rb +++ b/lib/view_component/slotable.rb @@ -82,19 +82,19 @@ def renders_one(slot_name, callable = nil) setter_method_name = :"with_#{slot_name}" - define_method setter_method_name do |*args, **kwargs, &block| + __vc_define_shareable_method(self, setter_method_name) do |*args, **kwargs, &block| __vc_set_slot(slot_name, nil, *args, **kwargs, &block) end - self::GeneratedSlotMethods.define_method slot_name do + __vc_define_shareable_method(self::GeneratedSlotMethods, slot_name) do __vc_get_slot(slot_name) end - self::GeneratedSlotMethods.define_method :"#{slot_name}?" do + __vc_define_shareable_method(self::GeneratedSlotMethods, :"#{slot_name}?") do __vc_get_slot(slot_name).present? end - define_method :"with_#{slot_name}_content" do |content| + __vc_define_shareable_method(self, :"with_#{slot_name}_content") do |content| send(setter_method_name) { content.to_s } self @@ -152,17 +152,17 @@ def renders_many(slot_name, callable = nil) setter_method_name = :"with_#{singular_name}" - define_method setter_method_name do |*args, **kwargs, &block| + __vc_define_shareable_method(self, setter_method_name) do |*args, **kwargs, &block| __vc_set_slot(slot_name, nil, *args, **kwargs, &block) end - define_method :"with_#{singular_name}_content" do |content| + __vc_define_shareable_method(self, :"with_#{singular_name}_content") do |content| send(setter_method_name) { content.to_s } self end - define_method :"with_#{slot_name}" do |collection_args = nil, &block| + __vc_define_shareable_method(self, :"with_#{slot_name}") do |collection_args = nil, &block| collection_args.map do |args| if args.respond_to?(:to_hash) __vc_set_slot(slot_name, nil, **args, &block) @@ -172,11 +172,11 @@ def renders_many(slot_name, callable = nil) end end - self::GeneratedSlotMethods.define_method slot_name do + __vc_define_shareable_method(self::GeneratedSlotMethods, slot_name) do __vc_get_slot(slot_name) end - self::GeneratedSlotMethods.define_method :"#{slot_name}?" do + __vc_define_shareable_method(self::GeneratedSlotMethods, :"#{slot_name}?") do __vc_get_slot(slot_name).present? end @@ -229,11 +229,11 @@ def __vc_register_slot(slot_name, **kwargs) end def __vc_register_polymorphic_slot(slot_name, types, collection:) - self::GeneratedSlotMethods.define_method(slot_name) do + __vc_define_shareable_method(self::GeneratedSlotMethods, slot_name) do __vc_get_slot(slot_name) end - self::GeneratedSlotMethods.define_method(:"#{slot_name}?") do + __vc_define_shareable_method(self::GeneratedSlotMethods, :"#{slot_name}?") do __vc_get_slot(slot_name).present? end @@ -263,11 +263,11 @@ def __vc_register_polymorphic_slot(slot_name, types, collection:) raise AlreadyDefinedPolymorphicSlotSetterError.new(setter_method_name, poly_slot_name) end - define_method(setter_method_name) do |*args, **kwargs, &block| + __vc_define_shareable_method(self, setter_method_name) do |*args, **kwargs, &block| __vc_set_polymorphic_slot(slot_name, poly_type, *args, **kwargs, &block) end - define_method :"with_#{poly_slot_name}_content" do |content| + __vc_define_shareable_method(self, :"with_#{poly_slot_name}_content") do |content| send(setter_method_name) { content.to_s } self @@ -280,6 +280,19 @@ def __vc_register_polymorphic_slot(slot_name, types, collection:) } end + # Defines methods with Ractor-shareable procs where possible, so they can + # be called from non-main Ractors. Falls back to the original block when + # it captures unshareable state. + def __vc_define_shareable_method(owner, method_name, &block) + block = begin + Ractor.make_shareable(block) + rescue Ractor::Error, ArgumentError + block + end + + owner.define_method(method_name, block) + end + def __vc_define_slot(slot_name, collection:, callable:) slot = {collection: collection} return slot unless callable @@ -294,7 +307,7 @@ def __vc_define_slot(slot_name, collection:, callable:) # If slot doesn't respond to `render_in`, we assume it's a proc, # define a method, and save a reference to it to call when setting method_name = :"_call_#{slot_name}" - define_method method_name, &callable + __vc_define_shareable_method(self, method_name, &callable) slot[:renderable_function] = instance_method(method_name) else raise(InvalidSlotDefinitionError) diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index 36c4a1dff..5207d033a 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -124,7 +124,7 @@ def compile_to_component end def safe_method_name_call - m = safe_method_name + m = -safe_method_name proc do __vc_maybe_escape_html(send(m)) do Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. " \ @@ -157,7 +157,7 @@ def coverage_running? end def safe_method_name_call - m = safe_method_name + m = -safe_method_name proc { send(m) } end @@ -209,7 +209,7 @@ def annotated? def compiled_source handler = details.handler_class this_source = source - this_source.rstrip! if @component.strip_trailing_whitespace? + this_source = this_source.rstrip if @component.strip_trailing_whitespace? short_identifier = defined?(Rails.root) ? @path.sub("#{Rails.root}/", "") : @path format = self.format || DEFAULT_FORMAT diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index c4d532451..b033e7409 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -20,7 +20,7 @@ module Translatable class_methods do def __vc_i18n_scope - @__vc_i18n_scope ||= virtual_path.sub(%r{^/}, "").gsub(%r{/_?}, ".") + @__vc_i18n_scope ||= -virtual_path.sub(%r{^/}, "").gsub(%r{/_?}, ".") end def __vc_build_i18n_backend @@ -134,7 +134,8 @@ def __vc_html_safe_translation(translation) end def __vc_html_escape_translation_options!(options) - options.except(*::I18n::RESERVED_KEYS).each do |name, value| + reserved_keys = ::I18n.respond_to?(:reserved_keys) ? ::I18n.reserved_keys : ::I18n::RESERVED_KEYS + options.except(*reserved_keys).each do |name, value| next if name == :count && value.is_a?(Numeric) options[name] = ERB::Util.html_escape(value.to_s) diff --git a/test/isolated/ractor_test.rb b/test/isolated/ractor_test.rb new file mode 100644 index 000000000..6720b348d --- /dev/null +++ b/test/isolated/ractor_test.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "minitest/autorun" +require "tmpdir" +require "rails" +require "action_controller/railtie" +require "active_support/testing/isolation" +require "view_component" + +# Boots a production application, calls the experimental +# `Rails.application.ractorize!`, and renders components in non-main Ractors. +# Requires Ruby 4.0+ and a Rails/Rack version that supports `ractorize!`. +# Skips elsewhere, except in the rails_main_head CI job (Ruby head, pinned +# Rails, Rack, and i18n), where every prerequisite is expected to be met. +class RactorTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + + COMPONENTS = { + "app/components/greeting_component.rb" => <<~RUBY, + class GreetingComponent < ViewComponent::Base + def initialize(name:) = @name = name + def before_render = @name = @name.upcase + end + RUBY + "app/components/greeting_component.html.erb" => "
Hello <%= @name %><%= content %>
", + "app/components/inline_component.rb" => <<~RUBY, + class InlineComponent < ViewComponent::Base + def call = content_tag(:p, "inline") + end + RUBY + "app/components/variant_component.rb" => "class VariantComponent < ViewComponent::Base; end", + "app/components/variant_component.html.erb" => "default", + "app/components/variant_component.html+phone.erb" => "phone", + "app/components/parent_component.rb" => "class ParentComponent < ViewComponent::Base; end", + "app/components/parent_component.html.erb" => "parent", + "app/components/child_component.rb" => <<~RUBY, + class ChildComponent < ParentComponent + def call = "\#{render_parent_to_string}".html_safe + end + RUBY + "app/components/slots_component.rb" => <<~RUBY, + class SlotsComponent < ViewComponent::Base + renders_one :header, ->(title:) { content_tag(:h1, title) } + renders_many :items, "ItemComponent" + end + RUBY + "app/components/slots_component.html.erb" => "<%= header %>", + "app/components/item_component.rb" => <<~RUBY, + class ItemComponent < ViewComponent::Base + with_collection_parameter :item + def initialize(item:, item_counter: nil) = (@item = item; @counter = item_counter) + def call = content_tag(:li, "\#{@item}\#{@counter}") + end + RUBY + "app/components/translated_component.rb" => <<~RUBY, + class TranslatedComponent < ViewComponent::Base + def call = t(".hello") + end + RUBY + "app/components/translated_component.en.yml" => "en:\n hello: Hi from sidecar\n", + # Wrapper components instead of ActionView templates: Rails compiles views + # lazily in each Ractor, which recent Ruby head builds don't allow. + "app/components/slots_page_component.rb" => <<~RUBY, + class SlotsPageComponent < ViewComponent::Base + def call + render(SlotsComponent.new) do |c| + c.with_header(title: "T") + c.with_item(item: "a") + c.with_item(item: "b") + nil + end + end + end + RUBY + "app/components/collection_page_component.rb" => <<~RUBY, + class CollectionPageComponent < ViewComponent::Base + def call = render(ItemComponent.with_collection(%w[x y])) + end + RUBY + "app/components/content_page_component.rb" => <<~RUBY, + class ContentPageComponent < ViewComponent::Base + def call = render(GreetingComponent.new(name: "blk")) { "!" } + end + RUBY + "app/controllers/greetings_controller.rb" => <<~RUBY, + class GreetingsController < ActionController::Base + def plain = render(plain: "ok") + def show = render(GreetingComponent.new(name: "Ractor")) + def inline = render(InlineComponent.new) + def child = render(ChildComponent.new) + def translated = render(TranslatedComponent.new) + def slots = render(SlotsPageComponent.new) + def collection = render(CollectionPageComponent.new) + def content = render(ContentPageComponent.new) + + def variant + request.variant = params[:v]&.to_sym + render(VariantComponent.new) + end + end + RUBY + "config/routes.rb" => <<~RUBY + Rails.application.routes.draw do + %w[plain show inline variant child translated slots collection content].each do |action| + get "/\#{action}", to: "greetings#\#{action}" + end + end + RUBY + }.freeze + + def setup + skip_unless_required "Ractor rendering requires Ruby 4.0+" if RUBY_VERSION < "4.0" + skip_unless_required "Rails.application.ractorize! is unavailable" unless Rails::Application.method_defined?(:ractorize!) + + begin + require "rack/ractorize" + require "active_support/ractors" + rescue LoadError + skip_unless_required "rack/ractorize is unavailable" + end + end + + def test_components_render_in_non_main_ractors + boot_ractorized_app + + status, body = request("/plain") + skip_unless_required "Rails can't render in a non-main Ractor in this environment: #{body}" unless status == 200 + + { + "/show" => "
Hello RACTOR
", + "/inline" => "

inline

", + "/variant" => "default", + "/variant?v=phone" => "phone", + "/child" => "parent", + "/slots" => "

T

", + "/collection" => "
  • x0
  • y1
  • ", + "/content" => "
    Hello BLK!
    " + }.each do |path, expected| + assert_equal [200, expected], request(path), path + end + end + + def test_translations_render_in_non_main_ractors + boot_ractorized_app + + # Requires an i18n release with Ractor support (ruby-i18n/i18n#741). + status, body = request("/translated") + skip_unless_required "i18n can't translate in a non-main Ractor: #{body}" if status != 200 && body.include?("I18n") + + assert_equal [200, "Hi from sidecar"], [status, body] + end + + private + + def skip_unless_required(message) + (ENV["RAILS_VERSION"] == "main_head") ? flunk(message) : skip(message) + end + + def boot_ractorized_app + root = Dir.mktmpdir + COMPONENTS.each do |path, source| + FileUtils.mkdir_p(File.dirname(File.join(root, path))) + File.write(File.join(root, path), source) + end + + Rails.env = "production" + # Some Rails framework callbacks aren't shareable yet; warn rather than fail on them. + ActiveSupport::Ractors.unshareable_proc_action = :warn + app = Class.new(Rails::Application) do + config.root = root + config.eager_load = true + config.enable_reloading = false + config.secret_key_base = "x" * 64 + config.logger = ActiveSupport::Ractors::Logger.new + config.public_file_server.enabled = false + config.cache_store = :null_store + config.hosts.clear + config.action_dispatch.show_exceptions = :none + config.view_component.previews.enabled = false + end + app.initialize! + app.ractorize! + # Rails leaves some frozen Sets unshareable, such as `action_methods`, the + # Mime registry, and renderers. Reading them from a non-main Ractor crashes + # Ruby head instead of raising an IsolationError, so share them up front, + # skipping ViewComponent's own state. + AbstractController::Base.descendants.each(&:action_methods) + ObjectSpace.each_object(Module) do |mod| + next if mod.name&.start_with?("ViewComponent") || mod <= ViewComponent::Base + + mod.instance_variables.each do |ivar| + value = mod.instance_variable_get(ivar) + Ractor.make_shareable(value) if value.frozen? && !Ractor.shareable?(value) + rescue Ractor::Error, TypeError + nil + end + end + end + + def request(path) + env = Rack::MockRequest.env_for(path) + env.delete("rack.input") + env.delete("rack.errors") + + Ractor.new(env) do |env| + env["rack.errors"] = $stderr + status, _headers, body = Rails.application.call(env) + out = +"" + body.each { out << _1 } + body.close if body.respond_to?(:close) + [status, out.strip] + rescue Exception => e # standard:disable Lint/RescueException + e = e.cause while e.cause + [e.class.name, "#{e.class}: #{e.message} #{e.backtrace&.first(3)&.join(" ")}"] + end.value + end +end diff --git a/test/sandbox/test/experimentally_cacheable_test.rb b/test/sandbox/test/experimentally_cacheable_test.rb index ebf6cd10c..ebdd82358 100644 --- a/test/sandbox/test/experimentally_cacheable_test.rb +++ b/test/sandbox/test/experimentally_cacheable_test.rb @@ -535,7 +535,7 @@ def test_install_is_idempotent # template was modified is therefore still registered as compiled, and keeps # rendering the modified output after the file is restored. Force it back. def recompile(component) - ViewComponent::CompileCache.cache.delete(component) + ViewComponent::CompileCache.invalidate_class!(component) component.__vc_compile(force: true) end diff --git a/test/sandbox/test/inline_template_test.rb b/test/sandbox/test/inline_template_test.rb index 3762f24a2..97e55d05c 100644 --- a/test/sandbox/test/inline_template_test.rb +++ b/test/sandbox/test/inline_template_test.rb @@ -211,7 +211,7 @@ class InlineComponentDerivedFromComponentSupportingVariants < Level2Component with_new_cache do with_coverage_running do # Force recompilation with coverage "enabled" - ViewComponent::CompileCache.cache.delete(ErbComponent) + ViewComponent::CompileCache.invalidate_class!(ErbComponent) # This would segfault before the fix due to negative lineno render_inline(ErbComponent.new(message: "Foo bar")) @@ -229,7 +229,7 @@ class InlineComponentDerivedFromComponentSupportingVariants < Level2Component without_template_annotations do with_coverage_running do # Force recompilation with coverage "enabled" but annotations disabled - ViewComponent::CompileCache.cache.delete(ErbComponent) + ViewComponent::CompileCache.invalidate_class!(ErbComponent) # This would segfault in v4.3.0 because it only avoided -1 lineno # when annotations were enabled @@ -246,7 +246,7 @@ class InlineComponentDerivedFromComponentSupportingVariants < Level2Component with_new_cache do with_coverage_running do # Force recompilation with coverage "enabled" - ViewComponent::CompileCache.cache.delete(InlineRaiseErbComponent) + ViewComponent::CompileCache.invalidate_class!(InlineRaiseErbComponent) # Inline templates should still work (lineno is 2+, so -1 won't be negative) error = assert_raises ArgumentError do diff --git a/test/sandbox/test/rendering_allocations_test.rb b/test/sandbox/test/rendering_allocations_test.rb index e7140baa1..2dca7bd47 100644 --- a/test/sandbox/test/rendering_allocations_test.rb +++ b/test/sandbox/test/rendering_allocations_test.rb @@ -9,8 +9,8 @@ class RenderingAllocationsTest < ViewComponent::TestCase ["8.0", "3.4"] => 34, ["8.1", "4.0"] => 32, ["8.1", "4.1"] => 32, - ["8.2", "4.0"] => 51, - ["8.2", "4.1"] => 51 + ["8.2", "4.0"] => 42, + ["8.2", "4.1"] => 42 }.freeze COLLECTION_ALLOCATIONS = { @@ -19,8 +19,8 @@ class RenderingAllocationsTest < ViewComponent::TestCase ["8.0", "3.4"] => 67, ["8.1", "4.0"] => 53, ["8.1", "4.1"] => 53, - ["8.2", "4.0"] => 71, - ["8.2", "4.1"] => 71 + ["8.2", "4.0"] => 62, + ["8.2", "4.1"] => 62 }.freeze class TestController < IntegrationExamplesController @@ -47,7 +47,7 @@ def without_debug_event_reporting def test_render_inline_allocations # Stabilize compilation status ahead of testing allocations to simulate rendering # performance with compiled component - ViewComponent::CompileCache.cache.delete(MyComponent) + ViewComponent::CompileCache.invalidate_class!(MyComponent) MyComponent.__vc_ensure_compiled # Ensure any one-time render allocations are done. @@ -67,7 +67,7 @@ def test_render_inline_allocations def test_render_collection_inline_allocations # Stabilize compilation status ahead of testing allocations to simulate rendering # performance with compiled component - ViewComponent::CompileCache.cache.delete(ProductComponent) + ViewComponent::CompileCache.invalidate_class!(ProductComponent) ProductComponent.__vc_ensure_compiled products = [Product.new(name: "Radio clock"), Product.new(name: "Mints")] diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 9a119688a..595db3a0f 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1014,7 +1014,7 @@ def test_compilation_in_production_mode end def test_multithread_render - ViewComponent::CompileCache.cache.delete(MyComponent) + ViewComponent::CompileCache.invalidate_class!(MyComponent) Rails.env.stub :test?, true do threads = Array.new(100) do Thread.new do