Skip to content

[Pro] Keep RSC payload retries inside one cached promise - #4564

Merged
justin808 merged 5 commits into
mainfrom
ihabadham/fix/stable-rsc-promise-retry
Jul 11, 2026
Merged

[Pro] Keep RSC payload retries inside one cached promise#4564
justin808 merged 5 commits into
mainfrom
ihabadham/fix/stable-rsc-promise-retry

Conversation

@ihabadham

@ihabadham ihabadham commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Alternative implementation to #4562 for the client-side request amplifier reported in shakacode/react_on_rails_rsc#187.

This PR and #4562 solve the same client-side problem and are mutually exclusive. This implementation is intended to replace #4562’s render-driven retry architecture, not build on top of it.

Relationship to #4562

#4562 handles retries by deleting the Promise for attempt one and relying on React’s retry render to create attempt two:

attempt one rejects
→ delete its Promise
→ React rerenders
→ create a second Promise
→ separate per-key state reconnects both attempts

This PR keeps one Promise for the complete logical load:

one cached Promise
→ attempt one
→ one internal retry when appropriate
→ resolve or retain the final rejection

The correctness distinction is ownership: the retry budget belongs to the logical payload load, not to React renders. With one Promise, React scheduling cannot change the number of requests, and stale attempts, cache eviction, and explicit refetch do not need to synchronize with a separate attempt record.

This also changes the behavior of the retry itself. #4562’s retry re-enters the ordinary getComponent path, so malformed embedded Flight data may be read a second time without making an HTTP request. This implementation retries with enforceRefetch: true, bypassing the failed embedded value and requesting a fresh payload.

Controlled browser comparison:

Implementation

RSCProvider now caches one Promise for the complete logical browser load:

  1. Perform the initial payload request.
  2. Retry once internally when the failure may be transient.
  3. Resolve if either attempt succeeds.
  4. Otherwise retain the final rejected Promise briefly so React can surface it through the Error Boundary.
  5. Allow a later lookup to start a fresh bounded load after the retention window.

The automatic retry uses enforceRefetch: true, ensuring a failed embedded or prefetched payload is bypassed in favor of a fresh HTTP request.

Retry policy:

  • Retry once: network failures, malformed payloads, HTTP 408/429, and 5xx.
  • Do not automatically retry: ordinary 4xx responses and AbortError.
  • Explicit refetch replaces a retained failure immediately.
  • Official server rendering explicitly disables the browser retry, including when server dependencies shim window.

Final failures remain pinned during the five-second retention window so ordinary LRU pressure cannot erase the request limit. This means a high-cardinality outage may temporarily exceed the normal 50-entry cache limit; those entries are removed and unpinned when retention ends.

Why one Promise?

The retry budget belongs to the logical payload load, not to React renders.

React always receives the same cached Promise regardless of how often it rerenders. React scheduling therefore cannot create additional attempts, and explicit refetches or stale operations cannot corrupt the current load’s ownership.

This differs from a render-driven retry, where attempt one is deleted and a later React render creates attempt two, requiring separate state to reconnect both Promises.

Browser verification

Scenario Result
Vulnerable RC with persistent malformed payload 3,424 requests in 6 seconds
Fixed package with persistent malformed payload Exactly 2 requests through 12 seconds; final error surfaced
First payload fails, retry succeeds Exactly 2 requests; Blue Page rendered
Malformed embedded payload, healthy endpoint Exactly 1 HTTP fallback; Blue Page rendered
SSR with server-side window shim Exactly 1 producer call

Validation

  • Full React on Rails Pro package suite: 600 tests passed
  • TypeScript type-check
  • ESLint
  • Prettier
  • Pro license-header validation
  • Bundle-size limits
  • Production-package browser reproduction
  • Stale retry/refetch and last-good restoration regression coverage

CI labels

Labels: ready-for-hosted-ci, benchmark — this changes Pro RSC cache and retry behavior under concurrency and cache pressure, so it needs optimized hosted confirmation plus the Pro benchmark route.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented repeated/unbounded browser fetches when server component payloads fail.
    • Added bounded automatic retry for transient failures, while preserving terminal failures and non-retryable cancellations.
    • Improved reuse during failure retention windows so repeated lookups don’t constantly restart requests.
    • Enhanced HTTP error reporting for non-OK responses and improved recovery from malformed or invalid preloaded/deferred payloads.
  • New Features
    • Introduced a configurable option to enable/disable rejected-payload retries (browser defaults on; server rendering defaults off).

Merge qualification

  • Release-mode gate: development from release tracker Release gate: react_on_rails 17.0.0 #3823; target main is beta phase. The standard beta gate is satisfied.
  • Current head: 5d895d25388a5cf55ee903a65c2138c991142750.
  • CI: pr-ci-readiness is READY with the required gate in use; optimized hosted CI, Pro package/integration jobs, bundle-size checks, CodeQL, and all four benchmark suites completed successfully for this head. Selector skips are explained by script/ci-changes-detector origin/main.
  • Review threads: 8 total, 0 unresolved. The strict merge ledger reports complete_allowed: true with changelog_present and no UNKNOWN fields.
  • Review coverage: Claude review and CodeRabbit produced current-head artifacts, satisfying the two-system coverage floor. Greptile only produced evidence for stale head b2eb62ee4967de17ad56f7dfe65eff569b7732e5; it is degraded for the final head and is not cited as a merge gate.
  • Independent QA: 137 focused affected tests and 453 broader Pro tests passed, plus type-check, lint, Prettier, diff checks, and all 847 Pro license headers.
  • Proof replay: the identical permanent-failure/same-key probe failed on merge base f45df4d6c082caf0e2e064256bbcd63af623e88d with 3 producer calls instead of 2, then passed on this head with exactly 2 calls, enforceRefetch: true on attempt two, and later lookups reusing the original rejected Promise.

Confidence note:

  • Validated: full Pro package suite (602 tests); independent focused/broader QA (590 tests); type-check; repository lint; Prettier; Pro license headers; git diff --check; deterministic base-red/head-green replay; hosted CI and benchmarks.
  • Evidence: current-head GitHub checks and reviews on this PR; strict script/pr-merge-ledger 4564 --changelog-classification changelog_present --strict; QA replay at the merge-base and current head above.
  • UNKNOWN: no additional real-browser wall-clock replay was run during coordinator closeout; the deterministic cache/timer replay directly proves the amplification mechanism and bounded fix, and the PR records prior production-browser verification.
  • Residual risk: terminal failures may temporarily exceed the soft 50-entry cache cap during the bounded five-second retention window; this is intentional, documented, and covered by cache-pressure tests.

Codex Decision Log

  • Non-blocking: Retain non-retryable terminal failures, including AbortError, for the bounded retention window.
    • Decision: Keep the retained rejection so render-driven lookups cannot reopen the request budget; explicit refetch still replaces it immediately.
    • Why: Focused tests and the red/green replay prove the hard two-attempt ceiling while preserving explicit recovery.
    • Review later: Optional production-wrapper SSR integration coverage may be added separately; no current defect was demonstrated.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

RSC payload loading now caches one logical-load Promise, retries eligible browser failures once, retains terminal failures temporarily, and distinguishes HTTP and abort errors. Client integrations enable retries, server rendering disables them, and tests cover retry, retention, cache, and refetch behavior.

Changes

RSC payload retry flow

