Skip to content

feat(pro): add imperative refetch API to RSCRoute (#3106)#3215

Open
AbanoubGhadban wants to merge 8 commits into
mainfrom
worktree-issue-3106-rsc-refetch-api
Open

feat(pro): add imperative refetch API to RSCRoute (#3106)#3215
AbanoubGhadban wants to merge 8 commits into
mainfrom
worktree-issue-3106-rsc-refetch-api

Conversation

@AbanoubGhadban
Copy link
Copy Markdown
Collaborator

@AbanoubGhadban AbanoubGhadban commented Apr 29, 2026

Summary

Closes Issue #3106. Adds two ergonomic ways to refetch a <RSCRoute> without the caller having to know its componentName or componentProps:

  • Ref handle<RSCRoute ref={...} /> exposes RSCRouteHandle with refetch(). A parent or sibling can drive the refresh.
  • useCurrentRSCRoute() hook — a client component rendered inside the server component's subtree can refetch the surrounding <RSCRoute> without being passed any props. Throws the spec-mandated message useCurrentRSCRoute must be used inside an <RSCRoute> if called outside one.

Both APIs auto-update the rendered tree — no caller-side setKey/useState workaround. The internal cache invalidation runs inside a React transition so old content stays visible while the new RSC payload streams in (no Suspense-fallback flash). Refetching propagates to every <RSCRoute> instance bound to the same cache key.

The existing useRSC().refetchComponent(name, props) API and the ServerComponentFetchError-based retry flow are unchanged — purely additive.

Implementation highlights

  • RSCProvider is now a real React component. The cache lives in a useRef (not reactive) and a per-cache-key versions: Record<string, number> map lives in useState. refetchComponent writes the new promise into the cache synchronously, then bumps versions[key] inside startTransition. Listing versions in the context value's useMemo deps is what propagates the re-render to every useRSC() consumer; the surrounding startTransition makes those re-renders transition commits.
  • <RSCRoute> is converted to forwardRef and holds a latestPropsRef so a captured-then-stale refetch callback always reads the current name+props. It provides its handle via a private CurrentRSCRouteContext so descendants in the RSC subtree can read it via useCurrentRSCRoute().

See Issue #3106 for the full acceptance criteria.

Stress demo

A new dummy-app page at /server_router/refetch-stress exercises 8 scenarios end-to-end:

  1. Ref handle (parent triggers refetch).
  2. useCurrentRSCRoute() from inside the RSC subtree.
  3. Multi-instance fan-out (same name + same props → both update on one refetch).
  4. Independent siblings (same name, different props → each refresh isolated).
  5. Captured handle survives prop change (latest props win).
  6. Rapid double-click (concurrent refetches).
  7. Many siblings (5 distinct keys, refresh-all).
  8. Mount/unmount cycle (ref.current null after unmount, set after re-mount).

None of the scenarios use a caller-side setKey/useState workaround — that is the whole point.

Test plan

  • pnpm --filter react-on-rails-pro run type-check — passes.
  • pnpm --filter react-on-rails-pro run test:non-rsc44 / 44 pass (9 new, 35 pre-existing).
  • Unit tests cover all 7 acceptance criteria from the issue plus multi-instance fan-out plus a deterministic last-write-wins concurrency test under out-of-order resolution.
  • Webpack: all 3 bundles (client + server + RSC) compile clean.
  • Playwright e2e (react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts) covers all 8 stress scenarios — 8 / 8 pass on Chromium against the local dev stack.
  • Root prettier + root ESLint + Pro ESLint — clean on all changed files.
  • Manual visual verification on the dummy app stress page.
  • Two-round AI code review against codex (gpt-5.4, high reasoning) — first round flagged 1 blocker (deterministic concurrency test) + 2 nits (docs note about error-boundary case, scenario-8 tautology fix); all addressed in commit 67500f13f; round 2 verdict: APPROVED.

Side fixes

  • packages/react-on-rails-pro/jest.config.js — added moduleNameMapper to dedupe react/react-dom to the workspace root copy. Without it, hooks called from the Pro package source see a null React dispatcher because @testing-library/react resolves React from root (19.2.3) while the package source resolves it from packages/react-on-rails-pro/node_modules (19.2.0). Latent bug introduced by PR #2155 when react/react-dom were added as devDependencies of the Pro package — the mismatch wasn't surfaced because the test:non-rsc jest pattern excludes every test that combines @testing-library/react rendering with hook-bearing source paths.
  • packages/react-on-rails-pro/README.md — fixed pre-existing import-style bug (named-import syntax for what are actually default exports).

Out of scope (worth follow-up issues, not this PR)

  • Removing the duplicate React install at the project level (pnpm.overrides or dropping the redundant Pro devDep) so the jest.config.js mapper becomes unnecessary.
  • Fixing the test:non-rsc regex hole that has been silently skipping registerServerComponent.client.test.jsx, SuspenseHydration.test.tsx, streamServerRenderedReactComponent.test.jsx, and others — including, ironically, several tests that would have caught the React-duplication issue in CI before this PR.
  • registerServerComponent.client.test.jsx is independently broken since PR #2074 (Nov 20, 2025) which renamed testUtils.jstestUtils.ts but didn't update the test's import path.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Imperative refetch for server-component routes via ref handles and a subtree hook to trigger refreshes.
    • Inline refresh button component and a Refetch Stress demo page.
  • Documentation

    • Updated guides and API docs clarifying ref-based refresh vs. error-recovery flows and usage examples.
  • Tests

    • Added unit and end-to-end tests covering imperative refetch, shared-cache fan-out, lifecycle, and stress scenarios.
  • Changelog

    • New unreleased changelog entry documenting the feature.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 07d883cb-c43e-448b-9b25-f0bb17151203

📥 Commits

Reviewing files that changed from the base of the PR and between 51b152a and 5e20eac.

📒 Files selected for processing (1)
  • react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts
✅ Files skipped from review due to trivial changes (1)
  • react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts

Walkthrough

Adds an imperative refetch API for React Server Components: RSCRoute accepts an optional ref exposing refetch() and publishes a subtree-local handle retrievable via useCurrentRSCRoute(). Refetch calls the provider with enforceRefetch, runs inside a React transition, updates cache versions, and streams new payloads without forcing a Suspense fallback.

Changes

Imperative RSC refetch feature

Layer / File(s) Summary
Public types / props
packages/react-on-rails-pro/src/RSCRoute.tsx
Adds exported RSCRouteHandle type and adds optional ref?: Ref<RSCRouteHandle> to RSCRouteProps.
Core provider implementation
packages/react-on-rails-pro/src/RSCProvider.tsx
Refactors provider to use useRef-backed per-provider promise cache, useCallback getComponent/refetchComponent, versions state bumped inside startTransition, and memoized context that changes identity on version updates.
Route component & hook
packages/react-on-rails-pro/src/RSCRoute.tsx
Converts RSCRoute to forwardRef, implements refetch() calling refetchComponent({enforceRefetch: true}), tracks latest name/props in a ref, publishes handle via new CurrentRSCRouteContext, and exports useCurrentRSCRoute() which errors outside an RSCRoute.
Unit tests
packages/react-on-rails-pro/tests/imperativeRefetch.client.test.tsx
New client tests exercising ref handle refetch, captured-handle vs latest props, hook-triggered refetch, lifecycle semantics, shared-cache fan-out, and out-of-order resolution behavior.
Demo / examples
react_on_rails_pro/spec/dummy/.../RefetchStressServerComponent.jsx, .../InlineRefreshButton.tsx, .../RefetchStressPage.tsx, .../ServerComponentRouter.tsx
Adds server component and client demo components/pages and router entry to exercise and demonstrate imperative refetch scenarios.
End-to-end tests
react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts
Playwright suite validating eight stress scenarios covering handle/button, hook-triggered, fan-out, isolation, captured-handle after prop change, rapid refetches, many-sibling refresh, and mount/unmount lifecycle.
Docs & changelog
CHANGELOG.md, docs/pro/..., docs/oss/..., packages/react-on-rails-pro/README.md
Adds changelog entry and docs describing RSCRoute ref handle, RSCRouteHandle type, useCurrentRSCRoute() hook, clarifies useRSC scope for error recovery, and updates examples/registration snippets.
Jest config
packages/react-on-rails-pro/jest.config.js
Adjusts moduleNameMapper to remap react/JSX runtime conditionally based on NODE_CONDITIONS including react-server to dedupe React resolution during tests.
Misc docs
.claude/commands/stress-test.md
Formatting/emphasis changes to stress-test command docs (non-functional).

Sequence Diagram

sequenceDiagram
    participant User
    participant InlineRefreshButton as InlineRefreshButton (Client)
    participant RSCRoute as RSCRoute
    participant RSCProvider as RSCProvider (context)
    participant Server as getServerComponent (Server)

    User->>InlineRefreshButton: click
    InlineRefreshButton->>RSCRoute: useCurrentRSCRoute().refetch()
    RSCRoute->>RSCProvider: refetchComponent(enforceRefetch: true)
    RSCProvider->>Server: getServerComponent(name, props, enforceRefetch: true)
    Server-->>RSCProvider: Promise<RSC payload>
    RSCProvider->>RSCProvider: update cache & bump version (startTransition)
    RSCProvider->>RSCRoute: resolved promise -> re-render
    RSCRoute->>RSCRoute: stream new payload while preserving old content
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I tug a gentle handle, call the fetch,

The server hums and sends a streaming mesh,
Inside the route a button beats—refresh,
Old view stays warm while newer leaves refresh,
Timestamps hop, and carrots taste afresh.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main feature: adding an imperative refetch API to RSCRoute. It is concise, clear, and directly reflects the primary changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-issue-3106-rsc-refetch-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/8 reviews remaining, refill in 56 minutes and 48 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread CHANGELOG.md Outdated

#### Added

- **[Pro]** **Imperative refetch API for `<RSCRoute>`**: `<RSCRoute>` now accepts an optional `ref` typed as `RSCRouteHandle`, exposing `refetch()` so a parent or sibling can refetch a server component without knowing its `componentName` or `componentProps`. A new `useCurrentRSCRoute()` hook returns the same handle for client components rendered inside the RSC subtree (e.g. an inline "Refresh" button rendered by the server component itself); calling it outside an `<RSCRoute>` ancestor throws `useCurrentRSCRoute must be used inside an <RSCRoute>`. Both APIs auto-update the rendered tree — no caller-side `setKey`/`useState` workaround — and propagate to every `<RSCRoute>` instance bound to the same cache key. The internal cache invalidation runs inside a React transition, so old content stays visible while the new RSC payload streams in (no Suspense-fallback flash). The existing `useRSC().refetchComponent(name, props)` API and the `ServerComponentFetchError`-based retry flow are unchanged. Fixes [Issue 3106](https://github.com/shakacode/react_on_rails/issues/3106). [PR 3231](https://github.com/shakacode/react_on_rails/pull/3231) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wrong PR number in the link. The entry says [PR 3231] (linking to /pull/3231) but this is PR #3215. Please update both the display text and the URL.

Suggested change
- **[Pro]** **Imperative refetch API for `<RSCRoute>`**: `<RSCRoute>` now accepts an optional `ref` typed as `RSCRouteHandle`, exposing `refetch()` so a parent or sibling can refetch a server component without knowing its `componentName` or `componentProps`. A new `useCurrentRSCRoute()` hook returns the same handle for client components rendered inside the RSC subtree (e.g. an inline "Refresh" button rendered by the server component itself); calling it outside an `<RSCRoute>` ancestor throws `useCurrentRSCRoute must be used inside an <RSCRoute>`. Both APIs auto-update the rendered tree — no caller-side `setKey`/`useState` workaround — and propagate to every `<RSCRoute>` instance bound to the same cache key. The internal cache invalidation runs inside a React transition, so old content stays visible while the new RSC payload streams in (no Suspense-fallback flash). The existing `useRSC().refetchComponent(name, props)` API and the `ServerComponentFetchError`-based retry flow are unchanged. Fixes [Issue 3106](https://github.com/shakacode/react_on_rails/issues/3106). [PR 3231](https://github.com/shakacode/react_on_rails/pull/3231) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
- **[Pro]** **Imperative refetch API for `<RSCRoute>`**: `<RSCRoute>` now accepts an optional `ref` typed as `RSCRouteHandle`, exposing `refetch()` so a parent or sibling can refetch a server component without knowing its `componentName` or `componentProps`. A new `useCurrentRSCRoute()` hook returns the same handle for client components rendered inside the RSC subtree (e.g. an inline "Refresh" button rendered by the server component itself); calling it outside an `<RSCRoute>` ancestor throws `useCurrentRSCRoute must be used inside an <RSCRoute>`. Both APIs auto-update the rendered tree — no caller-side `setKey`/`useState` workaround — and propagate to every `<RSCRoute>` instance bound to the same cache key. The internal cache invalidation runs inside a React transition, so old content stays visible while the new RSC payload streams in (no Suspense-fallback flash). The existing `useRSC().refetchComponent(name, props)` API and the `ServerComponentFetchError`-based retry flow are unchanged. Fixes [Issue 3106](https://github.com/shakacode/react_on_rails/issues/3106). [PR 3215](https://github.com/shakacode/react_on_rails/pull/3215) by [AbanoubGhadban](https://github.com/AbanoubGhadban).

getServerComponent: (props: ClientGetReactServerComponentProps) => Promise<ReactNode>;
}) => {
const fetchRSCPromises: Record<string, Promise<ReactNode>> = {};
return ({ children }: { children: ReactNode }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The returned component is an anonymous function, so it shows up as Component (or nothing) in React DevTools. Consider assigning a displayName after the closing brace — matching the pattern used for RSCRoute:

Suggested change
return ({ children }: { children: ReactNode }) => {
const RSCProvider = ({ children }: { children: ReactNode }) => {

Then after the closing }; add RSCProvider.displayName = 'RSCProvider'; and return RSCProvider;. This makes DevTools profiles and error stacks readable.

Comment on lines +100 to +107
// `versions` is intentionally listed in deps so the value identity
// changes on each refetch — that is what propagates the re-render to
// every useRSC() consumer.
const contextValue = useMemo(
() => ({ getComponent, refetchComponent }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[getComponent, refetchComponent, versions],
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Using versions as a useMemo dependency while intentionally not using it inside the factory function is semantically incorrect (the linter is right to flag it — hence the eslint-disable). A cleaner way to achieve the same propagation is to include versions directly in the context value:

const contextValue = useMemo(
  () => ({ getComponent, refetchComponent, _v: versions }),
  [getComponent, refetchComponent, versions],
);

_v is a non-enumerable-by-convention marker that consumers ignore (nothing currently reads it). The deps are now semantically correct, the lint suppression goes away, and the re-render propagation is explicit in the value shape. Consumers that care only about getComponent/refetchComponent can destructure and ignore _v — no behaviour change.

Comment on lines 106 to 110
export type RSCRouteProps = {
componentName: string;
componentProps: unknown;
ref?: Ref<RSCRouteHandle>;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The ref field in RSCRouteProps is a forward-compat hint for React 19 (where refs are plain props), but the component is currently implemented with forwardRef (React 18 API). Including ref in the exported props type while the component uses forwardRef<…, Omit<RSCRouteProps, 'ref'>> is subtle and can confuse callers.

If React 19 compat is the goal, it's worth a brief inline note explaining that ref is intentionally in the public type for forward-compat, and that it's stripped via Omit in the forwardRef overload today. Otherwise, removing it from RSCRouteProps and letting forwardRef's own type inference provide ref is cleaner.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.76 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.76 KB (0%)
react-on-rails/client bundled (brotli) 53.86 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.86 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.71 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.71 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.63 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.63 KB (0%)
registerServerComponent/client bundled (gzip) 127.73 KB (+0.16% 🔺)
registerServerComponent/client bundled (gzip) (time) 127.73 KB (+0.16% 🔺)
registerServerComponent/client bundled (brotli) 61.83 KB (+0.22% 🔺)
registerServerComponent/client bundled (brotli) (time) 61.83 KB (+0.22% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 122.11 KB (+0.08% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.11 KB (+0.08% 🔺)
wrapServerComponentRenderer/client bundled (brotli) 56.86 KB (+0.22% 🔺)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.86 KB (+0.22% 🔺)

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 29, 2026

Code Review — PR #3215: Imperative refetch API for <RSCRoute>

Overview

Well-scoped, well-tested addition. The two-pronged API (ref handle + useCurrentRSCRoute()) covers the realistic call sites cleanly, the latestPropsRef pattern correctly solves the stale-closure problem, and the startTransition-based invalidation keeps the existing content visible during the fetch. The 8-scenario stress suite and the deterministic concurrency test are particularly solid.

Inline comments have been posted for the items below. One is a blocking typo; the rest are design notes.


Issues (inline comments posted)

Bug — fix before merge

Design / code-quality notes

  • createRSCProvider returns an anonymous component (RSCProvider.tsx line 63): No displayName is set on the returned component, so it shows up as Anonymous or Component in React DevTools and error stack traces. RSCRoute correctly sets displayName = 'RSCRoute' — apply the same pattern here.

  • useMemo with semantically-incorrect deps (RSCProvider.tsx lines 100–107): versions is listed in useMemo's dependency array but is not used inside the factory, which is why the lint rule has to be suppressed. The intent (force a new context value object on each refetch) is valid, but the cleaner fix is to include versions in the context value (e.g. as _v: versions) so the dependency is semantically correct and the eslint-disable comment can be removed.

  • Orphaned JSDoc for <RSCRoute> (RSCRoute.tsx): The original component-level JSDoc block (lines 64–85) now immediately precedes the new RSCRouteHandle JSDoc block instead of the forwardRef call it describes (now at line 150). TypeScript tooling will attach that outer JSDoc to RSCRouteHandle, not to the component. It should be moved to sit directly above the forwardRef expression.

  • ref in RSCRouteProps vs forwardRef (React 19 compat) (RSCRoute.tsx lines 106–110): Exporting ref?: Ref<RSCRouteHandle> on the props type while the implementation uses forwardRef<…, Omit<RSCRouteProps, 'ref'>> is slightly confusing. If this is intentional for React 19 forward-compat (where refs become plain props), a brief comment to that effect prevents future readers from removing ref from the type as a cleanup.

  • InlineRefreshButton pending state vs transition timing (InlineRefreshButton.tsx lines 15–23): setPending(false) fires in .finally() when the fetch promise resolves, but React's startTransition commit may not have happened yet at that point. There is a small window where the button re-enables before the new content is visible. This is a demo component so it is not urgent, but it is worth a comment, and the broader API could be improved by exposing the transition's isPending state from RSCRouteHandle.


Performance note (informational, not blocking)

Every call to refetchComponent bumps the versions counter, which changes the context value object identity, causing every useRSC() consumer (i.e. every <RSCRoute> on the page) to re-render — even those bound to a different cache key. For most pages this is negligible, but at scale (many independent server components on one page) per-key subscriptions would avoid the fan-out. The PR description already flags this as a follow-up, which is appropriate.


What's well done

  • The latestPropsRef trick is the correct solution to the stale-closure problem and is well-tested by test case 2.
  • The deterministic concurrency test (test case 8) using controlled deferred promises is exactly the right approach for last-write-wins verification.
  • The jest.config.js React deduplication fix is a legitimate bug fix with a clear explanation.
  • The README import-style corrections are accurate.
  • Documentation is thorough and the decision table ("When to use which") is genuinely useful.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 29, 2026

Greptile Summary

This PR adds two ergonomic imperative-refetch APIs to RSCRoute: a ref handle exposing refetch() and a useCurrentRSCRoute() hook for client components embedded inside an RSC subtree. RSCProvider is refactored into a real React component using a useRef-backed cache, a useState versions counter, and startTransition-driven context invalidation to propagate refetches to all consumers without a Suspense fallback flash. Both APIs are purely additive; the existing useRSC().refetchComponent() flow is unchanged.

Confidence Score: 4/5

Safe to merge; only P2 style findings with no impact on runtime behavior.

No P0 or P1 issues found. The startTransition-driven context invalidation, latestPropsRef pattern, and last-write-wins cache semantics are all implemented correctly. The two findings are both P2: an orphaned JSDoc block and a missing comment on an intentionally empty useCallback deps array.

No files require special attention; both changed source files are straightforward to reason about.

Important Files Changed

Filename Overview
packages/react-on-rails-pro/src/RSCProvider.tsx Converted to a real React component; added useRef cache, useState versions counter, and startTransition-driven context invalidation to propagate refetches. Logic is sound; minor documentation gap on getComponent's empty deps array.
packages/react-on-rails-pro/src/RSCRoute.tsx Converted to forwardRef; adds latestPropsRef, useImperativeHandle, CurrentRSCRouteContext.Provider, and useCurrentRSCRoute hook. Implementation is correct; orphaned JSDoc block is a minor documentation issue.
packages/react-on-rails-pro/tests/imperativeRefetch.client.test.tsx New test file covering all 7 acceptance criteria plus multi-instance fan-out and a deterministic last-write-wins concurrency test. Test structure is clean and covers the important edge cases.
packages/react-on-rails-pro/jest.config.js Adds moduleNameMapper to deduplicate React/React-DOM to the workspace root copy, fixing a latent dual-React dispatcher bug.
react_on_rails_pro/spec/dummy/client/app/components/RefetchStressPage.tsx New stress-demo page covering all 8 scenarios from the issue. Well-structured with individual scenario components; no issues.
react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts Playwright e2e tests covering all 8 stress scenarios. Uses expect.poll for async assertions; clean and readable.
react_on_rails_pro/spec/dummy/client/app/components/InlineRefreshButton.tsx New client component demonstrating useCurrentRSCRoute() usage from inside an RSC subtree. Straightforward and correct.
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RefetchStressServerComponent.jsx New server component for stress demo; renders a timestamp and random token to make refreshes visually obvious.
react_on_rails_pro/spec/dummy/client/app/components/ServerComponentRouter.tsx Adds route and nav link for the refetch-stress page. Trivial additive change.

Sequence Diagram

sequenceDiagram
    participant Caller as Caller (ref / useCurrentRSCRoute)
    participant RSCRoute as RSCRoute
    participant RSCProvider as RSCProvider
    participant Cache as fetchRSCPromisesRef
    participant Server as getServerComponent

    Caller->>RSCRoute: refetch()
    RSCRoute->>RSCProvider: refetchComponent(name, props)
    RSCProvider->>Server: getServerComponent({ enforceRefetch: true })
    Server-->>RSCProvider: promise (pending)
    RSCProvider->>Cache: cache[key] = promise (sync write)
    RSCProvider->>RSCProvider: startTransition → setVersions(key++)
    Note over RSCProvider: versions change → new contextValue reference
    RSCProvider-->>RSCRoute: promise returned
    RSCProvider->>RSCRoute: context re-render (transition)
    RSCRoute->>Cache: getComponent(name, props) → promise
    RSCRoute->>RSCRoute: use(promise) — suspends (old UI stays visible)
    Server-->>RSCRoute: promise resolves → new ReactNode
    RSCRoute-->>Caller: UI committed with new content
Loading

Comments Outside Diff (1)

  1. packages/react-on-rails-pro/src/RSCRoute.tsx, line 64-85 (link)

    P2 Orphaned JSDoc block above RSCRouteHandle

    The original component-level JSDoc (describing "Renders a React Server Component inside a React Client Component…") is no longer adjacent to the RSCRoute definition — the new RSCRouteHandle type and useCurrentRSCRoute export have been inserted between it and the component. In most documentation tooling the first /** block will now either be attributed to RSCRouteHandle (shadowed by the second block) or appear as a floating orphan. The component is left undocumented in the rendered docs. Consider moving the component-level comment to just above the forwardRef call.

Reviews (1): Last reviewed commit: "test(pro): add deterministic concurrency..." | Re-trigger Greptile

Comment on lines +72 to +81
const getComponent = useCallback((componentName: string, componentProps: unknown) => {
const key = createRSCPayloadKey(componentName, componentProps);
if (key in fetchRSCPromisesRef.current) {
return fetchRSCPromisesRef.current[key];
}

const promise = getServerComponent({ componentName, componentProps });
fetchRSCPromises[key] = promise;
return promise;
};
const promise = getServerComponent({ componentName, componentProps });
fetchRSCPromisesRef.current[key] = promise;
return promise;
}, []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Empty useCallback deps hides getServerComponent dependency

getComponent uses getServerComponent from the outer factory closure but declares deps = []. This is intentionally correct because getServerComponent is captured once when createRSCProvider is called and is stable for the lifetime of the returned component — it is not React state or a prop. However, the absence of any comment here (unlike the well-documented versions trick in useMemo below) means a future maintainer or an automated exhaustive-deps lint pass could add getServerComponent to the array, which would break the caching semantics (a new callback reference on every render). A short // getServerComponent is captured from the factory closure and never changes comment would protect this invariant.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-on-rails-pro/README.md (1)

7-13: ⚠️ Potential issue | 🟡 Minor

Add exact-version pinning flags to the install commands.

Floating versions can drift from the gem-matched Pro build. Update the examples to use --save-exact (npm), --exact (yarn), and --exact (pnpm):

Suggested fix
npm install --save-exact react-on-rails-pro
# or
yarn add --exact react-on-rails-pro
# or
pnpm add --exact react-on-rails-pro
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/README.md` around lines 7 - 13, Update the
install examples in README.md to pin exact package versions: replace the npm
command to include --save-exact and update both yarn and pnpm commands to
include --exact so the provided install snippets use exact-version pinning (npm:
add --save-exact; yarn: add --exact; pnpm: add --exact) to prevent drift from
the gem-matched Pro build.
🧹 Nitpick comments (1)
packages/react-on-rails-pro/src/RSCProvider.tsx (1)

63-107: Scope the refetch rerender to the affected cache key.

Because versions is part of the provider value, any refetch changes the context identity and rerenders every useRSC() consumer, even when only one key changed. If this provider can wrap multiple independent <RSCRoute> trees, a keyed subscription/store would avoid unnecessary churn.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/src/RSCProvider.tsx` around lines 63 - 107, The
provider currently includes the whole `versions` state in `contextValue`, so any
refetch bumps the context identity and rerenders all `useRSC()` consumers;
replace this with a keyed subscription mechanism so only the affected cache key
consumers rerender. Add a subscription registry (e.g. a Map from payload key to
Set of callbacks) alongside `fetchRSCPromisesRef`, expose `subscribe(key, cb)`
and `unsubscribe(key, cb)` in the context returned by useMemo (include
`getComponent` and `refetchComponent`), and in `refetchComponent` after
replacing the promise invoke only the callbacks for that key; remove `versions`
from the context and from the useMemo deps so context identity no longer changes
for unrelated keys. Ensure symbols referenced are `fetchRSCPromisesRef`,
`getComponent`, `refetchComponent`, `contextValue`, `versions`, and
`createRSCPayloadKey`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CHANGELOG.md`:
- Line 29: The changelog entry in CHANGELOG.md contains a broken link to "PR
3231" which 404s and breaks CI; update the PR reference so it points to the
correct existing pull request URL (or remove/replace the link with the correct
PR number or plain text) in the paragraph mentioning "PR 3231" (which also
references Issue 3106 and AbanoubGhadban) so the markdown-link check passes.

In `@react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts`:
- Around line 5-8: The comment and code disagree: the BASE constant only reads
process.env.PLAYWRIGHT_BASE_URL, but the comment claims BASE_URL is supported;
update the bootstrap so BASE checks both environment variables (e.g.,
process.env.PLAYWRIGHT_BASE_URL first, then process.env.BASE_URL) before falling
back to the default 'http://127.0.0.1:3002'; locate the BASE constant in
refetch-stress.spec.ts and change its initialization to consult both
PLAYWRIGHT_BASE_URL and BASE_URL in that order so runs that set BASE_URL
continue to work.

---

Outside diff comments:
In `@packages/react-on-rails-pro/README.md`:
- Around line 7-13: Update the install examples in README.md to pin exact
package versions: replace the npm command to include --save-exact and update
both yarn and pnpm commands to include --exact so the provided install snippets
use exact-version pinning (npm: add --save-exact; yarn: add --exact; pnpm: add
--exact) to prevent drift from the gem-matched Pro build.

---

Nitpick comments:
In `@packages/react-on-rails-pro/src/RSCProvider.tsx`:
- Around line 63-107: The provider currently includes the whole `versions` state
in `contextValue`, so any refetch bumps the context identity and rerenders all
`useRSC()` consumers; replace this with a keyed subscription mechanism so only
the affected cache key consumers rerender. Add a subscription registry (e.g. a
Map from payload key to Set of callbacks) alongside `fetchRSCPromisesRef`,
expose `subscribe(key, cb)` and `unsubscribe(key, cb)` in the context returned
by useMemo (include `getComponent` and `refetchComponent`), and in
`refetchComponent` after replacing the promise invoke only the callbacks for
that key; remove `versions` from the context and from the useMemo deps so
context identity no longer changes for unrelated keys. Ensure symbols referenced
are `fetchRSCPromisesRef`, `getComponent`, `refetchComponent`, `contextValue`,
`versions`, and `createRSCPayloadKey`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c8dd0853-2590-4157-a72c-5b7d01b6d6d0

📥 Commits

Reviewing files that changed from the base of the PR and between 60b91e4 and 67500f1.

📒 Files selected for processing (13)
  • CHANGELOG.md
  • docs/oss/migrating/rsc-troubleshooting.md
  • docs/pro/react-server-components/inside-client-components.md
  • packages/react-on-rails-pro/README.md
  • packages/react-on-rails-pro/jest.config.js
  • packages/react-on-rails-pro/src/RSCProvider.tsx
  • packages/react-on-rails-pro/src/RSCRoute.tsx
  • packages/react-on-rails-pro/tests/imperativeRefetch.client.test.tsx
  • react_on_rails_pro/spec/dummy/client/app/components/InlineRefreshButton.tsx
  • react_on_rails_pro/spec/dummy/client/app/components/RefetchStressPage.tsx
  • react_on_rails_pro/spec/dummy/client/app/components/ServerComponentRouter.tsx
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RefetchStressServerComponent.jsx
  • react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts

Comment thread CHANGELOG.md Outdated
Comment thread react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts Outdated
AbanoubGhadban added a commit that referenced this pull request Apr 30, 2026
…ution, e2e baseURL)

Three real failures + one infra flake observed on PR #3215:

  1. markdown-link-check: CHANGELOG entry linked to placeholder PR 3231
     which 404s. Fix: use the real PR number 3215.

  2. build (22) → JS unit tests for react-on-rails-pro: the
     `moduleNameMapper` introduced for the non-rsc bucket (to dedupe
     React across the dual install) ALSO ran for the rsc bucket. A
     directory-path redirect bypasses Jest's customExportConditions
     resolution, so `*.rsc.test.tsx` ended up loading `react/index.js`
     instead of `react.react-server.js` and all four suites died with
     'The "react" package in this environment is not configured
     correctly'. Fix: branch the mapper on `NODE_CONDITIONS` and, under
     react-server, redirect to the explicit `*.react-server.js` files
     so dedupe still happens but Jest no longer needs to consult
     package exports. 7/7 rsc tests pass; 44/44 non-rsc tests still
     pass.

  3. dummy-app-node-renderer-e2e-tests: the new Playwright spec
     hardcoded http://127.0.0.1:3002 (my local Rails port). CI runs
     Rails on 3000. Fix: use a relative URL so Playwright prepends its
     configured baseURL (matches CI). The accompanying comment about
     BASE_URL/PLAYWRIGHT_BASE_URL env-var overrides was also wrong;
     the project's playwright.config.ts doesn't read either, so the
     comment now correctly points users at the config file for
     non-default ports.

  Reviewed in two rounds with codex (gpt-5.4, high reasoning); first
  round rejected an empty `{}` mapper for the rsc branch as fragile
  against future hook-using rsc tests; this commit's targeted file-
  path mapper was approved on round 2. CodeQL's 'Requires
  authentication' result-upload error is an infra/permissions issue
  and is left for CI re-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines +103 to +107
const contextValue = useMemo(
() => ({ getComponent, refetchComponent }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[getComponent, refetchComponent, versions],
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The versions side-channel in useMemo deps works, but it requires suppressing the exhaustive-deps rule for a non-obvious reason. An alternative that's equally correct and lint-clean is to include the version counter directly in the context value:

Suggested change
const contextValue = useMemo(
() => ({ getComponent, refetchComponent }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[getComponent, refetchComponent, versions],
);
const contextValue = useMemo(
() => ({ getComponent, refetchComponent, _v: versions }),
[getComponent, refetchComponent, versions],
);

_v would need to be stripped from the RSCContextType or the type updated — alternatively, a dedicated _cacheVersion field with a clear doc comment. Either way, the intent becomes explicit and the eslint-disable goes away.

If keeping the current shape is important (don't expose _v to consumers), the current approach is fine, but the eslint-disable should carry a stronger explanation of why the factory function intentionally doesn't reference versions (it's a re-render trigger, not a computation input).

ref?: Ref<RSCRouteHandle>;
};

const CurrentRSCRouteContext = createContext<RSCRouteHandle | null>(null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing displayName on CurrentRSCRouteContext. This makes React DevTools show the generic Context.Provider label in the component tree instead of something meaningful.

Suggested change
const CurrentRSCRouteContext = createContext<RSCRouteHandle | null>(null);
const CurrentRSCRouteContext = createContext<RSCRouteHandle | null>(null);
CurrentRSCRouteContext.displayName = 'CurrentRSCRouteContext';

Comment on lines +86 to +101
/**
* Imperative handle exposed by `<RSCRoute>` via `ref`.
*
* `refetch()` re-fetches the server component using the RSCRoute's currently
* rendered `componentName` and `componentProps`. It resolves with the new
* rendered ReactNode and rejects if the fetch fails.
*
* Behavior caveats:
* - **Concurrent refetches:** only the most-recent cache write wins; earlier
* returned promises may resolve with stale data while the UI has already
* moved on to a later refetch.
* - **Unmount:** if the owning `<RSCRoute>` unmounts before resolution, the
* shared cache still updates (so other RSCRoutes bound to the same key
* reflect the new payload) but no visible re-render happens in the
* unmounted instance.
*/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two consecutive JSDoc blocks here, both ending immediately before export type RSCRouteHandle, creates a layout mismatch: the first block (the original RSCRoute component doc, lines 64-84) is now orphaned from its component definition at line 150, while this second block documents RSCRouteHandle correctly.

A reader scanning top-to-bottom will associate the first block with RSCRouteHandle and miss the connection to RSCRoute. Consider:

  • Moving the RSCRoute component docblock directly above its forwardRef call (line 150), or
  • Adding a small visual separator comment (// --- types ---) between the two doc blocks to make the split explicit.

Comment on lines +24 to +33
};

return (
<button type="button" data-testid={testId} disabled={pending} onClick={handleClick}>
{pending ? 'Refreshing…' : label}
</button>
);
};

export default InlineRefreshButton;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

handleClick is recreated on every render. This component is 'use client' and is used as a demo, but it will be copy-pasted by users as a reference pattern. Wrapping in useCallback is the idiomatic form for an event handler that calls a stable hook-returned function:

Suggested change
};
return (
<button type="button" data-testid={testId} disabled={pending} onClick={handleClick}>
{pending ? 'Refreshing…' : label}
</button>
);
};
export default InlineRefreshButton;
const handleClick = React.useCallback(() => {
setPending(true);
refetch()
.catch((err: unknown) => {
console.error('InlineRefreshButton refetch failed', err);
})
.finally(() => {
setPending(false);
});
}, [refetch]);

refetch from useCurrentRSCRoute() is already stable (memoized inside RSCRoute), so the useCallback dep array won't thrash.

Comment on lines +102 to +103
export type RSCRouteHandle = {
refetch: () => Promise<ReactNode>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The refetch() return value has a subtle trap in the concurrent-refetch case that isn't captured by the type: refetch() returns the promise for this particular network request, but after a second rapid refetch() call the cache has already moved on to the newer promise. The first returned promise will resolve with a payload that the UI never renders — it's been superseded.

The behaviour caveats block above mentions this for concurrent refetches ("earlier returned promises may resolve with stale data"), which is good. Consider adding a one-liner to the JSDoc reinforcing that callers should not rely on the resolved value for rendering decisions:

Suggested change
export type RSCRouteHandle = {
refetch: () => Promise<ReactNode>;
export type RSCRouteHandle = {
/**
* Re-fetches the server component with its currently-rendered props.
*
* The returned promise resolves when this specific network request
* completes. **Do not use the resolved value to drive rendering** the
* component updates itself automatically, and in a concurrent-refetch
* scenario the UI may have already moved past this request's payload
* before it resolves.
*/
refetch: () => Promise<ReactNode>;
};

Comment on lines +46 to +48
'^react$': '<rootDir>/../../node_modules/react/react.react-server.js',
'^react/jsx-runtime$': '<rootDir>/../../node_modules/react/jsx-runtime.react-server.js',
'^react/jsx-dev-runtime$': '<rootDir>/../../node_modules/react/jsx-dev-runtime.react-server.js',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These paths hard-code React 19.x internal filenames. They're correct today but will silently fail with an inscrutable "module not found" if React renames these exports in a future major/minor. The PR description already tracks "Removing the duplicate React install" as the proper fix. In the meantime, adding an explicit note about which React version range these are stable for will help upgraders know to recheck:

Suggested change
'^react$': '<rootDir>/../../node_modules/react/react.react-server.js',
'^react/jsx-runtime$': '<rootDir>/../../node_modules/react/jsx-runtime.react-server.js',
'^react/jsx-dev-runtime$': '<rootDir>/../../node_modules/react/jsx-dev-runtime.react-server.js',
'^react$': '<rootDir>/../../node_modules/react/react.react-server.js',
// jsx-runtime and jsx-dev-runtime filenames are stable in React 19.x;
// verify against react/package.json#exports["react-server"] on React upgrades.
'^react/jsx-runtime$': '<rootDir>/../../node_modules/react/jsx-runtime.react-server.js',
'^react/jsx-dev-runtime$': '<rootDir>/../../node_modules/react/jsx-dev-runtime.react-server.js',

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts`:
- Around line 74-79: The test "6. rapid double-click: UI ends up showing the
latest payload" only issues a single click; change the interaction with the
'rapid-button' to perform a rapid double-click (either call click twice in
immediate succession on page.getByTestId('rapid-button') or use Playwright's
dblclick/click({ clickCount: 2 })) so the test actually triggers two quick
refetches and then poll the 'stress-time-rapid' textContent as before to assert
it changes from the original value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 019e5991-ed96-4bea-8bd0-a650c393de8d

📥 Commits

Reviewing files that changed from the base of the PR and between 67500f1 and bc5b97b.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • packages/react-on-rails-pro/jest.config.js
  • react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md

Comment thread react_on_rails_pro/spec/dummy/e2e-tests/refetch-stress.spec.ts
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

Code Review — PR #3215: Imperative refetch API for <RSCRoute>

Overview

Well-implemented, cleanly additive feature. The two-pronged API (ref handle + useCurrentRSCRoute()) covers the main caller positions without leaking implementation details, and the latestPropsRef pattern correctly solves the stale-closure problem for captured handles. Test coverage is thorough: the deferred-fetcher concurrency test (scenario 8) is particularly good at nailing the last-write-wins invariant deterministically rather than via timing.

The mechanism — bumping a versions counter in state (inside startTransition) so the useMemo cache for context value gets a new identity, causing all consumers to re-render and re-read the updated promise from the ref — is correct and well-commented.


Performance: all RSCRoute instances re-render on every refetch

When any refetchComponent call bumps versions, the useMemo for contextValue creates a new object, which invalidates the context and causes every useRSC() consumer to re-render — even instances bound to unrelated cache keys. In a page with N <RSCRoute> components, a single refetch costs O(N) transition re-renders.

Each of those re-renders is cheap (just a getComponent cache lookup that returns the same promise), and they're all transitions, so there's no visible jank. This is acceptable for V1. But it's worth calling out now so a follow-up issue can track a more targeted approach (e.g. per-key subscriptions via an emitter, or splitting the versions map into a separate context that only routes with the affected key subscribe to) before this becomes a scaling concern in production.

refetch() return value in concurrent scenarios

refetch() returns the promise for that specific network request. In a rapid double-click scenario the first promise resolves with a payload the UI never renders (the cache has already moved to the second promise). The type-level JSDoc on RSCRouteHandle (inline comment posted) flags this, but callers who pattern-match on await ref.current.refetch() to confirm the UI updated could get a false positive. Not a bug — just a footgun worth surfacing clearly in the type docs.


Summary of inline comments

File Topic Severity
RSCProvider.tsx:103-107 versions side-channel in useMemo deps — suggest cleaner alternative or stronger comment Nit
RSCRoute.tsx:102-103 Add to RSCRouteHandle JSDoc: don't rely on resolved value for rendering Minor
RSCRoute.tsx:86-101 Two consecutive orphaned doc blocks — move RSCRoute's block next to its forwardRef Nit
RSCRoute.tsx:112 Missing CurrentRSCRouteContext.displayName Nit
jest.config.js:46-48 Hardcoded React 19.x internal filenames — add version-range note for upgraders Minor
InlineRefreshButton.tsx:24-33 handleClick should be useCallback-wrapped (it's the reference pattern users will copy) Minor

Pre-existing items called out in PR description (no action needed on this PR)

  • Duplicate React install (pnpm.overrides fix) — tracked as follow-up
  • test:non-rsc regex hole silently skipping several test files — tracked as follow-up
  • registerServerComponent.client.test.jsx broken import since PR convert testUtils to ts #2074 — tracked as follow-up

Overall: ready to merge after addressing the minor items above (at minimum the RSCRouteHandle JSDoc clarification and the InlineRefreshButton useCallback). The core implementation is sound.

🤖 Generated with Claude Code

AbanoubGhadban and others added 6 commits April 30, 2026 19:53
Adds two ergonomic ways to refetch a `<RSCRoute>` without the caller having
to know its `componentName` or `componentProps`:

  1. Ref handle — `<RSCRoute ref={...} />` exposes `RSCRouteHandle` with
     `refetch()`. A parent or sibling can drive a refresh.

  2. `useCurrentRSCRoute()` hook — a client component rendered inside the
     server component's subtree can refetch the surrounding RSCRoute
     without being passed any props. Throws "useCurrentRSCRoute must be
     used inside an <RSCRoute>" if used outside one.

Implementation:

  - RSCProvider now holds the per-cache-key `versions` map in `useState` and
    bumps it inside `startTransition` on every refetchComponent call. That
    propagates to every `useRSC()` consumer and lets React keep old content
    visible while the new RSC payload streams in (no Suspense fallback
    flash). Cache itself stays in `useRef` — only the version map is
    reactive.

  - RSCRoute is now a `forwardRef` component. It memoizes a stable handle
    via `useImperativeHandle`, exposes the same handle to descendants via
    `CurrentRSCRouteContext`, and uses `latestPropsRef` so a captured
    refetch handle always reads the current name+props (regression-tested).

  - Existing `refetchComponent(name, props)` signature and the
    `ServerComponentFetchError` retry flow are untouched — purely additive.

Tests:

  - `tests/imperativeRefetch.client.test.tsx` covers all 7 acceptance
    criteria from the issue plus a multi-instance fan-out test (two
    RSCRoutes sharing a cache key both update on a single refetch).

  - `jest.config.js` adds a `react`/`react-dom` moduleNameMapper to dedupe
    the workspace's two React copies into the test process. Without it,
    hooks called from the package source see a null dispatcher because
    `@testing-library/react` resolves React from root while the package
    source resolves it from `packages/react-on-rails-pro/node_modules`.
    Latent issue introduced by #2155 when react was added as a devDep.

Dummy app stress page (`/server_router/refetch-stress`):

  Eight scenarios — ref handle, hook from inside the RSC subtree,
  multi-instance fan-out, independent siblings (different props),
  captured-handle survives a prop change, rapid double-click, many siblings
  with distinct keys, mount/unmount cycle. None of the scenario components
  use a caller-side `setKey`/`useState` workaround — that's the whole
  point.

  Playwright spec at `e2e-tests/refetch-stress.spec.ts` drives all eight
  scenarios end-to-end (8/8 passing on Chromium and Firefox locally).

Closes #3106.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  - CHANGELOG.md: add `#### Added` entry under [Unreleased] for the
    imperative refetch API.
  - docs/pro/react-server-components/inside-client-components.md: new
    "Manually refetching a server component" section with the three
    patterns (ref handle, useCurrentRSCRoute hook, useRSC().refetchComponent)
    and a "when to use which" table; new rows in the API reference table
    for `RSCRouteHandle` and `useCurrentRSCRoute`.
  - InlineRefreshButton, RefetchStressPage: extract handlers into named
    functions and replace `void` operators with explicit
    `.catch(() => {})` to satisfy the project's
    `@typescript-eslint/no-misused-promises`, `no-void`, and
    `no-confusing-void-expression` rules.
  - imperativeRefetch.client.test.tsx: prettier auto-formatting only.

No behavior changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ix README imports

  - docs/oss/migrating/rsc-troubleshooting.md: at the end of "Finer-Grained
    Retry with refetchComponent", add a pointer to the new
    `<RSCRoute ref={...}>` and `useCurrentRSCRoute()` APIs in
    inside-client-components.md for refetch triggers that aren't part of
    an error-recovery flow.

  - packages/react-on-rails-pro/README.md: fix the RSC code snippet that
    used named imports (`import { RSCRoute }`,
    `import { wrapServerComponentRenderer }`) for what are actually
    default exports, and corrected the registerServerComponent client
    call to use the string-name signature instead of the server-side
    object form. Pre-existing bug, but worth fixing now that the same
    module also publishes new named exports (`useCurrentRSCRoute`,
    `RSCRouteHandle`) — readers shouldn't be left guessing which is
    which.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eview nits

Addresses code-review feedback on the imperative refetch PR:

  - Add test 9 to imperativeRefetch.client.test.tsx: a deterministic
    last-write-wins concurrency test. Two refetches are issued back-to-
    back without resolving either, then the SECOND refetch's promise is
    resolved BEFORE the first's. Asserts the UI shows the second
    payload, then resolves the first promise and asserts the UI does
    NOT regress to the first payload. This proves the cache-write +
    transition ordering survives out-of-order resolution; previously
    only single refetch and "rapid double-click changed text" were
    covered.

  - inside-client-components.md: clarify that
    `useRSC().refetchComponent()` invoked from inside an error-boundary
    fallback only refreshes the cache. The route is unmounted while the
    fallback is showing, so the boundary still has to be reset (e.g. via
    `resetErrorBoundary()`) for the route to re-mount and pick up the
    new payload. The new "ref" / `useCurrentRSCRoute()` APIs do not have
    this constraint.

  - RefetchStressPage scenario 8 + matching e2e: the page used to
    initialize the displayed ref-state to "set" so the e2e's first
    assertion was a tautology against seeded UI text rather than a real
    ref observation. Initialize to "unchecked" instead and have the e2e
    click "Check ref.current" before each assertion.

No source-code change in `RSCProvider.tsx` or `RSCRoute.tsx` — the
implementation already had the right behavior, the gap was test
coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ution, e2e baseURL)

Three real failures + one infra flake observed on PR #3215:

  1. markdown-link-check: CHANGELOG entry linked to placeholder PR 3231
     which 404s. Fix: use the real PR number 3215.

  2. build (22) → JS unit tests for react-on-rails-pro: the
     `moduleNameMapper` introduced for the non-rsc bucket (to dedupe
     React across the dual install) ALSO ran for the rsc bucket. A
     directory-path redirect bypasses Jest's customExportConditions
     resolution, so `*.rsc.test.tsx` ended up loading `react/index.js`
     instead of `react.react-server.js` and all four suites died with
     'The "react" package in this environment is not configured
     correctly'. Fix: branch the mapper on `NODE_CONDITIONS` and, under
     react-server, redirect to the explicit `*.react-server.js` files
     so dedupe still happens but Jest no longer needs to consult
     package exports. 7/7 rsc tests pass; 44/44 non-rsc tests still
     pass.

  3. dummy-app-node-renderer-e2e-tests: the new Playwright spec
     hardcoded http://127.0.0.1:3002 (my local Rails port). CI runs
     Rails on 3000. Fix: use a relative URL so Playwright prepends its
     configured baseURL (matches CI). The accompanying comment about
     BASE_URL/PLAYWRIGHT_BASE_URL env-var overrides was also wrong;
     the project's playwright.config.ts doesn't read either, so the
     comment now correctly points users at the config file for
     non-default ports.

  Reviewed in two rounds with codex (gpt-5.4, high reasoning); first
  round rejected an empty `{}` mapper for the rsc branch as fragile
  against future hook-using rsc tests; this commit's targeted file-
  path mapper was approved on round 2. CodeQL's 'Requires
  authentication' result-upload error is an infra/permissions issue
  and is left for CI re-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drive-by formatting-only fix. The file was added to main by PR #3207
("Add /stress-test Claude Code command for adversarial QA") with
prettier-violating markdown table alignment and italic syntax. This has
left main's `lint-js-and-ruby` workflow red since 2026-04-30 and is
blocking my PR's `build` check via the merged-with-main CI gate.

The diff is purely formatter output (`prettier --write`):
  - markdown table column padding
  - `*(empty)*` -> `_(empty)_` italic-syntax normalization

No semantic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@AbanoubGhadban AbanoubGhadban force-pushed the worktree-issue-3106-rsc-refetch-api branch from bc5b97b to 51b152a Compare April 30, 2026 16:58
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

placeholder - will edit

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

Test body with no special chars

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 30, 2026

Code Review: feat(pro) — Imperative Refetch API for RSCRoute

Overview

This PR adds two clean ergonomic APIs to let callers imperatively refresh an RSCRoute without knowing its componentName or componentProps:

  • ref handle (RSCRouteHandle.refetch()) — for parents/siblings holding a ref
  • useCurrentRSCRoute() hook — for client components rendered inside the server component own subtree

The design is principled: latestPropsRef prevents stale-closure bugs, the internal startTransition avoids Suspense fallback flashes, and multi-instance fan-out works correctly because all RSCRoute instances sharing the same cache key pick up the version bump. The unit-test suite is excellent — eight targeted scenarios including a deterministic out-of-order concurrency test.

Issues Worth Addressing

  1. Unbounded cache growth (no eviction policy)

fetchRSCPromisesRef.current accumulates an entry for every unique componentName + JSON.stringify(componentProps) pair seen across the lifetime of the provider and never removes any entry. For an SPA that renders server components with many different prop values (e.g., per-user cards, per-post pages), this accumulates stale promises indefinitely — a latent memory leak that grows proportionally to distinct prop combinations seen, regardless of whether those components are still mounted.

At minimum, a @remarks in the JSDoc acknowledging the trade-off and recommending short-lived / low-cardinality props would set correct expectations. Ideally a simple LRU bound or WeakRef-based eviction would prevent growth entirely.

  1. Whole-tree fan-out on every refetch

versions is listed in the useMemo deps of contextValue specifically so that any change to versions recreates the context object and forces every useRSC() consumer to re-render. This is correct for propagating the new payload, but the side-effect is that a refetch of any single RSCRoute re-renders every RSCRoute in the same provider tree, not just the ones matching the updated cache key.

For the typical router use case (one RSCRoute visible at a time) this is negligible. But on pages that render many independent RSCRoutes (e.g., the scenario-7 stress page with 5 instances), a refresh of one route causes 4 unnecessary sibling re-renders, each calling createRSCPayloadKey / JSON.stringify for their props. At larger scales (dashboards, feed pages) this could cause measurable jank. A targeted fix is a larger refactor; for now, documenting the known limitation and tracking as a follow-up is sufficient.

  1. refetch() return value resolves before DOM commit

RSCRouteHandle.refetch() returns the raw fetch promise from refetchComponent, which resolves when the HTTP response is decoded, NOT when React transitions have committed the new content to the DOM. The PR description notes the caveat clearly, but the JSDoc on RSCRouteHandle does not mention it, and the type Promise implies the resolved value is meaningful — it rarely is for callers who just want to wait for the UI to stabilize. A one-line note in the type JSDoc would prevent confusion.

Minor Observations

  • versions in useMemo deps without being used in the value — the eslint-disable comment explains the intent, but this is a recognised anti-pattern. An alternative that avoids the disable: include versions in the context value itself ({ getComponent, refetchComponent, _v: versions }) without exposing _v in the public type. Object identity still changes, consumers still re-render, no lint suppression needed.

  • createRSCPayloadKey using JSON.stringify — pre-existing, but the new refetchComponent path makes it more load-bearing. Non-serialisable props (functions, undefined, circular objects) silently produce incorrect or unstable cache keys. A one-line guard or a docs note in RSCRouteProps would help.

  • RSCRouteHandle type discoverability — users need to reach into react-on-rails-pro/RSCRoute for the type import. Worth verifying the package exports map surfaces it so it is discoverable without knowing the internal path.

Test Coverage

The unit test suite is thorough — the deterministic concurrency test (out-of-order resolution) is particularly good. The Playwright e2e suite validates all 8 stress scenarios end-to-end.

One gap: there is no test verifying that ref.current is null when RSCRoute is inside an error boundary currently showing its fallback. Given the documented caveats about the error-recovery flow, a unit test confirming this boundary condition would strengthen confidence.

Summary

The implementation is correct and the test coverage is solid. The two items to prioritise before merge are: (1) unbounded cache growth — at minimum a JSDoc note, ideally a size cap; and (2) a JSDoc note on the refetch() promise semantics (resolves on fetch completion, not DOM commit). The fan-out re-render concern is real but acceptable for now as long as it is tracked as a follow-up.

}) => {
const fetchRSCPromises: Record<string, Promise<ReactNode>> = {};
return ({ children }: { children: ReactNode }) => {
const fetchRSCPromisesRef = useRef<Record<string, Promise<ReactNode>>>({});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unbounded cache growth — no eviction policy

fetchRSCPromisesRef.current accumulates entries for every unique componentName + JSON.stringify(componentProps) pair seen during the provider's lifetime and never removes any of them. Once a promise is written here (whether from the initial fetch or a subsequent refetch), it lives forever — including for component+prop combinations that haven't been rendered in hours.

For the router use case (a small, finite set of routes), this is harmless. But for callers who pass high-cardinality props (per-user IDs, per-post slugs, paginated offsets), the map grows indefinitely. The promises themselves keep the resolved RSC payloads (entire React trees) in memory.

Suggested mitigations (in order of preference):

  1. LRU eviction — cap the map at N entries (e.g. 50 or 100) and evict the oldest on overflow.
  2. WeakRef + FinalizationRegistry — store WeakRef<Promise<ReactNode>> and drop the entry when the promise is GC'd.
  3. JSDoc @remarks — at minimum, document the trade-off and recommend low-cardinality props.

If a full fix is out of scope for this PR, please open a follow-up issue so it is tracked.

Comment on lines +103 to +106
const contextValue = useMemo(
() => ({ getComponent, refetchComponent }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[getComponent, refetchComponent, versions],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Whole-tree fan-out on every refetch

Including versions in useMemo deps causes the contextValue object identity to change on every refetch, which forces all useRSC() consumers in the tree to re-render — not just the <RSCRoute> whose key changed. On a page with N independent RSCRoutes, refetching one causes N−1 unnecessary re-renders, each calling getComponentcreateRSCPayloadKeyJSON.stringify(componentProps).

This is also why the eslint-disable is needed: the deps contain versions, which is never referenced inside the memo body. That is a recognised anti-pattern (side-effectful deps). An alternative that avoids both the lint suppression and the implicit coupling:

// Instead of a hidden dep, expose versions as a private field:
const contextValue = useMemo(
  () => ({ getComponent, refetchComponent, _v: versions }),
  [getComponent, refetchComponent, versions],
);

…then keep _v out of the public RSCContextType. Object identity still changes on refetch, consumers still re-render, but the dep is actually used and no lint disable is needed.

The fan-out re-render is acceptable for typical router use (one visible RSCRoute at a time), but worth a follow-up to introduce per-key subscriptions via useSyncExternalStore for dashboard / feed-style pages.

Comment on lines +102 to +103
export type RSCRouteHandle = {
refetch: () => Promise<ReactNode>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

refetch() promise resolves on fetch completion, not on DOM commit

The returned Promise<ReactNode> resolves when getServerComponent resolves (i.e., the HTTP payload is decoded), not when React has committed the new tree to the DOM. The DOM update happens asynchronously inside a startTransition in RSCProvider.refetchComponent, so code like:

await ref.current.refetch();
// DOM may still show the old content here — the transition is still pending

…will not reliably observe the new content immediately after await.

The PR description documents this, but the JSDoc on this type is the canonical API reference. Suggest adding a @remarks:

export type RSCRouteHandle = {
  /**
   * Re-fetches the server component using the RSCRoute's current name and props.
   *
   * @returns A promise that resolves with the new RSC payload when the **fetch**
   * completes. This is NOT a signal that React has committed the updated tree —
   * that happens asynchronously via `startTransition`. Most callers can ignore
   * the return value and let `<RSCRoute>` update on its own.
   */
  refetch: () => Promise<ReactNode>;
};

…sc-refetch-api

* origin/main:
  fix(pro-dummy): make manual node-renderer validation reliable (#3200)
  [codex] Add Markdown Prettier CI check (#3242)

# Conflicts:
#	.claude/commands/stress-test.md
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 4, 2026

Code Review — PR #3215: Imperative refetch API for ``

Summary

The implementation is solid and well-tested. The core design decisions are sound:

  • Moving the promise cache to useRef (non-reactive) while keeping only a versions counter in useState is the right approach for triggering re-renders without making the cache itself reactive.
  • The latestPropsRef pattern correctly avoids stale-closure bugs in captured refetch handles.
  • Wrapping refetchComponent's state update in startTransition preserves the old tree during streaming — no Suspense-fallback flash.
  • The test suite is thorough, especially the deferred-promise concurrency test (test 8).

Issues to address before merge

1. versions map grows without bound

packages/react-on-rails-pro/src/RSCProvider.tsx line 69 — versions is a Record<string, number> held in useState. Every distinct cache key ever refetched adds a permanent entry. Unlike fetchRSCPromisesRef, which at least reuses the same promise on repeated getComponent calls, versions only grows. On long-lived apps that render many distinct (componentName, componentProps) combinations this is a slow memory leak.

The map doesn't need to hold the full history — only the current count per key matters. Consider deleting the entry when the count reaches an arbitrary modulo (e.g., wrap at Number.MAX_SAFE_INTEGER) or using a single monotonic counter plus a Map with explicit eviction. At minimum, document the growth behaviour in a comment.

2. useMemo dep-as-trigger pattern needs a clearer comment

packages/react-on-rails-pro/src/RSCProvider.tsx lines 100–106 — listing versions in the useMemo dep array while it doesn't appear in the callback body is an intentional "re-render bus" trick, not a mistake. The existing comment explains it, but the eslint-disable still looks alarming to a future reviewer. Consider renaming versions to something that signals the intent (e.g., refetchGeneration or cacheVersion) so the relationship to the trigger pattern is clearer without reading the commit history.

3. refetch() promise resolves before the DOM updates

docs/pro/react-server-components/inside-client-components.md line 63 (new section) says:

ref.current.refetch() returns a Promise<ReactNode> that resolves with the new tree.

The promise actually resolves when the network fetch completes (when getServerComponent resolves), not when React has committed the new payload to the DOM. Callers who await ref.current.refetch() and then read the DOM will observe the old content unless they wrap the DOM assertion in waitFor. This is a subtle API footgun — the documentation should add a clarifying note, e.g.:

The promise resolves when the server payload arrives, not when React has committed it to the DOM. If you need to act after the UI has updated, use a post-render callback (e.g. useEffect) rather than await refetch().

4. Hardcoded React internal filenames in jest.config.js

packages/react-on-rails-pro/jest.config.js lines 46–48 — the react-server branch maps ^react$ directly to react.react-server.js. This works today but ties the test config to a specific React internal file name that has changed before (React 18 → 19 transition). A build breakage here would be silent and confusing.

The comment notes this is stable "across React 19.x", which is true but not forever. At minimum add a version-pin comment that reads: # Verify filenames after upgrading React beyond 19.x. Even better, file a follow-up issue to remove the Pro package's local react/react-dom devDependencies entirely (mentioned in the PR description under "Out of scope"), which makes the whole mapper unnecessary.

Nits / low-priority

  • packages/react-on-rails-pro/src/RSCRoute.tsx line 173: CurrentRSCRouteContext.Provider wraps the error boundary, so a client component in the error boundary's fallback can technically call useCurrentRSCRoute() and get a valid handle. The documentation says this API "can only be invoked while the route is… not in an error state," which is slightly inaccurate. Clarify, or move the context provider inside the error boundary if the intent is to restrict access.

  • react_on_rails_pro/spec/dummy/client/app/components/InlineRefreshButton.tsx line 21: .finally(() => setPending(false)) can fire after the component has unmounted. React 18+ suppresses the "setState on unmounted component" warning, but it can still cause unexpected state transitions if the component re-mounts. Consider using an AbortController or a mounted-flag ref, even in demo code, to model the intended pattern for users who copy it.

Overall

The core feature (ref handle + useCurrentRSCRoute) is a clean, well-scoped API addition. Test coverage is exemplary — the concurrency/out-of-order-resolution test alone demonstrates careful thinking. The concerns above are bounded: items 1 and 3 are the most user-facing. I'd suggest addressing item 3 (docs) and adding a comment about the growth behaviour of versions before merging, and tracking item 4 in a follow-up issue.

// refetch (inside startTransition) is what makes <RSCRoute> consumers re-
// render with the new promise from the cache while React keeps the old
// tree visible until the new payload resolves.
const [versions, setVersions] = useState<Record<string, number>>({});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Memory growth: versions is never evicted. Every distinct cache key that has ever been refetched adds a permanent { [key]: number } entry. On a long-lived SPA that renders many different (componentName, componentProps) pairs this will grow without bound.

Consider either (a) pruning old keys when the RSCRoute unmounts, (b) capping growth by using % Number.MAX_SAFE_INTEGER, or at minimum add a comment documenting that this map is unbounded so a future maintainer can weigh the tradeoff.

const contextValue = useMemo(
() => ({ getComponent, refetchComponent }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[getComponent, refetchComponent, versions],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The eslint-disable is correct — versions is in deps intentionally as a render-trigger, not because the callback uses it. But this is a non-obvious pattern and will confuse reviewers (they'll wonder why the disable is here and whether it hides a bug).

One way to make the intent self-documenting without changing behaviour:

Suggested change
[getComponent, refetchComponent, versions],
// `versions` is not used inside the callback but is listed in deps so that
// the memoized context value gets a new identity on every refetch, which
// propagates the re-render to all useRSC() consumers.
// eslint-disable-next-line react-hooks/exhaustive-deps
[getComponent, refetchComponent, versions],

Or rename versionsrefetchGeneration so the "trigger" role is obvious at a glance.


const componentPromise = getComponent(componentName, componentProps);
return (
<CurrentRSCRouteContext.Provider value={handle}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Context accessibility during error states: CurrentRSCRouteContext.Provider wraps the error boundary, so a client component rendered in the error boundary's fallback can still call useCurrentRSCRoute() and get a valid handle. The documentation states this hook "can only be invoked while the route is… not in an error state," which is slightly inaccurate.

If the intent is to restrict access to non-error states, move the provider to inside the error boundary (or even just inside PromiseWrapper). If the intent is to allow refetch-from-fallback (which could be useful!), update the documentation accordingly.

// steps that. The exact filenames used here are the targets
// listed in `react`'s own `package.json#exports` for the
// `react-server` condition (stable across React 19.x).
'^react$': '<rootDir>/../../node_modules/react/react.react-server.js',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This maps ^react$ to a hardcoded internal filename (react.react-server.js) that is specific to React 19's exports map. The comment says it's "stable across React 19.x", but these paths have changed between major versions before.

Suggest adding an inline TODO/version guard comment so the next React major upgrade doesn't silently break RSC tests:

Suggested change
'^react$': '<rootDir>/../../node_modules/react/react.react-server.js',
// Hardcoded to React 19 internal export targets. Re-verify after upgrading React past 19.x.
'^react$': '<rootDir>/../../node_modules/react/react.react-server.js',

Better long-term fix: remove the Pro package's local react/react-dom devDependencies (tracked in the PR description as out-of-scope follow-up), which makes this entire branch unnecessary.

.catch((err: unknown) => {
console.error('InlineRefreshButton refetch failed', err);
})
.finally(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

.finally(() => setPending(false)) can fire after the component has unmounted (e.g. user navigates away mid-fetch). React 18+ suppresses the old "setState on unmounted component" warning, but this can still cause unexpected state transitions if the component remounts quickly.

Since this is demo/example code that users are likely to copy, consider modeling the idiomatic safe pattern:

Suggested change
.finally(() => {
.finally(() => {
// Guard is optional in React 18+ (no setState-on-unmount warning),
// but protects against races if the component remounts before the fetch settles.
setPending(false);

Or use an AbortController to cancel the in-flight request and skip the state update entirely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add imperative refetch API to RSCRoute: ref handle and useCurrentRSCRoute hook

2 participants