Skip to content

[Pro] Backport RSC retries and license configuration to 17.0.0 - #4590

Closed
justin808 wants to merge 2 commits into
release/17.0.0from
jg-codex/backport-rsc-retries-license-17
Closed

[Pro] Backport RSC retries and license configuration to 17.0.0#4590
justin808 wants to merge 2 commits into
release/17.0.0from
jg-codex/backport-rsc-retries-license-17

Conversation

@justin808

Copy link
Copy Markdown
Member

Summary

Backports #4564 and #4552 to the 17.0 release line. The companion RSC cache-integrity backport is already merged as #4589.

Validation

  • Focused Pro retry/client tests: 136 passed.
  • Focused Node renderer configuration/license/startup tests: 122 passed.
  • Node renderer type check.
  • git diff --check.

Alternative implementation to #4562 for the client-side request
amplifier reported in
[shakacode/react_on_rails_rsc#187](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.

relying on React’s retry render to create attempt two:

```text
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:

```text
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:

- #4562 with malformed embedded payload: zero HTTP requests; the same
embedded failure is surfaced.
- This implementation: one HTTP fallback; the healthy payload renders
successfully.

`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.

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.

| 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 |

- 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

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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

* **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).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

- Release-mode gate: `development` from release tracker #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.

- **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.

(cherry picked from commit 9d470c4)
## Why

React on Rails Pro license validation only consumed
`REACT_ON_RAILS_PRO_LICENSE`, even though Rails applications commonly
keep secrets in encrypted credentials and the renderer password already
supports application configuration. That forced Rails deployments to
expose the license through the process environment and left the separate
Node renderer's configuration boundary unclear.

## What changed

- add `ReactOnRailsPro.configuration.license_token`, with explicit
nonblank Rails configuration taking precedence over
`REACT_ON_RAILS_PRO_LICENSE`
- add the Node renderer's `licenseToken` option with the same precedence
and blank-value fallback
- validate the configured token during both clustered-master and
single-process renderer startup
- mask license values in every sanitized Node renderer configuration
view
- preserve `REACT_ON_RAILS_PRO_LICENSE` as the backward-compatible
default and generator path
- document Rails credentials, standalone Node secret configuration,
process separation, CI behavior, security guidance, troubleshooting, and
migration from the removed key file across the canonical and package
docs

Rails configuration intentionally does not propagate to a separately
running Node service. Each process must receive the same token through
its own configuration or environment.

## Validation

- `pnpm run lint`
- `pnpm start format.listDifferent`
- `pnpm run type-check`
- `pnpm run build`
- `(cd react_on_rails_pro && BUNDLE_GEMFILE=../Gemfile bundle exec
rubocop --ignore-parent-exclusion)`
- `(cd react_on_rails && bundle exec rake rbs:validate)`
- `node script/generate-llms-full.mjs --check`
- `script/check-docs-sidebar`
- `script/check-pro-license-headers`
- `git diff --check`
- pre-commit hooks, including offline Markdown links
- pre-push hooks, including online Markdown links

Local test suites were intentionally left to CI under the repository's
React on Rails PR policy. An automated local review pass was stopped
when it attempted to start those prohibited suites; no local test result
is claimed.

## Review churn

- Pre-push review gate: manual end-to-end flow and secret-redaction
review; automated Codex review stopped for attempting prohibited local
tests
- Post-push review churn: one planned changelog-only commit completed
after obtaining the PR number
- Remaining gaps: optimized hosted CI has been requested for the Ruby
and Node test suites

## Demo coordination

No flagship demo change is needed: the existing environment-variable
setup remains supported and generated defaults are unchanged. This adds
optional secret-source configuration without changing the recommended
generated deployment path.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added `config.license_token` support for Rails apps (including
credentials/config) with precedence over `REACT_ON_RAILS_PRO_LICENSE`.
* Added standalone Node renderer `licenseToken` option with env fallback
and masked diagnostics.
* **Bug Fixes**
* Updated license validation and startup logging to respect
precedence/blank values and avoid token disclosure in sanitized output.
  * Improved license-status caching to be scoped per effective token.
* **Documentation**
* Refreshed Pro license setup, installation, upgrading, troubleshooting,
and CI/license-task guidance, including review-app token exposure
security.
* **Tests**
* Expanded test coverage for token resolution, masking, and startup
validation flows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

## Codex Decision Log

- **Non-blocking:** Should this PR target `release/17.0.0` rather than
`main`?
  - **Decision:** Keep the `main` target.
- **Why:** The 17.0.0 tracker is in development mode, the RC branch is
for stabilizing fixes only, and this is an optional configuration
capability with no demonstrated RC regression or release hard-gate need.
- **Review later:** Backport only if a maintainer identifies a concrete
17.0.0 stabilizing need.

## Review Coverage Note

- Current head SHA: `1325f12e7d921699f09b8c52270b4a964f814273`.
- Current-head review systems: `claude-review` and CodeRabbit completed
successfully.
- Degraded advisory coverage: visible Greptile and GitHub Codex review
artifacts predate the final code head, so they are treated as stale
advisory history rather than merge evidence.
- Local fallback review attempts: `codex review` could not start because
the installed CLI is too old for its configured model; the Claude CLI
report-only fallback is not authenticated. Independent implementation
review and required runtime QA were completed instead.

(cherry picked from commit 91aeba1)
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 49070989-6dfd-4e7f-8ecf-6bb486d889c4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg-codex/backport-rsc-retries-license-17

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.

@github-actions

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.54 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 64.54 KB (0%)
react-on-rails-pro/client bundled (brotli) 55.47 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 55.47 KB (-0.05% 🔽)
registerServerComponent/client bundled (gzip) 135.06 KB (+0.16% 🔺)
registerServerComponent/client bundled (gzip) (time) 135.06 KB (+0.16% 🔺)
registerServerComponent/client bundled (brotli) 81.44 KB (+0.24% 🔺)
registerServerComponent/client bundled (brotli) (time) 81.44 KB (+0.24% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 127.5 KB (+0.15% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 127.5 KB (+0.15% 🔺)
wrapServerComponentRenderer/client bundled (brotli) 74.61 KB (+0.15% 🔺)
wrapServerComponentRenderer/client bundled (brotli) (time) 74.61 KB (+0.15% 🔺)

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review: [Pro] Backport RSC retries and license configuration to 17.0.0

Overview: this backports #4564 (bounded browser retry for rejected RSC payload promises) and #4552 (configurable license_token for Rails config / Node renderer, with ENV fallback) onto release/17.0.0. The Ruby/TS source changes, docs, and tests are well-scoped and match the patterns already established in these two merged PRs.

🔴 CHANGELOG.md: backport appears to delete already-released entries and the 17.0.0.rc.8 version boundary

The CHANGELOG.md diff doesn't just add the two backported entries — it also removes three entries and the ### [17.0.0.rc.8] section header that are already present on release/17.0.0 (i.e. they exist in the PR's base/"old" side of the diff, but not in the PR head):

I confirmed none of that content survives in the resulting merged file — searching the post-merge CHANGELOG.md for rolling_deploy_previous_url, Production streamed RSC CSS, and RSC payload prerender cache no longer stores turns up no matches for these entries.

This looks like the backport branch was cut from an older point of release/17.0.0 (before rc.8 and those three fixes were merged into it) and never rebased onto the current branch tip, so the diff silently reverts that changelog history when merged. Since this PR doesn't touch the rolling-deploy or RSC-CSS-reveal source files, the code for those fixes is presumably unaffected — only their changelog record and the rc.8 version boundary are lost. Worth rebasing onto the current release/17.0.0 tip (or manually restoring the three entries + rc.8 header/link) before merging.

Everything else looks solid

  • RSCProvider.tsx / RSCProviderCache.ts: the single-cached-promise retry (fetchPayload.catch → retry once → .then/error handler that marks terminalFailureRetained) correctly reuses the existing setPinned/deletePreservingPins/unpinWithoutEvict primitives, and the retention-window finally-block logic is consistent with the pre-existing pin/evict contract described in the surrounding comments. isRetryableRSCPayloadError's cause-chain walk (bounded at MAX_ERROR_CAUSE_DEPTH = 5, guarded by try/catch, AbortError short-circuits to non-retryable, numeric status gates on 5xx/408/429) is a reasonable, defensive implementation.
  • retryRejectedPayloads wiring: default typeof window !== 'undefined' with explicit true/false overrides in the browser/server wrapper call sites is correct — server rendering (wrapServerComponentRenderer/server.tsx) explicitly opts out, matching the changelog claim that "official server rendering does not perform the browser retry."
  • License token plumbing (configuration.rb, license_validator.rb, configBuilder.ts, licenseValidator.ts): the "explicit nonblank value wins, blank falls back to ENV" precedence is implemented consistently across Ruby and Node, and the cache-key approach (ENV_LICENSE_CACHE_KEY sentinel vs. the configured token string) correctly invalidates cached license status when the configured token changes, while preserving legacy "process-lifetime cache until reset!" semantics for the ENV/default path. Diagnostics (sanitizedSettings, envValuesUsed) mask the token value, matching the PR's security claim.
  • Test coverage: new specs cover the precedence rules (configured > ENV, blank-config falls back, cache invalidation on reconfiguration) on both the Ruby and Node sides, plus retry behavior in RSCProviderRetry.client.test.tsx. Looks thorough for a backport.

Minor

  • docs/llms-full*.txt updates for the license-token feature read cleanly and don't show the same base-drift problem as CHANGELOG.md.

No functional bugs found in the ported source/tests themselves — the main actionable item is the CHANGELOG regression above.

@justin808

Copy link
Copy Markdown
Member Author

Closing in favor of the serialized release-backport flow: one merged source PR per release-targeted backport PR. #4564 is being backported independently from the latest release/17.0.0 tip; #4552 must be evaluated and, if it qualifies for the release train, backported in its own separately reviewed and reversible PR. The branch is intentionally retained for evidence.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants