From 7f7c5ccc05669b623f0aecfb4286daeba6432845 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Mon, 6 Jul 2026 23:50:50 -1000 Subject: [PATCH 1/3] Test React on Rails 17 rc7 --- Gemfile | 2 +- Gemfile.lock | 15 +++++++-------- README.md | 10 +++++----- package.json | 6 +++--- yarn.lock | 34 +++++++++++++++++----------------- 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/Gemfile b/Gemfile index 343fc60c..c078c191 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby "3.4.6" gem "cpflow", "5.1.1", require: false -gem "react_on_rails_pro", "17.0.0.rc.6" +gem "react_on_rails_pro", "17.0.0.rc.7" gem "shakapacker", "10.2.0" # Bundle edge Rails instead: gem "rails", github: "rails/rails" diff --git a/Gemfile.lock b/Gemfile.lock index e8c08948..e02234c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,7 +78,7 @@ GEM addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) - async (2.39.0) + async (2.42.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) @@ -191,7 +191,7 @@ GEM interception (0.5) io-console (0.8.2) io-endpoint (0.17.2) - io-event (1.16.2) + io-event (1.19.1) io-stream (0.13.1) irb (1.18.0) pp (>= 0.6.0) @@ -346,22 +346,21 @@ GEM erb psych (>= 4.0.0) tsort - react_on_rails (17.0.0.rc.6) + react_on_rails (17.0.0.rc.7) addressable connection_pool execjs (~> 2.5) rails (>= 5.2) rainbow (~> 3.0) shakapacker (>= 6.0) - react_on_rails_pro (17.0.0.rc.6) - addressable + react_on_rails_pro (17.0.0.rc.7) async (>= 2.29) async-http (~> 0.95) execjs (~> 2.9) io-endpoint (~> 0.17.0) jwt (>= 2.5, < 4) - rainbow - react_on_rails (= 17.0.0.rc.6) + nokogiri (>= 1.12, < 2) + react_on_rails (= 17.0.0.rc.7) redcarpet (3.6.0) redis (5.3.0) redis-client (>= 0.22.0) @@ -549,7 +548,7 @@ DEPENDENCIES rails-html-sanitizer rails_best_practices rainbow - react_on_rails_pro (= 17.0.0.rc.6) + react_on_rails_pro (= 17.0.0.rc.7) redcarpet redis (~> 5.0) rspec-rails (~> 6.0.0) diff --git a/README.md b/README.md index 0cf47a7f..753d7b63 100644 --- a/README.md +++ b/README.md @@ -184,11 +184,11 @@ assets_bundler: rspack ### Version Targets -- `react_on_rails_pro` gem: `17.0.0.rc.6` -- `react-on-rails-pro` npm package: `17.0.0-rc.6` -- `react-on-rails-pro-node-renderer` npm package: `17.0.0-rc.6` -- `react-on-rails-rsc` npm package: `19.2.0` -- `shakapacker` gem/npm package: `10.1.0` +- `react_on_rails_pro` gem: `17.0.0.rc.7` +- `react-on-rails-pro` npm package: `17.0.0-rc.7` +- `react-on-rails-pro-node-renderer` npm package: `17.0.0-rc.7` +- `react-on-rails-rsc` npm package: `19.2.1-rc.0` +- `shakapacker` gem/npm package: `10.2.0` - `@rspack/core` and `@rspack/cli`: `2.0.0-beta.7` - `react`: `~19.2.7` (minimum for React Server Components) diff --git a/package.json b/package.json index 2fd1be9f..01f313de 100644 --- a/package.json +++ b/package.json @@ -82,9 +82,9 @@ "react-dom": "~19.2.7", "react-error-boundary": "^4.1.2", "react-intl": "^6.4.4", - "react-on-rails-pro": "17.0.0-rc.6", - "react-on-rails-pro-node-renderer": "17.0.0-rc.6", - "react-on-rails-rsc": "19.2.0", + "react-on-rails-pro": "17.0.0-rc.7", + "react-on-rails-pro-node-renderer": "17.0.0-rc.7", + "react-on-rails-rsc": "19.2.1-rc.0", "react-redux": "^8.1.0", "react-router": "^6.13.0", "react-router-dom": "^6.13.0", diff --git a/yarn.lock b/yarn.lock index 5028d4d2..f2b0ca9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8767,10 +8767,10 @@ react-is@^18.0.0, react-is@^18.3.1: resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-on-rails-pro-node-renderer@17.0.0-rc.6: - version "17.0.0-rc.6" - resolved "https://registry.npmjs.org/react-on-rails-pro-node-renderer/-/react-on-rails-pro-node-renderer-17.0.0-rc.6.tgz#1fef0e21a7a13eb9d4525e05b6989a0ed79fb9c2" - integrity sha512-nX9Nmgb3hiUMPItC5xfV/W/FGnYLLzefr5qJOURPxrkHgEa6dw1ndnH2mRPyh0zFkOaCB2FBIy8lkWfXvRpfiA== +react-on-rails-pro-node-renderer@17.0.0-rc.7: + version "17.0.0-rc.7" + resolved "https://registry.npmjs.org/react-on-rails-pro-node-renderer/-/react-on-rails-pro-node-renderer-17.0.0-rc.7.tgz#dc5da170b71ae91cb6e53305f90645ee5c2d95c9" + integrity sha512-qyB0wd1AU7KsPMJJyc3U3l4rgq8ZVR8FOAqNQ4riZ2adqeNfz5k5Gx1NKwKMWd51ri7fGqyZDFC8295bAIQEZw== dependencies: "@fastify/formbody" "^7.4.0 || ^8.0.2" "@fastify/multipart" "^8.3.1 || ^9.0.3" @@ -8780,27 +8780,27 @@ react-on-rails-pro-node-renderer@17.0.0-rc.6: lockfile "^1.0.4" pino "^9.14.0 || ^10.1.0" -react-on-rails-pro@17.0.0-rc.6: - version "17.0.0-rc.6" - resolved "https://registry.npmjs.org/react-on-rails-pro/-/react-on-rails-pro-17.0.0-rc.6.tgz#41dc95ed0e3513822a2f0c9b4b25b34c607106be" - integrity sha512-Uc4IUmNYAJIk4iy4RatLN0qVM7MAi8zgXVvd7vZMEnVkGGhHqvj1A82ft97yJqgfqF9mm3eSPPyU3gyXrH5CsQ== +react-on-rails-pro@17.0.0-rc.7: + version "17.0.0-rc.7" + resolved "https://registry.npmjs.org/react-on-rails-pro/-/react-on-rails-pro-17.0.0-rc.7.tgz#d7f79b9c19e66061452bf2eecc64532da385339a" + integrity sha512-tfHNFjlX0Vnb/t4GxUc3X3mw742hHcrR3VapYfz3kAlDPNnpMi03PzPVPxwAO5FeQloHvAKq9/rjUemdsRDSQQ== dependencies: - react-on-rails "17.0.0-rc.6" + react-on-rails "17.0.0-rc.7" -react-on-rails-rsc@19.2.0: - version "19.2.0" - resolved "https://registry.npmjs.org/react-on-rails-rsc/-/react-on-rails-rsc-19.2.0.tgz#3b539142048047193d524265fabdc81356bd482f" - integrity sha512-4PlfNFWGHl029al/yi10G3fcREbTwzqQ45uqYs6i9Baqds2OROyWp1xdorfX4Ok46IVJjje14SeaJVyN8pGDGg== +react-on-rails-rsc@19.2.1-rc.0: + version "19.2.1-rc.0" + resolved "https://registry.npmjs.org/react-on-rails-rsc/-/react-on-rails-rsc-19.2.1-rc.0.tgz#cf50da6586b8c978d3ff0699178e892e8cbde006" + integrity sha512-lWUQloOMYS2/Jio7M9pKaY84+gNJ1ZD2pMTj37HDZtf6GsJjb0CJfJlizvxbquUdMQawX+1h59c63syFZ7AWmQ== dependencies: acorn-loose "^8.3.0" neo-async "^2.6.1" react-server-dom-webpack "~19.2.7" webpack-sources "^3.2.0" -react-on-rails@17.0.0-rc.6: - version "17.0.0-rc.6" - resolved "https://registry.npmjs.org/react-on-rails/-/react-on-rails-17.0.0-rc.6.tgz#4372994d13e7dcff4acaf06efad7b413da4bc42d" - integrity sha512-/u2hzYvuUN+1HnH0gEdbfQvkWkgLMSN2kl/2x9CCOInIHffBvilgXzBgJrP5NlvjWpguUWSxO6PrYpgV0BzLMA== +react-on-rails@17.0.0-rc.7: + version "17.0.0-rc.7" + resolved "https://registry.npmjs.org/react-on-rails/-/react-on-rails-17.0.0-rc.7.tgz#02c73fc20e68a7471aae391c9e5c7ed5ca3adaff" + integrity sha512-JWeDJeFUc8TEWtz4MWLMHpCIPBw3stKBx//EY8aPDZDwL1UxX3tnDDt/4JlIOzvGonz4ncGxEbEKcnBheyafDw== react-proxy@^1.1.7: version "1.1.8" From 3a8788b11efa910424412d007010ba77889fef29 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 8 Jul 2026 00:09:55 -1000 Subject: [PATCH 2/3] Fix RSC error recovery demo --- .../components/LiveActivityRefresher.jsx | 96 ++++++++++++++----- .../ror_components/ServerComponentsPage.jsx | 6 +- spec/system/server_components_demo_spec.rb | 2 +- 3 files changed, 76 insertions(+), 28 deletions(-) diff --git a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx index 1be88de3..3c5e5eb6 100644 --- a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx +++ b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx @@ -4,6 +4,7 @@ import React, { useState, Suspense } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import RSCRoute from 'react-on-rails-pro/RSCRoute'; import { useRSC } from 'react-on-rails-pro/RSCProvider'; +import { ServerComponentFetchError } from 'react-on-rails-pro/ServerComponentFetchError'; // Same shape and dimensions as the rendered LiveActivity card. Local Suspense // fallback prevents the RSCRoute suspension from bubbling to an outer @@ -23,33 +24,86 @@ function ActivityCardSkeleton() { ); } +function ActivityErrorFallback({ error, onRetry }) { + return ( +
+

