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/client/app/bundles/server-components/components/LiveActivityRefresher.jsx b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx index 1be88de3..9844e27c 100644 --- a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx +++ b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx @@ -1,9 +1,10 @@ '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'; +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,102 @@ 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 [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) { + throw payload; + } + }); + const handleRefresh = () => { - setSimulateError(false); - setRefreshKey((k) => k + 1); + const nextKey = refreshCount + 1; + + nextRequestId(); + setFetchError(null); + setRefreshCount(nextKey); + setRouteRefreshKey(nextKey); }; const handleSimulateError = () => { - setSimulateError(true); - setRefreshKey((k) => k + 1); + const requestId = nextRequestId(); + const nextKey = refreshCount + 1; + const errorProps = { simulateError: true, refreshKey: nextKey }; + + setFetchError(null); + setRefreshCount(nextKey); + refetchLiveActivity(errorProps).catch((error) => { + 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 buildRetry = (resetErrorBoundary) => () => { - const newKey = refreshKey + 1; - setSimulateError(false); - setRefreshKey(newKey); - refetchComponent('LiveActivity', { simulateError: false, refreshKey: newKey }) - // eslint-disable-next-line no-console - .catch((err) => console.error('Retry refetch failed:', err)) - .finally(() => resetErrorBoundary()); + const buildBoundaryRetry = (resetErrorBoundary) => () => { + const requestId = nextRequestId(); + const newKey = refreshCount + 1; + const correctedProps = { simulateError: false, refreshKey: newKey }; + + setFetchError(null); + setRefreshCount(newKey); + refetchLiveActivity(correctedProps) + .then(() => { + if (latestRequestIdRef.current === requestId) { + setRouteRefreshKey(newKey); + resetErrorBoundary(); + } + }) + .catch((err) => { + if (latestRequestIdRef.current === requestId) { + // eslint-disable-next-line no-console + console.error('Retry refetch failed:', err); + setFetchError(toServerComponentFetchError(err, correctedProps)); + } + }); }; + const componentProps = { simulateError: false, refreshKey: routeRefreshKey }; + return (
@@ -67,30 +137,24 @@ function LiveActivityRefresher() { > Simulate Error - Refresh count: {refreshKey} + Refresh count: {refreshCount}
( -
-

Server component fetch failed

-

{error.message}

- -
+ )} - resetKeys={[refreshKey]} + resetKeys={[routeRefreshKey]} > - }> - - + {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/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/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") 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"