Skip to content
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
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Version bump to 17.0.0.rc.7 — per the PR description, this lane is reported as BLOCKED: one system spec (spec/system/server_components_demo_spec.rb:35) regresses under rc.7 vs. the rc.6 baseline. Given this, and that the RC report explicitly says "Do not count this hard-gate lane as green... until the rc.7 RSC error-boundary regression is fixed, explained, or explicitly waived," this PR shouldn't be merged as-is — worth confirming CI is green after the LiveActivityRefresher workaround before merging, and that the workaround doesn't just paper over the underlying regression for other (non-simulated) error paths.

gem "shakapacker", "10.2.0"

# Bundle edge Rails instead: gem "rails", github: "rails/rails"
Expand Down
15 changes: 7 additions & 8 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,33 +24,102 @@ function ActivityCardSkeleton() {
);
}

function ActivityErrorFallback({ error, onRetry }) {
return (
<div className="bg-rose-50 border border-rose-200 rounded-lg p-4">
<p className="text-rose-700 font-semibold mb-1">Server component fetch failed</p>
<p className="text-rose-600 text-sm font-mono mb-3">{error.message}</p>
<button
type="button"
onClick={onRetry}
className="px-3 py-1.5 text-sm bg-rose-600 text-white rounded hover:bg-rose-700"
>
Retry
</button>
</div>
);
}

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;
}
});
Comment on lines +61 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does react-on-rails-pro's useRSC().refetchComponent accept a third boolean parameter, and can its returned promise resolve with an Error instead of rejecting?

💡 Result:

