11'use client' ;
22
3- import React , { useState , Suspense } from 'react' ;
3+ import React , { Suspense , useRef , useState } from 'react' ;
44import { ErrorBoundary } from 'react-error-boundary' ;
55import RSCRoute from 'react-on-rails-pro/RSCRoute' ;
66import { useRSC } from 'react-on-rails-pro/RSCProvider' ;
7+ import { ServerComponentFetchError } from 'react-on-rails-pro/ServerComponentFetchError' ;
78
89// Same shape and dimensions as the rendered LiveActivity card. Local Suspense
910// fallback prevents the RSCRoute suspension from bubbling to an outer
@@ -23,33 +24,102 @@ function ActivityCardSkeleton() {
2324 ) ;
2425}
2526
27+ function ActivityErrorFallback ( { error, onRetry } ) {
28+ return (
29+ < div className = "bg-rose-50 border border-rose-200 rounded-lg p-4" >
30+ < p className = "text-rose-700 font-semibold mb-1" > Server component fetch failed</ p >
31+ < p className = "text-rose-600 text-sm font-mono mb-3" > { error . message } </ p >
32+ < button
33+ type = "button"
34+ onClick = { onRetry }
35+ className = "px-3 py-1.5 text-sm bg-rose-600 text-white rounded hover:bg-rose-700"
36+ >
37+ Retry
38+ </ button >
39+ </ div >
40+ ) ;
41+ }
42+
43+ function toServerComponentFetchError ( error , componentProps ) {
44+ if ( error instanceof ServerComponentFetchError ) {
45+ return error ;
46+ }
47+
48+ const originalError = error instanceof Error ? error : new Error ( String ( error ) ) ;
49+ return new ServerComponentFetchError ( originalError . message , 'LiveActivity' , componentProps , originalError ) ;
50+ }
51+
52+ function ThrowActivityError ( { error } ) {
53+ throw error ;
54+ }
55+
2656function LiveActivityRefresher ( ) {
27- const [ refreshKey , setRefreshKey ] = useState ( 0 ) ;
28- const [ simulateError , setSimulateError ] = useState ( false ) ;
57+ const [ refreshCount , setRefreshCount ] = useState ( 0 ) ;
58+ const [ routeRefreshKey , setRouteRefreshKey ] = useState ( 0 ) ;
59+ const [ fetchError , setFetchError ] = useState ( null ) ;
60+ const latestRequestIdRef = useRef ( 0 ) ;
2961 const { refetchComponent } = useRSC ( ) ;
3062
63+ const nextRequestId = ( ) => {
64+ latestRequestIdRef . current += 1 ;
65+ return latestRequestIdRef . current ;
66+ } ;
67+
68+ const refetchLiveActivity = ( componentProps ) =>
69+ refetchComponent ( 'LiveActivity' , componentProps , true ) . then ( ( payload ) => {
70+ if ( payload instanceof Error ) {
71+ throw payload ;
72+ }
73+ } ) ;
74+
3175 const handleRefresh = ( ) => {
32- setSimulateError ( false ) ;
33- setRefreshKey ( ( k ) => k + 1 ) ;
76+ const nextKey = refreshCount + 1 ;
77+
78+ nextRequestId ( ) ;
79+ setFetchError ( null ) ;
80+ setRefreshCount ( nextKey ) ;
81+ setRouteRefreshKey ( nextKey ) ;
3482 } ;
3583
3684 const handleSimulateError = ( ) => {
37- setSimulateError ( true ) ;
38- setRefreshKey ( ( k ) => k + 1 ) ;
85+ const requestId = nextRequestId ( ) ;
86+ const nextKey = refreshCount + 1 ;
87+ const errorProps = { simulateError : true , refreshKey : nextKey } ;
88+
89+ setFetchError ( null ) ;
90+ setRefreshCount ( nextKey ) ;
91+ refetchLiveActivity ( errorProps ) . catch ( ( error ) => {
92+ if ( latestRequestIdRef . current === requestId ) {
93+ setFetchError ( toServerComponentFetchError ( error , errorProps ) ) ;
94+ }
95+ } ) ;
3996 } ;
4097
41- // refetchComponent primes the cache with corrected props before resetting
42- // the boundary, so the post-reset render hits cache instead of re-fetching.
43- const buildRetry = ( resetErrorBoundary ) => ( ) => {
44- const newKey = refreshKey + 1 ;
45- setSimulateError ( false ) ;
46- setRefreshKey ( newKey ) ;
47- refetchComponent ( 'LiveActivity' , { simulateError : false , refreshKey : newKey } )
48- // eslint-disable-next-line no-console
49- . catch ( ( err ) => console . error ( 'Retry refetch failed:' , err ) )
50- . finally ( ( ) => resetErrorBoundary ( ) ) ;
98+ const buildBoundaryRetry = ( resetErrorBoundary ) => ( ) => {
99+ const requestId = nextRequestId ( ) ;
100+ const newKey = refreshCount + 1 ;
101+ const correctedProps = { simulateError : false , refreshKey : newKey } ;
102+
103+ setFetchError ( null ) ;
104+ setRefreshCount ( newKey ) ;
105+ refetchLiveActivity ( correctedProps )
106+ . then ( ( ) => {
107+ if ( latestRequestIdRef . current === requestId ) {
108+ setRouteRefreshKey ( newKey ) ;
109+ resetErrorBoundary ( ) ;
110+ }
111+ } )
112+ . catch ( ( err ) => {
113+ if ( latestRequestIdRef . current === requestId ) {
114+ // eslint-disable-next-line no-console
115+ console . error ( 'Retry refetch failed:' , err ) ;
116+ setFetchError ( toServerComponentFetchError ( err , correctedProps ) ) ;
117+ }
118+ } ) ;
51119 } ;
52120
121+ const componentProps = { simulateError : false , refreshKey : routeRefreshKey } ;
122+
53123 return (
54124 < div className = "space-y-3" >
55125 < div className = "flex items-center gap-2" >
@@ -67,30 +137,24 @@ function LiveActivityRefresher() {
67137 >
68138 Simulate Error
69139 </ button >
70- < span className = "text-xs text-slate-500 ml-2" > Refresh count: { refreshKey } </ span >
140+ < span className = "text-xs text-slate-500 ml-2" > Refresh count: { refreshCount } </ span >
71141 </ div >
72142 < ErrorBoundary
73143 // react-error-boundary's fallbackRender is a render-prop API by design;
74- // the closure captures buildRetry which depends on parent state .
144+ // the closure captures retry state from this component .
75145 // eslint-disable-next-line react/no-unstable-nested-components
76146 fallbackRender = { ( { error, resetErrorBoundary } ) => (
77- < div className = "bg-rose-50 border border-rose-200 rounded-lg p-4" >
78- < p className = "text-rose-700 font-semibold mb-1" > Server component fetch failed</ p >
79- < p className = "text-rose-600 text-sm font-mono mb-3" > { error . message } </ p >
80- < button
81- type = "button"
82- onClick = { buildRetry ( resetErrorBoundary ) }
83- className = "px-3 py-1.5 text-sm bg-rose-600 text-white rounded hover:bg-rose-700"
84- >
85- Retry
86- </ button >
87- </ div >
147+ < ActivityErrorFallback error = { error } onRetry = { buildBoundaryRetry ( resetErrorBoundary ) } />
88148 ) }
89- resetKeys = { [ refreshKey ] }
149+ resetKeys = { [ routeRefreshKey ] }
90150 >
91- < Suspense fallback = { < ActivityCardSkeleton /> } >
92- < RSCRoute componentName = "LiveActivity" componentProps = { { simulateError, refreshKey } } />
93- </ Suspense >
151+ { fetchError ? (
152+ < ThrowActivityError error = { fetchError } />
153+ ) : (
154+ < Suspense fallback = { < ActivityCardSkeleton /> } >
155+ < RSCRoute componentName = "LiveActivity" componentProps = { componentProps } />
156+ </ Suspense >
157+ ) }
94158 </ ErrorBoundary >
95159 </ div >
96160 ) ;
0 commit comments