Server component fetch failed

+

{error.message}

+ +
+ ); +} + +function toServerComponentFetchError(error, componentProps) { + if (error instanceof ServerComponentFetchError) { + return error; + } + + const originalError = error instanceof Error ? error : new Error(String(error)); + return new ServerComponentFetchError(originalError.message, 'LiveActivity', componentProps, originalError); +} + +function ThrowActivityError({ error }) { + throw error; +} + function LiveActivityRefresher() { const [refreshKey, setRefreshKey] = useState(0); - const [simulateError, setSimulateError] = useState(false); + const [fetchError, setFetchError] = useState(null); const { refetchComponent } = useRSC(); + const refetchLiveActivity = (componentProps) => + refetchComponent('LiveActivity', componentProps, true).then((payload) => { + if (payload instanceof Error) { + throw payload; + } + }); + const handleRefresh = () => { - setSimulateError(false); + setFetchError(null); setRefreshKey((k) => k + 1); }; const handleSimulateError = () => { - setSimulateError(true); - setRefreshKey((k) => k + 1); + const nextKey = refreshKey + 1; + const errorProps = { simulateError: true, refreshKey: nextKey }; + + setFetchError(null); + setRefreshKey(nextKey); + refetchLiveActivity(errorProps).catch((error) => { + setFetchError(toServerComponentFetchError(error, errorProps)); + }); }; // refetchComponent primes the cache with corrected props before resetting // the boundary, so the post-reset render hits cache instead of re-fetching. - const buildRetry = (resetErrorBoundary) => () => { + const handleRetry = () => { const newKey = refreshKey + 1; - setSimulateError(false); + const correctedProps = { simulateError: false, refreshKey: newKey }; + + setFetchError(null); setRefreshKey(newKey); - refetchComponent('LiveActivity', { simulateError: false, refreshKey: newKey }) + refetchLiveActivity(correctedProps) // eslint-disable-next-line no-console - .catch((err) => console.error('Retry refetch failed:', err)) - .finally(() => resetErrorBoundary()); + .catch((err) => { + console.error('Retry refetch failed:', err); + setFetchError(toServerComponentFetchError(err, correctedProps)); + }); }; + const buildBoundaryRetry = (resetErrorBoundary) => () => { + handleRetry(); + resetErrorBoundary(); + }; + + const componentProps = { simulateError: false, refreshKey }; + return (
@@ -71,26 +125,20 @@ function LiveActivityRefresher() {
( -
-

Server component fetch failed

-

{error.message}

- -
+ )} resetKeys={[refreshKey]} > - }> - - + {fetchError ? ( + + ) : ( + }> + + + )}
); diff --git a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx index 1aa636f6..4ba036bf 100644 --- a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx +++ b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx @@ -63,15 +63,15 @@ function ServerComponentsPage({ comments = [] }) {

Live Server Activity - RSCRoute + ErrorBoundary + RSCRoute + Error Recovery

Click Refresh to fetch a new RSC payload — the server re-renders this section and streams the result back, no client-side JSON parsing or loading state plumbing. Click{' '} Simulate Error to make the server component throw; the failure surfaces as{' '} - ServerComponentFetchError and is caught by <ErrorBoundary>, which - renders a Retry button that calls refetchComponent with corrected props. + ServerComponentFetchError through the RSC refetch API, and the Retry button calls{' '} + refetchComponent with corrected props.

diff --git a/spec/system/server_components_demo_spec.rb b/spec/system/server_components_demo_spec.rb index 6361fc68..dd543cb7 100644 --- a/spec/system/server_components_demo_spec.rb +++ b/spec/system/server_components_demo_spec.rb @@ -32,7 +32,7 @@ expect(page).to have_content("Refresh count: 1") end - it "shows the ErrorBoundary fallback when Simulate Error is clicked, then recovers on Retry" do + it "shows the error recovery fallback when Simulate Error is clicked, then recovers on Retry" do click_button "Simulate Error" expect(page).to have_content("Server component fetch failed") From 6da5a7de9e587564234718c324e5d199c1626b6a Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Wed, 8 Jul 2026 00:25:13 -1000 Subject: [PATCH 3/3] Tighten RSC error recovery flow --- .../components/LiveActivityRefresher.jsx | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx index 3c5e5eb6..9844e27c 100644 --- a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx +++ b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, Suspense } from 'react'; +import React, { Suspense, useRef, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import RSCRoute from 'react-on-rails-pro/RSCRoute'; import { useRSC } from 'react-on-rails-pro/RSCProvider'; @@ -54,10 +54,17 @@ function ThrowActivityError({ error }) { } function LiveActivityRefresher() { - const [refreshKey, setRefreshKey] = useState(0); + const [refreshCount, setRefreshCount] = useState(0); + const [routeRefreshKey, setRouteRefreshKey] = useState(0); const [fetchError, setFetchError] = useState(null); + const latestRequestIdRef = useRef(0); const { refetchComponent } = useRSC(); + const nextRequestId = () => { + latestRequestIdRef.current += 1; + return latestRequestIdRef.current; + }; + const refetchLiveActivity = (componentProps) => refetchComponent('LiveActivity', componentProps, true).then((payload) => { if (payload instanceof Error) { @@ -66,43 +73,52 @@ function LiveActivityRefresher() { }); const handleRefresh = () => { + const nextKey = refreshCount + 1; + + nextRequestId(); setFetchError(null); - setRefreshKey((k) => k + 1); + setRefreshCount(nextKey); + setRouteRefreshKey(nextKey); }; const handleSimulateError = () => { - const nextKey = refreshKey + 1; + const requestId = nextRequestId(); + const nextKey = refreshCount + 1; const errorProps = { simulateError: true, refreshKey: nextKey }; setFetchError(null); - setRefreshKey(nextKey); + setRefreshCount(nextKey); refetchLiveActivity(errorProps).catch((error) => { - setFetchError(toServerComponentFetchError(error, errorProps)); + if (latestRequestIdRef.current === requestId) { + setFetchError(toServerComponentFetchError(error, errorProps)); + } }); }; - // refetchComponent primes the cache with corrected props before resetting - // the boundary, so the post-reset render hits cache instead of re-fetching. - const handleRetry = () => { - const newKey = refreshKey + 1; + const buildBoundaryRetry = (resetErrorBoundary) => () => { + const requestId = nextRequestId(); + const newKey = refreshCount + 1; const correctedProps = { simulateError: false, refreshKey: newKey }; setFetchError(null); - setRefreshKey(newKey); + setRefreshCount(newKey); refetchLiveActivity(correctedProps) - // eslint-disable-next-line no-console + .then(() => { + if (latestRequestIdRef.current === requestId) { + setRouteRefreshKey(newKey); + resetErrorBoundary(); + } + }) .catch((err) => { - console.error('Retry refetch failed:', err); - setFetchError(toServerComponentFetchError(err, correctedProps)); + if (latestRequestIdRef.current === requestId) { + // eslint-disable-next-line no-console + console.error('Retry refetch failed:', err); + setFetchError(toServerComponentFetchError(err, correctedProps)); + } }); }; - const buildBoundaryRetry = (resetErrorBoundary) => () => { - handleRetry(); - resetErrorBoundary(); - }; - - const componentProps = { simulateError: false, refreshKey }; + const componentProps = { simulateError: false, refreshKey: routeRefreshKey }; return (
@@ -121,7 +137,7 @@ function LiveActivityRefresher() { > Simulate Error - Refresh count: {refreshKey} + Refresh count: {refreshCount}
( )} - resetKeys={[refreshKey]} + resetKeys={[routeRefreshKey]} > {fetchError ? (