Based on the current React on Rails Pro documentation and implementation, here are the answers to your questions: 1. Does refetchComponent accept a third boolean parameter? No. The refetchComponent(name, props) method accepts only two parameters: the component name and the component props [1][2][3]. 2. Can its returned promise resolve with an Error instead of rejecting? The behavior of the returned promise depends on the nature of the failure: - Promise Rejection: The underlying RSC fetch typically results in a rejected promise when a network, renderer, or deployment failure occurs [4]. Recent updates have ensured that these failures consistently reject, and the internal cache is updated to evict failed promises so that subsequent retries can succeed rather than returning a "sticky" failed state [4][5]. - Error as a Value: Technical documentation notes that payloads which explicitly resolve with an Error value (rather than throwing/rejecting) are intentionally left in the cache [4]. This is distinct from a failed fetch that causes a rejection [4]. In summary, refetchComponent is designed for error-retry flows where you provide the component name and props; it does not take a third boolean parameter [1][6], and it expects standard promise rejection patterns for fetch failures [4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'FILES:\n'
git ls-files | rg 'LiveActivityRefresher\.jsx|refetchComponent|useRSC|react_on_rails|server-components'

printf '\nUSAGES OF refetchComponent:\n'
rg -n "refetchComponent\(" client app . -g '!node_modules' -g '!dist' -g '!build' || true

printf '\nDEFINITIONS/EXPORTS AROUND useRSC:\n'
rg -n "useRSC|refetchComponent" client app . -g '!node_modules' -g '!dist' -g '!build' || true

Repository: shakacode/react-webpack-rails-tutorial

Length of output: 2370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'LIVEACTIVITYREFRESHER CONTEXT:\n'
cat -n client/app/bundles/server-components/components/LiveActivityRefresher.jsx | sed -n '1,140p'

printf '\nREADME CONTEXT:\n'
sed -n '210,250p' README.md

printf '\nREACT-ON-RAILS-PRO INITIALIZER:\n'
cat -n config/initializers/react_on_rails_pro.rb

Repository: shakacode/react-webpack-rails-tutorial

Length of output: 9589


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'PACKAGE/VERSION HINTS:\n'
rg -n "react-on-rails-pro|17\.0\.0-rc\.7|refetchComponent" package.json Gemfile Gemfile.lock yarn.lock pnpm-lock.yaml package-lock.json README.md config client -g '!node_modules' -g '!dist' -g '!build' || true

printf '\nRELEVANT DOCS SEARCH:\n'
rg -n "useRSC\(\)\.refetchComponent|refetchComponent\(name, props|refetchComponent" README.md docs client config -g '!node_modules' -g '!dist' -g '!build' || true

Repository: shakacode/react-webpack-rails-tutorial

Length of output: 5982


Drop the extra true and handle rejections directly. useRSC().refetchComponent(name, props) is documented as a two-argument call, and this path already has a .catch(...); the payload instanceof Error branch reads like it depends on an undocumented resolve-with-Error behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/app/bundles/server-components/components/LiveActivityRefresher.jsx`
around lines 61 - 66, The refetchLiveActivity flow is passing an extra boolean
to refetchComponent and then checking for a resolved Error payload, which
doesn’t match the documented API or the existing rejection handling. Update
refetchLiveActivity in LiveActivityRefresher to call refetchComponent with only
the component name and props, and remove the payload instanceof Error branch so
failures are handled through the existing .catch path.


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));
}
});
};
Comment on lines 84 to 96

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant fetch on "Simulate Error"

handleSimulateError bumps refreshKey (line 78) and fires a manual refetchLiveActivity(errorProps) (line 79) with different props (simulateError: true).

Since refreshKey is also embedded in the componentProps used by the visible <RSCRoute> at line 105/143 ({ simulateError: false, refreshKey }), bumping it causes the mounted RSCRoute to independently re-fetch with simulateError: false — a legitimate, successful fetch that briefly renders (or holds Suspense) before being discarded the moment fetchError resolves and swaps the tree to <ThrowActivityError>.

So every "Simulate Error" click triggers two RSC round-trips: one real fetch that's thrown away, and one error-only fetch used to derive the failure. Net effect (besides the wasted request) is a possible flicker where real stats render for a moment before the error fallback replaces them.

Consider not bumping the display refreshKey for the visible RSCRoute when simulating an error (e.g. track "refresh count" in separate state from the value used as componentProps.refreshKey), so only the intentional error-fetch happens.

Comment on lines 84 to 96

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

handleSimulateError only reacts to the rejected case of refetchLiveActivity. If the fetch were ever to resolve successfully here (e.g. the server stops honoring simulateError), nothing updates routeRefreshKey/re-renders RSCRoute, so the UI is left showing the previous card forever while "Refresh count" has already incremented — silently stale with no error and no new data. Since this path is only reached via the "Simulate Error" button (which is expected to always fail), this is low risk today, but it's worth at least a .then() branch (or a comment noting the assumption) so the code doesn't silently drift out of sync with refreshCount if the demo/server behavior changes.


// 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 (
<div className="space-y-3">
<div className="flex items-center gap-2">
Expand All @@ -67,30 +137,24 @@ function LiveActivityRefresher() {
>
Simulate Error
</button>
<span className="text-xs text-slate-500 ml-2">Refresh count: {refreshKey}</span>
<span className="text-xs text-slate-500 ml-2">Refresh count: {refreshCount}</span>
</div>
<ErrorBoundary
// react-error-boundary's fallbackRender is a render-prop API by design;
// the closure captures buildRetry which depends on parent state.
// the closure captures retry state from this component.
// eslint-disable-next-line react/no-unstable-nested-components
fallbackRender={({ error, resetErrorBoundary }) => (
<div className="bg-rose-50 border border-rose-200 rounded-lg p-4">
<p className="text-rose-700 font-semibold mb-1">Server component fetch failed</p>
<p className="text-rose-600 text-sm font-mono mb-3">{error.message}</p>
<button
type="button"
onClick={buildRetry(resetErrorBoundary)}
className="px-3 py-1.5 text-sm bg-rose-600 text-white rounded hover:bg-rose-700"
>
Retry
</button>
</div>
<ActivityErrorFallback error={error} onRetry={buildBoundaryRetry(resetErrorBoundary)} />
)}
resetKeys={[refreshKey]}
resetKeys={[routeRefreshKey]}
>
<Suspense fallback={<ActivityCardSkeleton />}>
<RSCRoute componentName="LiveActivity" componentProps={{ simulateError, refreshKey }} />
</Suspense>
{fetchError ? (
<ThrowActivityError error={fetchError} />
) : (
<Suspense fallback={<ActivityCardSkeleton />}>
<RSCRoute componentName="LiveActivity" componentProps={componentProps} />
</Suspense>
)}
</ErrorBoundary>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ function ServerComponentsPage({ comments = [] }) {
<h2 className="text-xl font-semibold text-slate-700 mb-4 flex items-center gap-2">
Live Server Activity
<span className="text-xs font-normal bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">
RSCRoute + ErrorBoundary
RSCRoute + Error Recovery
</span>
</h2>
<p className="text-slate-500 text-sm mb-4">
Click <strong>Refresh</strong> 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{' '}
<strong>Simulate Error</strong> to make the server component throw; the failure surfaces as{' '}
<code>ServerComponentFetchError</code> and is caught by <code>&lt;ErrorBoundary&gt;</code>, which
renders a Retry button that calls <code>refetchComponent</code> with corrected props.
<code>ServerComponentFetchError</code> through the RSC refetch API, and the Retry button calls{' '}
<code>refetchComponent</code> with corrected props.
</p>
<LiveActivityRefresher />
</section>
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion spec/system/server_components_demo_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Per the PR description, this exact spec (server_components_demo_spec.rb:35) is reported as FAILING against the rc.7 node renderer (RENDERER_URL=http://localhost:3801 bundle exec rspec1 failure), while it passes against the rc.6 baseline. The PR's own "Release Impact" section says not to count this lane as green until the regression is fixed/waived.

Merging this PR as-is means master will carry a spec that's known to fail against the RC dependency set it just pinned to (rc.7 gem/npm pins in Gemfile/package.json). Suggest either:

  • Holding this PR until the upstream RSC error-boundary regression (react_on_rails#4357-adjacent) is resolved in rc.8+, or
  • If landing intentionally as a documented-red RC probe, marking this example pending/skipped with a comment linking the tracking issue, so CI doesn't silently show a real regression as routine noise.

click_button "Simulate Error"
expect(page).to have_content("Server component fetch failed")

Expand Down
34 changes: 17 additions & 17 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
Loading