Layer / File(s) Summary
Retry policy and error contract
packages/react-on-rails-pro/src/RSCProvider.tsx, packages/react-on-rails-pro/src/getReactServerComponent.client.ts, packages/react-on-rails-pro/src/registerDefaultRSCProvider.client.tsx, packages/react-on-rails-pro/src/wrapServerComponentRenderer/*
Adds configurable rejected-payload retries, classifies HTTP-like and abort errors, preserves HTTP status metadata, and enables retries for client providers while disabling them for server rendering.
Provider retry and retention lifecycle
packages/react-on-rails-pro/src/RSCProvider.tsx, packages/react-on-rails-pro/src/RSCProviderCache.ts
Replaces rejected-promise eviction with cached logical-load retries, terminal-failure retention, coordinated cache pinning, and deferred cleanup.
Retry behavior validation
packages/react-on-rails-pro/tests/RSCProviderRetry.client.test.tsx
Covers transient recovery, terminal retention, failure classification, prefetch failures, explicit-refetch precedence, LRU pinning, recovery, and disabled retry policy.
Existing flow regression coverage
packages/react-on-rails-pro/tests/boundedCacheProvider.client.test.tsx, packages/react-on-rails-pro/tests/deferredRouteSsr.test.tsx, packages/react-on-rails-pro/tests/getReactServerComponent.client.test.ts, packages/react-on-rails-pro/tests/imperativeRefetch.client.test.tsx, CHANGELOG.md
Updates cache, deferred SSR, malformed-payload, and imperative-refetch tests for forced retries and revised timing, and documents the fix.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RSCProvider
  participant getServerComponent
  participant RSCProviderCache
  RSCProvider->>RSCProviderCache: read cached logical-load Promise
  RSCProvider->>getServerComponent: request RSC payload
  getServerComponent-->>RSCProvider: return payload or classified error
  RSCProvider->>getServerComponent: force one retry for eligible failure
  getServerComponent-->>RSCProvider: return retry result
  RSCProvider->>RSCProviderCache: retain or release cache pin
Loading

Possibly related issues

Possibly related PRs

Suggested labels: enhancement, review-needed

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: keeping RSC payload retries within one cached promise.
✨ 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 ihabadham/fix/stable-rsc-promise-retry

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

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

@ihabadham
ihabadham marked this pull request as ready for review July 11, 2026 00:03
@ihabadham

Copy link
Copy Markdown
Collaborator Author

+ci-status

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 63.51 KB (0%)
react-on-rails/client bundled (gzip) (time) 63.51 KB (0%)
react-on-rails/client bundled (brotli) 54.46 KB (0%)
react-on-rails/client bundled (brotli) (time) 54.46 KB (0%)
react-on-rails-pro/client bundled (gzip) 64.87 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 64.87 KB (0%)
react-on-rails-pro/client bundled (brotli) 55.81 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 55.81 KB (0%)
registerServerComponent/client bundled (gzip) 135.37 KB (+0.15% 🔺)
registerServerComponent/client bundled (gzip) (time) 135.37 KB (+0.15% 🔺)
registerServerComponent/client bundled (brotli) 81.66 KB (+0.21% 🔺)
registerServerComponent/client bundled (brotli) (time) 81.66 KB (+0.21% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 127.84 KB (+0.15% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 127.84 KB (+0.15% 🔺)
wrapServerComponentRenderer/client bundled (brotli) 74.96 KB (+0.25% 🔺)
wrapServerComponentRenderer/client bundled (brotli) (time) 74.96 KB (+0.25% 🔺)

@github-actions

Copy link
Copy Markdown
Contributor

CI Status

Head SHA: b2eb62ee4967
Changed files: 12
Docs-only heuristic (matches ci-changes-detector metadata paths): no
ready-for-hosted-ci label: absent
force-full-hosted-ci label: absent
Current hosted-CI waiver: not present for this SHA

Only the required gate is active unless hosted CI is requested.

@ihabadham ihabadham added ready-for-hosted-ci Run optimized hosted GitHub CI for this PR benchmark labels Jul 11, 2026
@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR changes browser RSC payload loading to keep retries inside one cached promise. The main changes are:

  • One internal retry for retryable payload failures.
  • Forced HTTP refetch after failed embedded or prefetched payloads.
  • Short retention of final rejected promises to cap repeated requests.
  • Explicit client and server retry policy wiring.
  • Tests for retry, refetch, cache retention, SSR, and embedded payload fallback.

Confidence Score: 5/5

This looks safe to merge after a small retry-classification cleanup.

  • The main retry and cache-retention paths are guarded by promise identity checks.
  • Client and server wrappers now pass explicit retry policy values.
  • The remaining issue is limited to producers that wrap HTTP status in a plain object cause.

packages/react-on-rails-pro/src/RSCProvider.tsx

Important Files Changed

Filename Overview
packages/react-on-rails-pro/src/RSCProvider.tsx Adds the one-promise retry flow, retry classification, terminal failure retention, and retry policy option.
packages/react-on-rails-pro/src/getReactServerComponent.client.ts Adds status-bearing HTTP payload errors so retry policy can distinguish retryable and non-retryable HTTP responses.
packages/react-on-rails-pro/src/RSCProviderCache.ts Documents the soft cache cap while pinned failures are retained.
packages/react-on-rails-pro/src/registerDefaultRSCProvider.client.tsx Explicitly enables retry for the default browser provider.
packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx Explicitly enables retry for the client wrapper.
packages/react-on-rails-pro/src/wrapServerComponentRenderer/server.tsx Explicitly disables retry for the server wrapper.
packages/react-on-rails-pro/tests/RSCProviderRetry.client.test.tsx Adds focused tests for retry policy, retained failures, refetch races, cache pressure, and server opt-out.
packages/react-on-rails-pro/tests/getReactServerComponent.client.test.ts Adds coverage for HTTP status errors and embedded payload fallback.
packages/react-on-rails-pro/tests/boundedCacheProvider.client.test.tsx Updates bounded-cache tests for retry and retained rejection behavior.
packages/react-on-rails-pro/tests/deferredRouteSsr.test.tsx Updates deferred route expectations for forced retry calls.
packages/react-on-rails-pro/tests/imperativeRefetch.client.test.tsx Updates imperative refetch tests for same-promise retry semantics.
CHANGELOG.md Documents the Pro RSC request amplification fix.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
  participant React
  participant Provider as RSCProvider
  participant Cache
  participant Producer

  React->>Provider: getComponent(component, props)
  Provider->>Cache: read payload key
  alt cached promise exists
    Cache-->>Provider: cached promise
    Provider-->>React: same promise
  else cache miss
    Provider->>Cache: setPinned(logical promise)
    Provider->>Producer: initial payload request
    alt initial succeeds
      Producer-->>Provider: payload
      Provider->>Cache: mark successful and unpin
      Provider-->>React: resolve payload
    else retryable browser failure
      Producer-->>Provider: rejection
      Provider->>Producer: "retry with enforceRefetch=true"
      alt retry succeeds
        Producer-->>Provider: fresh payload
        Provider->>Cache: mark successful and unpin
        Provider-->>React: resolve payload
      else retry fails
        Producer-->>Provider: final rejection
        Provider->>Cache: retain pinned rejection briefly
        Provider-->>React: reject final error
        Provider->>Cache: delete retained failure after window
      end
    else non-retryable failure or retry disabled
      Producer-->>Provider: rejection
      Provider-->>React: reject error
      Provider->>Cache: cleanup according to policy
    end
  end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
  participant React
  participant Provider as RSCProvider
  participant Cache
  participant Producer

  React->>Provider: getComponent(component, props)
  Provider->>Cache: read payload key
  alt cached promise exists
    Cache-->>Provider: cached promise
    Provider-->>React: same promise
  else cache miss
    Provider->>Cache: setPinned(logical promise)
    Provider->>Producer: initial payload request
    alt initial succeeds
      Producer-->>Provider: payload
      Provider->>Cache: mark successful and unpin
      Provider-->>React: resolve payload
    else retryable browser failure
      Producer-->>Provider: rejection
      Provider->>Producer: "retry with enforceRefetch=true"
      alt retry succeeds
        Producer-->>Provider: fresh payload
        Provider->>Cache: mark successful and unpin
        Provider-->>React: resolve payload
      else retry fails
        Producer-->>Provider: final rejection
        Provider->>Cache: retain pinned rejection briefly
        Provider-->>React: reject final error
        Provider->>Cache: delete retained failure after window
      end
    else non-retryable failure or retry disabled
      Producer-->>Provider: rejection
      Provider-->>React: reject error
      Provider->>Cache: cleanup according to policy
    end
  end
Loading

Reviews (1): Last reviewed commit: "[Pro] Attribute RSC retry changelog entr..." | Re-trigger Greptile

Comment thread packages/react-on-rails-pro/src/RSCProvider.tsx Outdated
Comment thread packages/react-on-rails-pro/src/RSCProvider.tsx
Comment thread packages/react-on-rails-pro/tests/RSCProviderRetry.client.test.tsx Outdated
@claude

claude Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review: Keep RSC payload retries inside one cached promise

Summary

This replaces the render-driven retry from #4562 with a single cached Promise per logical RSC payload load: one bounded internal retry (with enforceRefetch: true to bypass a bad embedded/prefetched payload), then the terminal rejection is pinned in the cache for a 5s retention window so React can surface it through the Error Boundary without the key immediately becoming re-fetchable and re-amplifying requests. This is a meaningfully better design than #4562 — retry ownership belongs to the logical load, not to React's render count, which sidesteps the reconnection/synchronization problems of a two-promise approach.

Strengths

  • isRetryableRSCPayloadError is careful: bounded cause-chain walk (MAX_ERROR_CAUSE_DEPTH), duck-typed AbortError check (works across realms), fails closed (catch { return false }) instead of risking a retry loop on unexpected input.
  • BoundedLRU's deletePreservingPins/unpinWithoutEvict pairing is reused cleanly for the new terminal-retention path, matching the existing restoreLastSuccessfulPromise pattern rather than inventing new primitives.
  • Good identity guards throughout (fetchRSCPromises.get(key, false) === promise) so a stale automatic retry can never clobber a newer explicit refetchComponent() call — verified by the "does not let terminal cleanup delete a newer explicit refetch" and "keeps an explicit refetch when the stale automatic retry resolves/rejects" tests.
  • SSR is explicitly opted out (retryRejectedPayloads: false in wrapServerComponentRenderer/server.tsx), avoiding doubling server round-trips and avoiding the retention window mattering in a single-render context.
  • Test coverage is thorough: transient recovery, terminal retention + cooldown, per-status-code classification table, prefetch adoption, over-cap protection during an outage, and LRU resumption after recovery. The boundedCacheProvider/deferredRouteSsr/imperativeRefetch test updates look like faithful call-count updates for the new retry step rather than loosened assertions.
  • The trade-off of temporarily exceeding the 50-entry cache cap during a high-cardinality outage is explicitly documented in both the PR description and the RSCProviderCache.ts doc comments — good, since it's a real (if bounded and short-lived) memory/behavior change.

Findings (posted inline)

  1. MediumAbortError rejections get the same 5s retention/pin as genuine failures, even though the retry-policy doc explicitly excludes AbortError from automatic retry. The exclusion only affects whether a retry is attempted, not whether the terminal rejection is retained; a cancelled load blocks a fresh getComponent for the same key for up to 5s. Not reachable through the shipped producers today (no AbortSignal is threaded into getComponent's fetch), but is reachable for any custom getServerComponent producer, and is a behavior regression vs. the old evictPromiseIfRejected (~0ms eviction for any rejection type, abort included).
  2. Low/nitRSC_PAYLOAD_FAILURE_RETENTION_MS isn't exported from RSCProvider.tsx, so the new test file re-declares the same 5_000 literal as TERMINAL_FAILURE_RETENTION_MS. Risk of silent drift if the retention window value changes later.

Other notes (not blocking)

  • Every terminal failure (not just retried-and-failed ones) now goes through the 5s retention window — e.g. a plain 404 is retained just as long as a 5xx. That looks intentional (bounds the request budget for any terminal outcome, not just retryable ones) and is consistent with the "Explicit refetch replaces a retained failure immediately" policy, but is worth confirming is the intended UX (a persistent 404 now blocks re-render-triggered refetch attempts for 5s where it previously didn't).
  • No security concerns — no new user-controlled data flows into error messages/DOM, RSCPayloadHttpError construction only echoes the already-redacted sourceDescription/status that existed before this PR.

@claude

claude Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Summary

This PR replaces the render-driven retry from #4562 with a single-cached-promise retry model in RSCProvider: one logical payload load now performs at most one automatic retry (with enforceRefetch: true to bypass a bad embedded/prefetched payload), and a terminal failure is retained (pinned) for 5s so LRU pressure can't reopen the request budget before React observes the rejection through the Error Boundary. The retry classification (isRetryableRSCPayloadError) treats 5xx/408/429/network/malformed payloads as retryable and 4xx/AbortError as terminal.

Overall this is a solid, well-reasoned fix — the request-amplification bug it targets (thousands of requests/sec from a render-driven retry loop) is real and the "one Promise owns the retry budget" design is the right shape. Test coverage is extensive (retry classification matrix, stale-refetch races, LRU-capacity protection, embedded-payload bypass).

A couple of points worth double-checking before merge (left as inline comments):

  1. Companion-state cleanup gap on terminal-failure retention expiry — the new deletePreservingPins/unpinWithoutEvict cleanup path in getComponent's .finally() bypasses BoundedLRU's onEvict callback, so refetchVersionsRef and the versions/successfulVersions React state are never cleared for a key that permanently fails after having been explicitly refetched — unlike refetchComponent's equivalent absent-key branch, which explicitly calls scheduleAbsentKeyVersionCleanup. Given this code exists specifically to survive high-cardinality outages, it's worth confirming this asymmetry is intentional/bounded.

  2. Mounted-pin invariantdeletePreservingPins removes the map entry regardless of the pins refcount, so if a <RSCRoute> is still mounted and holding its own "mounted" pin via retainComponent on a key that ends up in terminalFailureRetained, the retention-expiry cleanup will still forcibly evict that entry out from under the mounted pin, seemingly contradicting BoundedLRU's own documented invariant Add linting and CI scripts #3 ("Mounted <RSCRoute> entries retain their payload keys..."). Worth a sanity check that this is deliberate.

Neither of these undermines the core fix (bounded request count), but they're worth a second look given how central the cache-bookkeeping invariants are to this file's correctness story.

Comment thread packages/react-on-rails-pro/src/RSCProvider.tsx
Comment thread packages/react-on-rails-pro/src/RSCProvider.tsx
Comment thread packages/react-on-rails-pro/src/RSCProvider.tsx
Comment thread packages/react-on-rails-pro/tests/deferredRouteSsr.test.tsx
@claude

claude Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review summary

Overview: this PR replaces the render-driven retry from #4562 with a single cached Promise per logical RSC payload load — RSCProvider now performs at most one internal retry (with enforceRefetch: true) inside the same promise, then pins the terminal rejection for a 5s retention window so LRU pressure and repeated Suspense re-renders can't reopen the request budget. This directly fixes the unbounded-request amplification in react_on_rails_rsc#187.

Code quality

  • The implementation is well-reasoned and heavily commented at the points that matter (pin lifecycle, retention window, race with explicit refetchComponent). The BoundedLRU pin ref-counting (deletePreservingPins + unpinWithoutEvict) correctly avoids wiping a mounted route's separate retain pin when the retry-owned pin is released — an improvement over the old deleteWithoutEvict(key) call (no preservePins) that a prior version of this code used.
  • Retry classification (isRetryableRSCPayloadError) walks the .cause chain up to depth 5, correctly distinguishing AbortError/4xx (no retry) from network/5xx/408/429/malformed payloads (retry once). Left one inline note on a gap: the status check is gated behind current instanceof Error, so a rejection value that carries status but isn't an Error instance skips classification entirely and defaults to retryable.
  • server.tsx explicitly passes retryRejectedPayloads: false rather than relying on the typeof window default, so SSR (including environments that shim window) can't accidentally pick up the browser retry — matches the PR description.

Test coverage

  • Very thorough: new RSCProviderRetry.client.test.tsx covers retry-then-succeed, terminal retention + cooldown, per-status-code classification (400/404/408/429/503/AbortError/network), prefetch adoption, explicit-refetch races, and over-cap retention. Existing suites (boundedCacheProvider, deferredRouteSsr, imperativeRefetch, getReactServerComponent.client) were updated to match the new call-count/enforceRefetch semantics.
  • Left one inline nit: one test in deferredRouteSsr.test.tsx ('wraps synchronous provider load failures...') wasn't updated alongside its siblings and now silently exercises the new double-call retry behavior without asserting on it (its assertion is a 'some call matches' check, not a count check).

Risk / design tradeoffs (as documented by the author)

  • The 5s pinned-retention window intentionally allows the cache to exceed its normal 50-entry soft cap during a high-cardinality failure burst (each pinned entry is small — a rejected promise reference — and self-expires), which is a reasonable, explicitly documented tradeoff rather than an oversight.
  • No security concerns identified; this is cache/retry bookkeeping, not new attack surface. status propagation in getReactServerComponent.client.ts's buildRSCPayloadHttpError is consistent with the test expectations (cause.status).

Overall: solid, well-tested fix for a real correctness bug (request amplification). The two inline comments are minor/non-blocking.

@ihabadham

Copy link
Copy Markdown
Collaborator Author

Address-review summary

Scan scope: full PR history; no prior summary.

Mattered

  • Fixed plain-object HTTP status classification in 8e9f04a4a; plain { status: 404 } causes no retry.
  • Moved the retention duration into the existing shared cache-policy constants in 8e9f04a4a.
  • Fixed terminal-expiry companion bookkeeping cleanup in 5d895d253, with regression coverage for the failed-refetch/replacement race.
  • Validated mounted-pin continuity through terminal expiry and 55-key LRU churn.

Optional

  • Kept AbortError terminal retention intentionally: no automatic retry, explicit refetch remains immediate, and retention prevents render-driven budget reopening.
  • Kept the bounded unknown/deep-cause retry and declined a duplicate synchronous-call-count assertion after focused probes confirmed the two-attempt cap.

Skipped

  • CI/status/benchmark reports and positive review summaries were non-actionable.

Follow-up issue: None.

Next default scan starts after this comment. Say check all reviews to rescan the full PR.

Comment thread packages/react-on-rails-pro/tests/deferredRouteSsr.test.tsx
@claude

claude Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review: Keep RSC payload retries inside one cached promise (#4564)

Overview

Replaces #4562's render-driven retry (evict the rejected promise, rely on React re-rendering to create a second attempt) with a single cached Promise per logical load that performs one internal retry (enforceRefetch: true) before settling. Terminal rejections are pinned for RSC_PAYLOAD_FAILURE_RETENTION_MS (5s) so LRU pressure can't reopen the request budget before React observes the error. Also classifies retryable failures (5xx/408/429/network/malformed) vs. non-retryable (4xx, AbortError) via a new isRetryableRSCPayloadError, and adds RSCPayloadHttpError (with .status) so HTTP failures carry a machine-readable status through the .cause chain.

Strengths

  • The core design fix is sound: owning the retry budget by the promise/load instead of by render count removes the amplification bug (React dnd can't find installed react #187) cleanly, and avoids the two-Promise reconciliation complexity [Pro] Bound RSC payload retries so a failing payload surfaces instead of looping #4562 needed.
  • isRetryableRSCPayloadError's cause-chain walk is defensive (try/catch, depth-capped, guards against non-object/null current) and its classification matches the documented policy in all the cases I traced (status-bearing errors at any cause depth, AbortError anywhere in the chain, default-retry for unclassified errors).
  • The BoundedLRU identity guards (fetchRSCPromises.get(key, false) !== promise) correctly prevent a stale automatic retry from clobbering a newer explicit refetchComponent result — traced through markSuccessfulPromise and the terminal-failure handler, both re-check cache identity before mutating shared state.
  • Very thorough test additions, including adversarial races (stale retry vs. explicit refetch, synchronous-throw replacement, over-cap failure retention, pin release after recovery).
  • retryRejectedPayloads is wired explicitly at both real call sites (true for browser, false for SSR) rather than relying solely on the typeof window fallback, which correctly addresses the "server shims window" case called out in the PR description.

Issues

  1. Test-fidelity gap (moderate) — see inline comment on deferredRouteSsr.test.tsx. None of that file's 13 createRSCProvider(...) calls set retryRejectedPayloads: false, so despite testing "deferred SSR" scenarios they all run with retry enabled (jsdom always defines window). Production SSR explicitly disables retry via wrapServerComponentRenderer/server.tsx, but that "no retry during real SSR" behavior only has unit-level coverage (one test in RSCProviderRetry.client.test.tsx), not coverage through the actual SSR→hydration flow this file exercises.

  2. Minor duplicationisRetryableRSCPayloadError (RSCProvider.tsx) re-implements the same AbortError duck-typing check (typeof x === 'object' && 'name' in x && x.name === 'AbortError') already present as isAbortError in getReactServerComponent.client.ts. Not a bug, but could drift if one is updated without the other; consider sharing one predicate.

  3. Note (by design, not a defect)RSC_PAYLOAD_FAILURE_RETENTION_MS pins terminal failures through the cap, so a burst of many distinct failing keys (e.g., high-cardinality componentProps from user-controlled query/search params) can temporarily grow the cache past RSC_PAYLOAD_CACHE_MAX_ENTRIES for up to 5s. This is documented in the code comments as an accepted trade-off and is client-side/self-bounded (5s window, browser-only), so it's low risk, but flagging since componentProps cardinality is often not fully controlled by the app.

Security

No injection/XSS concerns — this only changes retry/caching control flow and error classification, not payload rendering or DOM injection paths. The RSCPayloadHttpError carries status as a plain number; no new attacker-controlled data flows into logging or the DOM.

Performance

Retry is capped at exactly one extra request, matching the browser-verification table in the PR description (2 requests max for a persistently failing key, vs. thousands before the fix). Overhead per getComponent call (extra .catch/.then link, bounded cause-chain walk) is negligible.

No blocking issues found; the one moderate item (SSR retry-disabled coverage) is worth addressing before/after merge but doesn't affect shipped runtime behavior.

@github-actions

Copy link
Copy Markdown
Contributor

Pro Node Renderer Benchmark Summary

Benchmark RPS p50(ms) p90(ms) Status
Pro Node Renderer: simple_eval (non-RSC) 2029.25 ▼1.9% (2069.12) 4.66 ▲5.9% (4.4) 5.74 ▲1.5% (5.66) 200=60882
Pro Node Renderer: react_ssr (non-RSC) 1790.85 ▼2.6% (1837.87) 5.44 ▲8.9% (4.99) 6.53 ▲5.9% (6.17) 200=53732

▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · (n) = baseline

@ihabadham

Copy link
Copy Markdown
Collaborator Author

Address-review summary

Scan scope: activity after latest summary at 2026-07-11T00:30:27Z.

Mattered

  • No new production defect was identified.

Optional

  • Declined additional server-wrapper integration coverage: explicit server policy has unit coverage and the real renderToPipeableStream wrapper path was independently validated; the cited suite intentionally mixes server and client scenarios.

Skipped

  • Declined extracting the duplicated four-line AbortError predicate because it would add cross-module abstraction without changing behavior.
  • High-cardinality retention was already documented and stress-tested; benchmark reports were informational.

Follow-up issue: None.

Next default scan starts after this comment. Say check all reviews to rescan the full PR.

@github-actions

Copy link
Copy Markdown
Contributor

Core Benchmark Summary

Benchmark RPS p50(ms) p90(ms) Status
/: Core 3.61 ▲0.9% (3.58) 2237.18 ▼0.7% (2252.87) 2906.04 ▲2.8% (2826.28) 200=116
/client_side_hello_world: Core 715.5 ▲10.2% (649.38) 8.49 ▼10.6% (9.49) 21.07 ▲2.9% (20.48) 200=21616
/client_side_rescript_hello_world: Core 719.05 ▲10.2% (652.68) 8.41 ▼13.4% (9.71) 20.98 ▲6.7% (19.67) 200=21724
/client_side_hello_world_shared_store: Core 485.48 ▼21.3% (617.14) 12.12 ▲23.8% (9.79) 16.26 ▼18.5% (19.95) 200=14668
/client_side_hello_world_shared_store_controller: Core 715.99 ▲16.9% (612.29) 8.82 ▼12.0% (10.02) 22.38 ▲2.9% (21.75) 200=21627
/client_side_hello_world_shared_store_defer: Core 687.89 ▲12.8% (609.8) 9.04 ▼10.6% (10.11) 19.17 ▼9.0% (21.06) 200=20785
/server_side_hello_world_shared_store: Core 15.46 ▲3.5% (14.93) 415.15 ▼17.5% (503.45) 623.54 ▼7.8% (676.47) 200=475
/server_side_hello_world_shared_store_controller: Core 13.16 ▼13.0% (15.12) 447.87 ▼11.8% (507.97) 586.2 ▼14.5% (685.48) 200=404
/server_side_hello_world_shared_store_defer: Core 15.49 ▲2.8% (15.08) 529.94 ▲8.4% (488.77) 712.41 ▲5.8% (673.63) 200=474
/server_side_hello_world: Core 31.5 ▲4.4% (30.17) 264.03 ▲4.4% (252.87) 313.78 ▼0.9% (316.58) 200=959
/server_side_hello_world_hooks: Core 31.53 ▲5.2% (29.96) 266.82 ▲8.5% (245.85) 318.72 ▲1.8% (313.08) 200=959
/server_side_hello_world_props: Core 31.56 ▲8.0% (29.22) 266.48 ▲2.0% (261.22) 318.2 ▼0.8% (320.89) 200=957
/client_side_log_throw: Core 512.52 ▼21.1% (649.24) 9.68 ▼0.2% (9.7) 13.28 ▼31.5% (19.39) 200=15489
/server_side_log_throw: Core 30.71 ▲3.0% (29.83) 296.84 ▲12.6% (263.59) 328.13 ▲1.8% (322.18) 200=931
/server_side_log_throw_plain_js: Core 30.64 ▲2.7% (29.84) 268.79 ▲7.8% (249.34) 323.44 ▲3.3% (313.06) 200=934
/server_side_log_throw_raise: Core 30.95 ▲3.4% (29.92) 269.53 ▲9.8% (245.4) 321.31 ▲2.1% (314.6) 3xx=941
/server_side_log_throw_raise_invoker: Core 878.34 ▲14.1% (770.0) 6.95 ▼15.1% (8.18) 12.66 ▼20.8% (15.98) 200=26537
/server_side_hello_world_es5: Core 31.43 ▲4.5% (30.09) 190.69 ▼22.7% (246.72) 300.43 ▼4.7% (315.39) 200=962
/server_side_redux_app: Core 30.15 ▲3.4% (29.16) 274.86 ▲9.7% (250.55) 327.01 ▲2.6% (318.71) 200=920
/server_side_hello_world_with_options: Core 31.2 ▲2.2% (30.53) 106.1 ▼57.5% (249.65) 301.75 ▼3.8% (313.64) 200=956
/server_side_redux_app_cached: Core 766.48 ▲15.0% (666.28) 9.69 ▼6.4% (10.35) 15.93 ▼19.6% (19.81) 200=23156
/client_side_manual_render: Core 724.08 ▲11.2% (651.21) 8.78 ▼8.7% (9.62) 17.99 ▼6.4% (19.23) 200=21875
/render_js: Core 34.08 ▲6.9% (31.89) 266.77 ▲15.4% (231.18) 296.41 ▲1.9% (291.0) 200=1032
/react_router: Core 29.52 ▲4.9% (28.13) 220.34 ▼17.4% (266.89) 326.85 ▼4.1% (340.86) 200=900
/pure_component: Core 26.14 ▼13.8% (30.34) 217.98 ▼13.9% (253.04) 283.97 ▼9.6% (314.27) 200=798
/react_compiler_example: Core 31.36 ▲3.0% (30.44) 271.09 ▲9.0% (248.7) 321.51 ▲1.0% (318.35) 200=952
/css_modules_images_fonts_example: Core 31.44 ▲4.1% (30.2) 268.05 ▲3.6% (258.77) 313.9 ▼1.3% (317.94) 200=956
/turbolinks_cache_disabled: Core 726.6 ▲12.9% (643.71) 8.44 ▼13.1% (9.71) 20.49 ▲7.7% (19.02) 200=21952
/rendered_html: Core 31.98 ▲6.2% (30.11) 265.15 ▲5.8% (250.6) 313.44 ▲0.8% (311.02) 200=971
/xhr_refresh: Core 16.23 ▲4.6% (15.52) 515.34 ▲4.2% (494.75) 681.21 ▲3.3% (659.23) 200=497
/react_helmet: Core 30.81 ▲3.8% (29.67) 270.59 ▲7.3% (252.11) 323.49 ▲3.1% (313.86) 200=936
/broken_app: Core 26.28 ▼12.0% (29.85) 226.23 ▼13.4% (261.17) 302.1 ▼6.8% (324.29) 200=800
/image_example: Core 30.99 ▲5.3% (29.42) 269.52 ▲5.2% (256.09) 315.8 ▼1.8% (321.57) 200=945
/font_optimization_example: Core 806.63 ▲10.0% (733.56) 8.02 ▼7.6% (8.68) 17.8 ▼7.5% (19.24) 200=24367
/client_side_activity: Core 716.75 ▲9.1% (657.15) 7.25 ▼24.5% (9.6) 15.75 ▼19.6% (19.6) 200=21798
/server_side_activity: Core 30.9 ▲2.8% (30.06) 266.79 ▲8.1% (246.74) 319.53 ▲1.3% (315.44) 200=942
/turbo_frame_tag_hello_world: Core 842.01 ▲13.9% (739.45) 7.28 ▼13.1% (8.38) 12.53 ▼28.5% (17.53) 200=25440
/manual_render_test: Core 778.18 ▲17.2% (664.16) 7.92 ▼16.2% (9.45) 13.18 ▼31.2% (19.15) 200=23512
/root_error_callbacks: Core 31.74 ▲3.5% (30.68) 266.09 ▲11.7% (238.28) 320.16 ▲3.3% (309.89) 200=962
/hydration_scheduling: Core 10.79 ▲2.1% (10.56) 567.28 ▼17.9% (690.72) 878.64 ▼15.2% (1035.78) 200=336
/rails_form: Core 727.66 ▲8.5% (670.86) 8.75 ▼5.3% (9.24) 17.86 ▼6.7% (19.15) 200=21985
/typed_rails_action: Core 752.25 ▲16.7% (644.58) 8.5 ▼12.4% (9.7) 20.64 ▲7.8% (19.14) 200=22725

▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · (n) = baseline

@github-actions

Copy link
Copy Markdown
Contributor

Pro (shard 2/2) Benchmark Summary

Benchmark RPS p50(ms) p90(ms) Status
/empty: Pro 1144.86 ▼6.2% (1221.02) 6.96 ▲17.8% (5.91) 9.79 ▲7.7% (9.09) 200=34584
/ssr_shell_error: Pro 134.81 ▼11.8% (152.78) 54.96 ▲19.8% (45.87) 87.82 ▲18.9% (73.88) 200=4077
/ssr_sync_error: Pro 139.22 ▼12.1% (158.36) 53.51 ▲20.3% (44.5) 83.24 ▲18.5% (70.24) 200=4208
/rsc_component_error: Pro 106.78 ▼25.7% (143.62) 50.55 ▲14.3% (44.21) 86.7 ▲12.5% (77.03) 200=3879,3xx=20
/non_existing_stream_react_component: Pro 159.21 ▼8.3% (173.71) 48.57 ▲20.6% (40.28) 71.15 ▲10.0% (64.7) 200=4813
/server_side_redux_app_cached: Pro 325.42 ▼10.1% (361.99) 24.53 ▲20.8% (20.3) 37.05 ▲14.6% (32.34) 200=9832
/loadable: Pro 134.39 ▼7.4% (145.15) 47.98 ▲4.8% (45.8) 77.45 ▼0.1% (77.52) 200=4065
/apollo_graphql: Pro 105.56 ▼18.1% (128.93) 54.49 ▲9.0% (50.0) 155.34 ▲91.0% (81.35) 200=3186,3xx=6
/console_logs_in_async_server: Pro 2.57 ▼21.0% (3.25) 2123.81 ▼0.1% (2124.91) 2146.55 ▼0.9% (2165.55) 200=94
/stream_error_demo: Pro 3.32 ▼1.0% (3.35) 2012.31 0.0% (2011.55) 2032.46 ▼10.4% (2267.5) 200=108
/stream_async_components: Pro 110.73 ▼13.5% (128.07) 49.71 ▲8.8% (45.69) 88.2 ▲9.0% (80.94) 200=3869,3xx=6
/rsc_posts_page_over_http: Pro 121.93 ▼10.0% (135.42) 63.16 ▲25.3% (50.4) 94.12 ▲11.4% (84.46) 200=3690
/rsc_echo_props: Pro 54.17 ▼9.4% (59.8) 129.98 ▲13.4% (114.65) 178.73 ▲3.0% (173.49) 200=1641
/client_side_fouc_probe: Pro 317.35 ▼10.3% (353.84) 24.1 ▲22.2% (19.72) 38.71 ▲18.8% (32.58) 200=9591
/async_on_server_sync_on_client_client_render: Pro 258.34 ▼21.1% (327.48) 22.29 ▲8.6% (20.53) 68.12 ▲88.2% (36.2) 200=7809
/server_router_client_render: Pro 263.07 ▼22.8% (340.9) 21.75 ▲1.0% (21.54) 25.88 ▼23.4% (33.81) 200=8002
/unwrapped_rsc_route_stream_render: Pro 133.66 ▼20.4% (167.86) 44.36 ▲10.2% (40.27) 121.79 ▲74.7% (69.71) 200=4043
/async_render_function_returns_component: Pro 160.53 ▼10.6% (179.6) 47.5 ▲25.5% (37.86) 74.76 ▲15.8% (64.58) 200=4854
/native_metadata: Pro 150.21 ▼11.2% (169.23) 50.47 ▲29.6% (38.94) 74.69 ▲14.4% (65.3) 200=4519,3xx=22
/hybrid_metadata_streaming: Pro 153.23 ▼9.2% (168.74) 48.79 ▲17.8% (41.41) 78.07 ▲12.4% (69.46) 200=4630
/cache_demo: Pro 106.93 ▼13.8% (124.09) 62.55 ▲17.3% (53.31) 94.07 ▲8.0% (87.07) 200=3235
/client_side_hello_world: Pro 307.56 ▼9.5% (340.02) 20.42 ▲2.6% (19.91) 33.62 ▲1.5% (33.12) 200=9316
/client_side_hello_world_shared_store_controller: Pro 251.29 ▼21.8% (321.35) 22.98 ▲7.0% (21.47) 34.07 ▼4.3% (35.61) 200=7596
/server_side_hello_world_shared_store: Pro 113.59 ▼6.2% (121.04) 70.78 ▲15.5% (61.26) 104.14 ▲9.4% (95.19) 200=3435
/server_side_hello_world_shared_store_defer: Pro 114.55 ▼5.8% (121.67) 66.41 ▲15.8% (57.35) 91.61 ▼0.2% (91.76) 200=3485
/server_side_hello_world_hooks: Pro 164.82 ▼7.5% (178.22) 45.58 ▲22.8% (37.12) 67.99 ▲7.2% (63.45) 200=4967,3xx=19
/server_side_log_throw: Pro 151.63 ▼7.9% (164.66) 57.52 ▲40.0% (41.08) 77.85 ▲11.9% (69.59) 200=4585
/source_mapped_prerender_error_probe: Pro 187.56 ▼23.9% (246.38) 31.54 ▲15.5% (27.3) 68.81 ▲49.0% (46.19) 3xx=5670
/server_side_log_throw_raise_invoker: Pro 355.89 ▼11.6% (402.53) 17.56 ▼0.6% (17.66) 29.35 ▲1.0% (29.06) 200=10756
/server_side_redux_app: Pro 99.99 ▼36.6% (157.7) 51.89 ▲22.1% (42.5) 80.35 ▲9.0% (73.72) 200=4300,3xx=24
/server_side_redux_app_cached: Pro 264.19 ▼27.0% (361.99) 21.87 ▲7.7% (20.3) 34.06 ▲5.3% (32.34) 200=7984
/render_js: Pro 346.89 ▼6.0% (368.9) 22.7 ▲16.0% (19.56) 33.47 ▲5.1% (31.84) 200=10485
/pure_component: Pro 158.0 ▼9.3% (174.24) 49.17 ▲23.2% (39.91) 73.27 ▲8.0% (67.87) 200=4775
/turbolinks_cache_disabled: Pro 322.94 ▼7.1% (347.61) 23.25 ▲16.0% (20.05) 35.15 ▲9.9% (31.98) 200=9760
/xhr_refresh: Pro 101.81 ▼19.1% (125.89) 57.65 ▲4.4% (55.25) 135.6 ▲50.4% (90.19) 200=3079
/broken_app: Pro 154.95 ▼12.7% (177.41) 48.16 ▲20.0% (40.12) 76.16 ▲15.3% (66.07) 200=4686
/server_render_with_timeout: Pro 48.47 ▼18.5% (59.45) 120.56 ▲4.3% (115.57) 135.43 ▲2.2% (132.55) 200=1486

▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · (n) = baseline

@github-actions

Copy link
Copy Markdown
Contributor

Pro (shard 1/2) Benchmark Summary

Benchmark RPS p50(ms) p90(ms) Status
/: Pro 54.95 ▲8.2% (50.79) 145.42 ▲4.2% (139.6) 201.72 ▲3.9% (194.09) 200=1663
/error_scenarios_hub: Pro 357.02 ▲5.1% (339.72) 17.7 ▼16.5% (21.2) 29.11 ▼13.0% (33.48) 200=10791
/ssr_async_error: Pro 2.89 ▼12.6% (3.31) 2011.77 0.0% (2011.7) 2054.0 ▼8.7% (2250.65) 200=94
/ssr_async_prop_error: Pro 1.52 ▲26.3% (1.2) 5020.99 0.0% (5019.38) 5449.07 ▼40.9% (9215.63) 200=55
/non_existing_react_component: Pro 166.26 ▼0.9% (167.76) 45.13 ▲8.1% (41.73) 74.49 ▲8.8% (68.5) 200=5027
/non_existing_rsc_payload: Pro 174.23 ▼0.3% (174.79) 42.9 ▲6.9% (40.14) 69.2 ▲5.6% (65.51) 200=5266
/cached_react_helmet: Pro 371.68 ▲8.6% (342.13) 21.0 ▲0.7% (20.85) 31.5 ▼11.2% (35.46) 200=11232
/cached_redux_component: Pro 378.74 ▲6.5% (355.61) 20.84 ▲2.6% (20.32) 30.6 ▼5.1% (32.23) 200=11448
/lazy_apollo_graphql: Pro 135.27 ▼2.5% (138.67) 53.48 ▲1.9% (52.48) 78.42 ▼7.3% (84.6) 200=4091
/redis_receiver: Pro 93.89 ▲1.4% (92.62) 73.59 ▲3.6% (71.02) 165.15 ▲11.3% (148.4) 200=2841
/stream_shell_error_demo: Pro 143.86 ▲5.1% (136.9) 49.02 ▲9.6% (44.73) 73.8 ▼11.0% (82.93) 200=4326,3xx=26
/test_incremental_rendering: Pro 2.9 ▼10.9% (3.26) 2009.47 0.0% (2009.22) 2025.84 ▼1.7% (2060.21) 200=94
/rsc_posts_page_over_redis: Pro 85.41 ▼7.5% (92.36) 69.63 ▼5.2% (73.42) 149.96 ▲22.6% (122.34) 200=2583
/rsc_fouc_probe: Pro 152.04 ▲5.4% (144.22) 35.74 ▼21.0% (45.23) 65.0 ▼20.0% (81.22) 200=4626
/async_on_server_sync_on_client: Pro 2.71 ▲22.4% (2.21) 3010.62 0.0% (3010.63) 3400.7 ▼6.0% (3616.12) 200=91
/server_router: Pro 153.81 ▲2.5% (150.12) 47.98 ▲8.4% (44.26) 78.02 ▲0.6% (77.55) 200=4650
/unwrapped_rsc_route_client_render: Pro 366.5 ▲7.1% (342.13) 17.32 ▼11.0% (19.47) 28.41 ▼9.2% (31.28) 200=11075
/async_render_function_returns_string: Pro 164.55 ▲2.5% (160.56) 32.33 ▼19.0% (39.91) 61.25 ▼13.8% (71.1) 200=4993,3xx=17
/async_components_demo: Pro 6.64 ▼2.0% (6.77) 1025.72 0.0% (1025.97) 1050.17 ▼1.5% (1066.47) 200=212
/stream_native_metadata: Pro 167.19 ▲2.4% (163.25) 46.75 ▲7.2% (43.59) 74.03 ▲3.0% (71.9) 200=5053
/rsc_native_metadata: Pro 5.64 ▼17.2% (6.81) 1009.88 ▼0.1% (1010.78) 1026.69 ▼2.3% (1050.43) 200=184
/react_intl_rsc_demo: Pro 77.06 ▼5.0% (81.12) 86.22 ▲9.2% (78.93) 155.58 ▲16.3% (133.83) 200=2332
/client_side_hello_world_shared_store: Pro 278.65 ▼14.4% (325.55) 20.28 ▼9.4% (22.4) 24.08 ▼33.1% (36.02) 200=8477
/client_side_hello_world_shared_store_defer: Pro 336.18 ▲7.4% (313.16) 22.6 ▲1.9% (22.18) 36.05 ▲0.2% (35.97) 200=10156
/server_side_hello_world_shared_store_controller: Pro 120.01 ▲8.8% (110.32) 45.67 ▼25.6% (61.41) 81.97 ▼18.9% (101.07) 200=3654
/server_side_hello_world: Pro 136.38 ▼17.4% (165.12) 40.75 ▼1.2% (41.24) 57.35 ▼15.1% (67.58) 200=4152
/client_side_log_throw: Pro 362.31 ▲5.1% (344.74) 20.75 ▲0.4% (20.66) 31.36 ▼5.2% (33.09) 200=10949
/server_side_log_throw_plain_js: Pro 375.67 ▲4.6% (358.99) 20.85 ▲1.4% (20.57) 30.0 ▼4.9% (31.54) 200=11351
/server_side_log_throw_raise: Pro 232.9 ▲6.8% (218.14) 22.97 ▼22.3% (29.56) 44.61 ▼11.1% (50.16) 3xx=7089
/server_side_hello_world_es5: Pro 162.86 ▼3.4% (168.55) 44.34 ▲6.8% (41.52) 72.68 ▲3.4% (70.27) 200=4910,3xx=15
/server_side_hello_world_with_options: Pro 173.47 ▲0.4% (172.7) 44.87 ▲9.1% (41.13) 71.08 ▲6.5% (66.77) 200=5242
/client_side_manual_render: Pro 360.63 ▲4.7% (344.58) 20.8 ▲1.2% (20.56) 31.62 ▼2.9% (32.57) 200=10900
/react_router: Pro 166.92 ▼9.1% (183.73) 35.43 ▼5.3% (37.41) 71.39 ▲12.0% (63.74) 200=5049
/css_modules_images_fonts_example: Pro 140.83 ▼17.4% (170.45) 31.95 ▼21.9% (40.91) 51.25 ▼22.9% (66.44) 200=4287
/rendered_html: Pro 176.11 ▲0.5% (175.26) 31.11 ▼21.9% (39.81) 56.86 ▼13.8% (65.99) 200=5361
/react_helmet: Pro 112.68 ▲3.5% (108.91) 49.22 ▼26.5% (66.94) 68.92 ▼35.0% (105.95) 200=3417
/image_example: Pro 104.55 ▼34.1% (158.71) 43.32 ▲8.0% (40.11) 72.9 ▲0.2% (72.78) 200=5038,3xx=9
/posts_page: Pro 122.91 ▼3.9% (127.88) 60.0 ▲7.0% (56.07) 95.53 ▲5.3% (90.7) 200=3720

▲/▼ non-zero change vs baseline · 0.0% exact/near-zero match · 🔴 significant regression · 🟢 significant improvement (tracked measures) · (n) = baseline

@justin808

justin808 commented Jul 11, 2026

Copy link
Copy Markdown
Member

QA Evidence

  • QA lane: codex-pr4564-qa-orchid; detached worktrees for head and merge base; private batch heartbeat done.
  • Scope checked: Pro RSC retry/cache/SSR/refetch behavior for PR [Pro] Keep RSC payload retries inside one cached promise #4564, including one-retry promise identity, HTTP/Abort classification, five-second terminal retention, cache pressure, forced refetch, official server retry disablement, and stale/refetch races.
  • Tested at: head 5d895d25388a5cf55ee903a65c2138c991142750; merge base f45df4d6c082caf0e2e064256bbcd63af623e88d.
  • Automated checks: 137 focused tests; 453 broader Pro tests; full implementation-lane Pro suite of 602 tests; type-check; lint; Prettier; diff check; all 847 Pro license headers; hosted CI and benchmark suites. Deterministic identical replay failed on base with 3 producer calls instead of 2 and passed on head with exactly 2 calls, forced retry on attempt two, and subsequent same-key lookups reusing the original rejected Promise.
  • Manual checks: source/diff inspection confirmed client wrappers enable retry, the server wrapper disables it, retries force enforceRefetch: true, and terminal cleanup is promise-identity guarded. No additional live-browser smoke was required for this code-only package path.
  • Findings: none.
  • QA required: yes.
  • QA required rationale: user-visible Pro RSC runtime/cache/SSR behavior required independent evidence.
  • QA lane status: satisfied.
  • Release-blocking status: clear.
  • Process-gap disposition: not applicable.

justin808 added a commit that referenced this pull request Jul 12, 2026
## Why

Backport the RSC payload cache integrity fix from #4551 to the active
`17.0.0` release train. Without this fix, a subsequent cache hit can
return an empty RSC payload after the original response stream has been
consumed.

Fixes #4566.

## Source and provenance

- Source PR: #4551
- Source merge commit: `176dc8ae044ceb6f27a39fcf8482421b56a90816`
- Backport commit: `2bd78492a309c4d505788f575487e63b66d60b58`
- Base at backport time: `release/17.0.0` at
`3acfc4425225951725d748962eeaff88107602eb`
- Stable patch ID for the five code/test files:
`12f1074adc9887feb72a27ddc549987708928b5d`

The five Pro code/test changes are byte-for-byte equivalent to the
source change. The only cherry-pick conflict was `CHANGELOG.md`; it was
resolved by adding the #4551 entry once beneath the existing Unreleased
`Fixed` heading while leaving stamped `17.0.0.rc.8` content unchanged.
Unrelated mainline changes were excluded.

## Changes

- Preserve RSC stream cache content so repeated cache reads return the
complete payload.
- Exercise the cache implementation, helper behavior, and real-renderer
request path.
- Add the release-train changelog entry.

## Verification

- `react_on_rails_pro/spec/react_on_rails_pro/stream_cache_spec.rb`: 5
examples, 0 failures
- Dummy test bundle built successfully
- Helper and RSC payload request specs with the real renderer: 121
examples, 0 failures
- Full Pro RuboCop: 241 files, 0 offenses
- License headers: 844 files checked
- Prettier check: clean
- Repo-compatible Lychee 0.23.0: 0 link errors
- Pre-push hook: branch RuboCop and online Markdown links passed

## QA Evidence

Independent QA checked out the exact candidate commit
`2bd78492a309c4d505788f575487e63b66d60b58` and confirmed:

- Exact live base, head, source commit, and cherry-pick provenance
- Identical source/backport blobs for all five code/test files and
identical stable patch IDs
- Unchanged stamped changelog section
- All focused specs, real-renderer integration specs, RuboCop, license,
and detector checks passed
- No release-blocking findings; renderer process stopped and its port
was clear after testing

## Review evidence

- Independent self-review: clean
- Private Claude Opus 4.8 review: no blocking, discuss, or follow-up
findings
- Read-only simplify pass: no changes recommended
- High-risk adversarial review: no code/provenance blockers; one
release-order discussion was dispositioned below
- The configured local Codex review model could not run because the
installed CLI does not support `gpt-5.6-sol`; no code was changed as a
result

## Release sequencing decision

Draft PR #4588 stamped rc.9 before this backport existed. It remains
intentionally outside this backport and must not merge first. The chosen
sequence is:

1. Merge this exact-source #4551 backport.
2. Build and merge the serialized #4564 backport from the updated
release branch.
3. Rebase or regenerate #4588 afterward so both backport entries are
included in the rc.9 stamped section.

This disposes the adversarial review's changelog-order concern without
changing the code payload or touching #4588 in this PR.

## Release notes

- Review churn: one changelog conflict, resolved as described above; no
other deviations from the source PR
- Human decision point: final merge authorization remains with the
maintainer
- Merge authority remains with the maintainer

Co-authored-by: Abanoub Ghadban <abanoub@shakacode.com>
justin808 added a commit that referenced this pull request Jul 12, 2026
## Why

Backports merged source PR #4564 to the 17.0.0 release train as its own
source-atomic release PR. This prevents failed RSC payloads from letting
React renders repeatedly reopen the browser request budget.

Source:

- PR: #4564
- merged commit: `9d470c475e3670e2bfa45b7b15e336a2e838e5db`
- backport commit: `01199896eab5d8ef7b3629b1dc0883089bdd8437`
- dependency already on the release branch: #4589 / `c16b4bdee`

## Backport method

- branched from the current `release/17.0.0` tip after #4589 landed
- used `git cherry-pick -x`
- retained the source PR's changelog entry under `Unreleased -> Fixed`
- the normalized zero-context patch for all 11 Pro runtime/test files
has the same SHA-256 on source and backport:
`670bb3906a8ee1e2e9c4ce6cb99aa7749e4033f1e7855af07266d84507cbf747`
- no other source PR is included

## Validation

- focused affected tests: 5 suites, 136 tests passed
- full React on Rails Pro package suite:
  - non-RSC: 421 tests passed
  - streaming: 134 tests passed
  - RSC: 23 tests passed
- TypeScript type-check
- ESLint and Prettier
- Lychee offline and pre-push online link checks
- branch-wide Ruby lint
- `git diff --check`
- all 845 in-scope Pro license headers
- high-risk adversarial review: no blocking code concern; request
ceiling, retry/cache/refetch races, SSR/browser gating, pin accounting,
release compatibility, and #4589 dependency were checked

## Review decision carried from the source PR

The adversarial pass surfaced the intentional server behavior: official
server rendering disables browser retries and reuses the same failed
Promise for a same-key lookup. That behavior was explicitly documented
and tested in #4564, then accepted when the source PR was merged; this
backport preserves it exactly.

## Remaining gates

- optimized hosted CI and benchmarks on this release-branch head
- current-head review agents and thread triage
- release-branch merge ledger

<!-- qa-evidence v1
required: yes
status: satisfied
head_sha: 0119989
tested_at: local release backport head; source PR base-red/head-green
replay
scope: Pro RSC retry cache refetch SSR behavior and release-base
compatibility
automated_checks: 136 focused tests; 578 full Pro package tests;
type-check; lint; formatting; link checks; license headers
manual_checks: normalized source/backport patch equivalence and
changelog conflict inspection
findings: no blocking code concern
release_blocking: clear pending hosted CI and current-head reviews
process_gap_disposition: handled by source-atomic backport workflow PRs
-->

Co-authored-by: Ihab Adham <71561048+ihabadham@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmark ready-for-hosted-ci Run optimized hosted GitHub CI for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants