Skip to content

Fix RSCProvider retry after rejected getComponent promise#4022

Draft
justin808 wants to merge 7 commits into
mainfrom
codex/3929-rscprovider-retry
Draft

Fix RSCProvider retry after rejected getComponent promise#4022
justin808 wants to merge 7 commits into
mainfrom
codex/3929-rscprovider-retry

Conversation

@justin808

@justin808 justin808 commented Jun 14, 2026

Copy link
Copy Markdown
Member

Summary

  • Fixes the Pro RSCProvider retry path after a rejected getComponent promise by evicting the rejected same-key cache entry on the next macrotask.
  • Preserves the identity guard so newer same-key refetch promises are not evicted by stale failures.
  • Adds focused client tests for retry, superseded same-key refetches, and timing comments documenting the Suspense/macrotask behavior.

Issue Disposition

Agent Merge Confidence

Mode: accelerated-rc
Current head SHA: a00d251
Score: 3/10
Auto-merge recommendation: no
Affected areas: Pro RSCProvider retry behavior, Pro RSC client tests
CI detector: script/ci-changes-detector origin/main -> Pro/RSC runtime coverage expected; labels full-ci, benchmark remain appropriate.
Validation run:

  • pnpm --dir packages/react-on-rails-pro exec jest tests/imperativeRefetch.client.test.tsx --runInBand -> passed, 25 tests.
  • pnpm --dir packages/react-on-rails-pro run type-check -> passed.
  • /Users/justin/.codex/worktrees/7e0e/react_on_rails/node_modules/.bin/prettier --check packages/react-on-rails-pro/src/RSCProvider.tsx -> passed for the latest comment-only change.
  • git diff --check and git diff --check origin/main...HEAD -> passed.
  • Pre-commit and pre-push hooks passed for a00d2510e1da2603e0c81c906d536bc59e94c9b7.
    Review/check gate:
  • GitHub checks: current head a00d2510e1da2603e0c81c906d536bc59e94c9b7 has one failed check and multiple jobs still queued/in progress. Bundle Size / check-bundle-size failed because static size limits were exceeded; the log includes unchanged OSS bundle entries (react-on-rails/client 63,845 > 63,392 gzip and 54,774 > 54,406 brotli), so this appears broader than the two Pro files touched by this PR, but it remains merge-blocking until fixed, rerun clean, or explicitly waived.
  • Prior failed job on superseded head de31fe2afa76ad966e9bced42a23ef9b03642b02: webpack-dummy-app-tests / build-dummy-app-test-bundles (4.0, 22, latest, webpack) failed before repo build/test code because hosted runner apt-get update received 403s from Microsoft package repositories while installing libyaml-dev.
  • Review threads: GraphQL unresolved count is 5. Two threads remain the Error-as-value retryability product/contract decision; three new current-head review threads call out documentation/comment follow-ups for non-recovering refetchComponent rejection caching, the delayed-eviction closure over promise, and timer FIFO assumptions. The non-blocking macrotask-window thread was addressed and resolved in a00d2510e.
  • Current-head reviewer verdicts: Claude review, CodeRabbit, and Cursor Bugbot are complete/success for a00d2510e1da2603e0c81c906d536bc59e94c9b7; full CI/benchmark completion is still pending.
    Feedback triage:
  • de31fe2af removed the Sinon-specific timerApi.clock fake-timer branch from the Jest-only test helper.
  • de31fe2af added a concise FIFO timer-ordering comment at the rejected getComponent eviction wait.
  • a00d2510e clarified that the cached macrotask-window entry is the chained rejected promise itself.
  • Error-as-value retryability is blocked as a product/contract decision: Pro RSCProvider should evict rejected getComponent promises so transient failures can retry #3929 covers rejected getComponent promises, createRscPayloadNode already turns serialized Error payloads into rejections for route-loader payloads, and changing fulfilled Error retention could expand the direct getServerComponent contract.
    Labels: full-ci, benchmark. This changes Pro/RSC retry behavior and should retain broad CI plus benchmarks.
    Known residual risk: High until the current-head bundle-size failure is resolved or waived, the five unresolved review threads are addressed or explicitly triaged, and the Error-as-value product decision is made; PR remains draft pending maintainer/assignee coordination for Pro RSCProvider should evict rejected getComponent promises so transient failures can retry #3929.
    Finalized by: pending current-head independent review/check completion.

Current Readiness

Draft, not merge-ready. Local validation is green for a00d2510e1da2603e0c81c906d536bc59e94c9b7, but current-head GitHub checks include a failed Bundle Size / check-bundle-size job, and five current review threads remain open. #3929 is assigned to ihabadham, so undraft/merge should wait for maintainer or assignee coordination.


Note

Medium Risk
Changes Pro RSC promise caching and retry timing around Suspense; behavior is well-tested but affects production RSC fetch recovery paths.

Overview
Fixes Pro RSCProvider so a failed getComponent fetch does not leave a permanently cached rejected promise for the same cache key.

getComponent now chains a rejection handler that still re-throws for Suspense, then deletes the in-flight cache entry on the next setTimeout(0) macrotask, but only when the entry is still the same promise—so a newer same-key refetchComponent promise is not cleared by a stale failure. Comments document the Suspense timing choice and unmount safety.

refetchComponent behavior is unchanged; a comment clarifies that non-recovering refetches keep the rejected promise in cache.

Client tests add an RSCProbe harness and cases for dedupe/success caching, retry after eviction, and superseded refetches, plus a real-timer helper aligned with the deferred eviction.

Reviewed by Cursor Bugbot for commit 704b7f9. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jun 14, 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: 11fd57d4-aeb1-43e6-a022-d80951d98135

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 codex/3929-rscprovider-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 and usage tips.

@claude

claude Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Code Review: Fix RSCProvider retry after rejected getComponent promise

Overall Assessment: LGTM with minor notes

The fix is correct, minimal, and well-tested. The two-argument .then(markPayloadIfSuccessful, evictPromiseIfRejected) pattern is the right approach — it avoids a .catch() chain (which would intercept errors thrown inside the success handler) and correctly evicts the cache entry only when the stored ref still points at the same promise.

What works well

  • Stale-rejection guard is tight. The fetchRSCPromisesRef.current[key] === promise check in evictPromiseIfRejected correctly handles the race in test 0c: a later refetchComponent overwrites the cache before the older promise rejects, so the eviction no-ops and the newer promise survives.
  • Variable-capture pattern is safe. promise is captured by reference inside the closures and is guaranteed to be assigned before either callback runs asynchronously. This mirrors the pre-existing markPayloadIfSuccessful pattern.
  • Test coverage is comprehensive. Tests 0a/0b/0c cover deduplication, retry-after-rejection, and the stale-rejection-vs-newer-refetch races — exactly the scenarios that can regress silently.

One design note to verify

evictPromiseIfRejected is only attached in the getComponent path. In refetchComponent, a rejected promise (when recoverOnError is false or unset) is left in fetchRSCPromisesRef.current[key] — and that cached rejected promise has no eviction handler, so a subsequent getComponent call returns the same stale rejection indefinitely.

In practice this seems safe today because:

  1. RSCRoute always passes recoverOnError=true in production (restoring the last successful promise on failure).
  2. Direct refetchComponent callers own the rejection path.

But it's a gotcha worth a comment in refetchComponent so future editors know the asymmetry is intentional.

Minor test nit

RSCProbe (defined inline in the test file) is a React.forwardRef component without a displayName. This is test-only so it doesn't affect production, but setting RSCProbe.displayName = 'RSCProbe' would make React DevTools output cleaner when debugging failing tests.

Comment thread packages/react-on-rails-pro/src/RSCProvider.tsx
Comment thread packages/react-on-rails-pro/tests/imperativeRefetch.client.test.tsx
@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.83 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.83 KB (0%)
react-on-rails/client bundled (brotli) 53.9 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.9 KB (0%)
react-on-rails-pro/client bundled (gzip) 64.27 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 64.27 KB (0%)
react-on-rails-pro/client bundled (brotli) 55.21 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 55.21 KB (0%)
registerServerComponent/client bundled (gzip) 74.45 KB (+0.03% 🔺)
registerServerComponent/client bundled (gzip) (time) 74.45 KB (+0.03% 🔺)
registerServerComponent/client bundled (brotli) 64.14 KB (+0.17% 🔺)
registerServerComponent/client bundled (brotli) (time) 64.14 KB (+0.17% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 67.25 KB (+0.04% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 67.25 KB (+0.04% 🔺)
wrapServerComponentRenderer/client bundled (brotli) 57.74 KB (+0.13% 🔺)
wrapServerComponentRenderer/client bundled (brotli) (time) 57.74 KB (+0.13% 🔺)

@claude

claude Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Code Review

The fix is correct and well-targeted. The setTimeout(0) boundary is the right tool here — it guarantees React's Suspense machinery has a full macrotask to observe the rejection before the cache entry is evicted, whereas queueMicrotask would race with React's own microtask queue and could evict too early. The identity guard (fetchRSCPromisesRef.current[key] === promise) cleanly prevents a stale eviction from nuking a concurrent refetchComponent promise, and test 0c validates that scenario explicitly.

Test coverage is thorough: 0a covers the dedup/success-caching path, 0b covers retry-after-rejection, and 0c covers the stale-eviction-vs-newer-refetch race. Nothing is obviously missing.

A few things worth discussing:

The timer is never canceled on unmount

If the provider unmounts in the ~0 ms window between a rejection and the scheduled eviction, the setTimeout callback still fires. It's safe because the closure captures the old fetchRSCPromisesRef, whose .current will no longer match any live cache — but this is a subtle correctness assumption that isn't documented. Consider a cleanup comment, or for stricter hygiene, gate the eviction on a mounted flag set in a useEffect cleanup.

waitForRejectedGetComponentEviction timing

The helper works by relying on FIFO macrotask ordering: the eviction's setTimeout(0) is always enqueued before the helper's setTimeout(0), so the helper resolves only after the eviction has already fired. This is correct in all standard JS engines, but the assumption isn't commented, making it fragile for future readers. See inline comment.

refetchComponent asymmetry

The intentional design difference (non-recovering refetchComponent keeps the rejected promise; getComponent evicts it) is now documented with a comment, which is good. However, this asymmetry is an observable API contract and would benefit from a JSDoc note on refetchComponent so callers know not to rely on retry semantics from the refetch path without recoverOnError.

Minor: test naming convention

Prefixing with 0a/0b/0c to insert before test 1. is unusual. If Jest's --runInBand order matters here, that's worth a brief comment; if not, sequential names would be more idiomatic.

No blocking issues. The core fix is solid.

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

claude Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Code Review

The fix is logically sound. The core insight is correct: when getComponent's underlying promise rejects, synchronously deleting the cache entry races with React's Suspense machinery trying to observe that same rejection. Delaying eviction by one macrotask (setTimeout(0)) gives React time to see the rejection before the key is cleared, fixing the infinite-retry loop from #3929.

The identity guard (fetchRSCPromisesRef.current[key] === promise) correctly prevents a stale-rejection eviction from clobbering a concurrent refetchComponent promise on the same key. Test 0c exercises this path cleanly.

Observations

1. Brief rejected-promise window (design gap in docs)
Between when the original promise rejects and when setTimeout(0) fires, any new caller to getComponent with the same key receives the already-rejected promise and immediately sees the error rather than starting a fresh fetch. This is almost certainly intentional — Suspense needs to observe the rejection — but the code does not document it. A one-line comment at the key in fetchRSCPromisesRef.current early-return would make this window explicit rather than surprising.

2. let promise! self-reference requires close reading
The TypeScript definite-assignment assertion on promise is safe — the rejection handler only fires asynchronously, so promise is always assigned first. But a future reader may not see why the ! is needed or realise there is a self-referential closure (evictPromiseIfRejected closes over promise before promise is assigned on the next line). A short inline comment clarifies the invariant.

3. FIFO macrotask assumption in waitForRejectedGetComponentEviction is real-timer only
The helper works because Node.js processes macrotasks FIFO and the suite uses real timers. If a future test scope calls jest.useFakeTimers(), the inner setTimeout(resolve, 0) will never fire without an explicit jest.runAllTimers(), silently breaking test 0b. A one-liner noting this constraint would help.

4. Test naming convention mismatch
The existing suite numbers tests from 1; the new tests use 0a/0b/0c. The intent (foundational coverage preceding the refetch tests) is legible, but it breaks the numbering convention. Consider renaming to keep a consistent style.

Summary

No bugs found. No security implications. Performance impact of setTimeout(0) is negligible. The new regression tests (dedup, retry-after-rejection, stale-rejection-with-concurrent-refetch) cover the fix well. Items 1 and 2 are worth addressing before merge; 3 and 4 are minor/cosmetic.

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

claude Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Adversarial PR Review — #4022 Fix RSCProvider retry after rejected getComponent promise

Head: `0274c35` | Base: `main` | State: OPEN / DRAFT / BLOCKED | Merged: no


Summary

The core fix is correct and well-targeted. The two-argument `.then(onFulfilled, evictPromiseIfRejected)` pattern is the right choice over `.catch()` (which would intercept errors thrown by the success handler), and the `setTimeout(0)` boundary over `queueMicrotask` is the right boundary to use — microtasks can race with React's internal queue and evict the cache entry before Suspense observes the rejection. The identity guard (`fetchRSCPromisesRef.current[key] === promise`) correctly handles the concurrent-refetch case (verified by test 0c). The new tests are targeted and cover the three behaviors that matter: deduplication, eviction-on-rejection, and non-eviction of superseded promises.


BLOCKING

1. PR is still a draft; no human maintainer has approved it; CI is incomplete.
The PR body explicitly marks this as draft pending "maintainer/assignee coordination and current-head CI/review finish." At review time, all workflow jobs were QUEUED or IN_PROGRESS and no human reviewer has given a formal approval (CodeRabbit's APPROVED is advisory per this repo's policy). Do not merge until CI passes, the PR is taken out of draft, and a maintainer approves.


DISCUSS

2. Missing CHANGELOG entry.
Every Pro RSCProvider change in the Unreleased section has a `[Pro]` entry. This PR fixes a bug in the `getComponent` retry path (issue #3929) which is user-visible (RSC components would fail to recover from transient errors). There is no CHANGELOG entry for this fix. One should be added before merge.

3. The unmount-safety comment is misleading (code is correct, comment is not).
See inline comment on `RSCProvider.tsx` lines 131–133. The eviction does happen from the unmounted instance's ref, but it's harmless. The current comment incorrectly states "no eviction happens," which could mislead future readers into making a wrong assumption about the safety invariant. Recommend clarifying the wording. (Filed inline below.)


FOLLOWUP (post-merge)

4. No Suspense-level integration test for the retry path.
Tests 0a–0c drive `getComponent` and `refetchComponent` directly via the `RSCProbe` handle, bypassing React's `use(promise)` / Suspense / error boundary stack. A future test that actually renders a `` component through an error boundary, triggers a transient failure, resets the boundary, and verifies the component successfully re-renders would give higher confidence that the end-to-end retry path works correctly under React's real Suspense cycle.

5. Fake-timer hazard for `waitForRejectedGetComponentEviction`.
The helper's comment warns about fake timers, but only inside a code comment that is easy to miss. See inline comment on the test file. Consider a `jest.fakeTimers` guard that throws a descriptive error if the helper is called in a fake-timer context, or at minimum move the warning to the function name or a JSDoc block.


NON_BLOCKING_DECISION

6. `setTimeout(0)` over `queueMicrotask`.
The choice is well-documented with a solid rationale. Accepted.

7. `no-dynamic-delete` ESLint suppression.
This is the correct suppression for a known-safe keyed delete on an internal ref cache. Accepted.

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

Copy link
Copy Markdown
Member Author

Review follow-up after ba856a731:

  • Corrected the unmount-safety comment: the old timeout may evict the unmounted instance ref, but a remounted provider owns a fresh ref so it cannot delete the new in-flight promise.
  • Added a runtime guard so waitForRejectedGetComponentEviction fails loudly under Jest fake timers instead of hanging or giving a misleading result.

Validation rerun:

  • pnpm --dir packages/react-on-rails-pro exec jest tests/imperativeRefetch.client.test.tsx --runInBand -> passed, 25 tests.
  • pnpm --dir packages/react-on-rails-pro run type-check -> passed.
  • pnpm exec prettier --check packages/react-on-rails-pro/src/RSCProvider.tsx packages/react-on-rails-pro/tests/imperativeRefetch.client.test.tsx -> passed.
  • git diff --check origin/main...HEAD && git diff --check -> passed.
  • Pre-commit and pre-push hooks passed.

Comment thread packages/react-on-rails-pro/tests/imperativeRefetch.client.test.tsx Outdated
return payload;
};
promise = getServerComponent({ componentName, componentProps }).then(markPayloadIfSuccessful);
const evictPromiseIfRejected = (error: unknown) => {

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.

Note the asymmetry between the two error paths handled here:

  • evictPromiseIfRejected (promise rejection) → cache entry is evicted after one macrotask, enabling retry
  • markPayloadIfSuccessful above for payload instanceof Error (error-as-value) → cache entry is retained permanently

If getServerComponent can fulfill with an Error object (the error-as-value pattern), those entries will block retries just like the old rejected-promise behavior — getComponent keeps returning the same fulfilled-with-Error promise until an explicit refetchComponent call. Is this intentional? If error-as-value responses are retryable, the same eviction logic would apply to the !(payload instanceof Error) branch too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Leaving this open as a product/contract decision rather than changing behavior in this safe-small review pass. Issue #3929 and its acceptance criteria cover rejected getComponent promises, and this PR is intentionally scoped to that rejected-promise retry path.

The fulfilled Error-as-value path is currently treated separately in RSCProvider for both getComponent and refetchComponent, while route-loader payloads from createRscPayloadNode already normalize serialized Error payloads into promise rejections. Making fulfilled Error values retryable could change the contract for direct getServerComponent implementations that intentionally fulfill with an Error object. Since #3929 is assigned to ihabadham, I am leaving this thread unresolved for assignee/maintainer direction before expanding scope.

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

claude Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Code Review

The core fix is correct and well-designed. The setTimeout(0) eviction with an identity guard cleanly solves the retry-blocking problem without breaking concurrent refetch semantics. The implementation commentary is thorough and accurate.

Strengths

  • Identity guard is airtight. fetchRSCPromisesRef.current[key] === promise (with promise captured by closure) correctly handles all three race cases: a refetchComponent that replaced the cache entry before the timeout fires, a delete that cleared it, and the normal single-inflight case.
  • setTimeout(0) vs queueMicrotask rationale is well-documented inline. The concern is real — microtasks interleave with React's own queue in ways that macrotasks don't.
  • Unmount safety comment is accurate: the old closure holds a ref to the old instance's object, and a fresh mount gets a fresh useRef({}) — the old timeout can't reach the new instance's cache.
  • Tests 0a/0b/0c cover the three cache-layer scenarios (dedup, eviction/retry, stale-rejection guard) and are readable.

Issues

usingJestFakeTimers().clock check is Sinon-specific (inline comment at line 168)
(setTimeout as unknown as { clock?: unknown }).clock is an internal Sinon.js convention; it can't be true in a Jest-only test suite. jest.isMockFunction(setTimeout) alone suffices.

Error-as-value path is not evicted (inline comment at line 122 in RSCProvider.tsx)
evictPromiseIfRejected handles promise rejections, but markPayloadIfSuccessful silently retains a cache entry when getServerComponent resolves with an Error object. If error-as-value responses are transient/retryable, they'll permanently block getComponent retries just like the old behavior. Confirm this asymmetry is intentional — or apply the same eviction to the instanceof Error branch.

Suspense timing gap in test coverage
The primary motivation for setTimeout(0) over queueMicrotask is that microtasks can race with React's Suspense machinery and evict the cache before Suspense can observe the rejection. None of the new tests exercise Suspense suspension during the rejection window — they use RSCProbe to call getComponent/refetchComponent imperatively, bypassing use() entirely. This means the timing guarantee is untested at the layer it was designed for. At minimum, a comment in the tests noting this coverage gap would be helpful; ideally a test that mounts a Suspense tree, triggers a rejection, and confirms error-boundary catches before the cache clears.

Test numbering (0a/0b/0c preceding test 1) is unconventional. Consider renumbering the block to 1a/1b/1c and shifting the existing numbered tests to 2, 3, … — or just using descriptive names without numbers entirely.

Minor nit

In test 0c, the assertion comment expect(getServerComponent).toHaveBeenCalledTimes(2) after the refetchComponent act relies on await Promise.resolve() being sufficient to drain refetchComponent's internal Promise.resolve().then(fn) chain. This is correct (one microtask tick is enough for a single .then), but a brief inline comment would save future readers from second-guessing it.


Overall: ready to merge after addressing the Sinon .clock nit and confirming the error-as-value eviction asymmetry. The Suspense-layer test gap is a nice-to-have, not a blocker.

@claude

claude Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Adversarial Review — PR #4022 (RSCProvider getComponent retry)

State: Draft · Head: de31fe2 · CI: queued/running · 1 unresolved thread · Not merge-ready.


BLOCKING

None found.


DISCUSS

1. payload instanceof Error — retained rejected-as-value promises are not retried (open thread)

The unresolved thread in the PR body is flagged as an intentional product decision, but it deserves an explicit maintainer answer before undraft:

When getServerComponent resolves with an Error instance (rather than rejecting), the promise chain takes the fulfillment path: markPayloadIfSuccessful returns the Error payload without calling markSuccessfulPromise, and evictPromiseIfRejected is never triggered. The cache entry for that key is kept permanently — subsequent calls to getComponent return the same promise that resolved to an Error, with no retry path.

This PR only fixes the rejection (throw/reject) path. If the contract allows getServerComponent to signal failure by returning an Error value rather than throwing, those failures are not retryable through getComponent after this PR either. The question is whether that is a deliberate contract boundary or a follow-up issue.

2. CI not complete for head SHA

All major check suites (Pro Package Tests, Pro Integration Tests, JS unit tests, Lint, Integration Tests, CodeQL, Generator tests) are queued or in-progress for de31fe2. Cannot assess the full gate. Wait for CI to settle before undrafting.


FOLLOWUP

3. Missing CHANGELOG entry

This is a user-visible Pro bug fix (previously a rejected getComponent promise left a permanently stale cache entry, silently blocking all retries for that component key). Per the changelog guidelines and the project's unified /CHANGELOG.md, this warrants a **[Pro]**-tagged entry under #### Fixed in ### [Unreleased] before marking the PR ready.


NON_BLOCKING_DECISION

4. setTimeout(0) macrotask window intentionally exposes duplicate callers to the same rejection

The existing comment documents this: during the window between rejection and eviction, any concurrent getComponent caller for the same key receives the same rejected promise. This is correct and the right behavior (prevents immediate retry loops, deduplicates the failure signal), but it means an error boundary that synchronously re-renders and calls getComponent in the same macrotask will see the same rejection rather than starting a fresh fetch. This matches the stated intent and is fine, but worth acknowledging in user-facing docs if the RSCProvider retry contract is ever published.

5. FIFO setTimeout(0) assumption in waitForRejectedGetComponentEviction

The helper relies on FIFO ordering of same-delay macrotasks. This holds in Node.js (V8 timer queue ordering) and is documented in the helper. The guard that throws when fake timers are active correctly prevents silent mis-ordering. The assumption is safe as written.


Correctness Assessment

The eviction mechanism is mechanically correct:

  • evictPromiseIfRejected closes over promise by reference; promise is assigned (to the chained .then() result) synchronously before the rejection handler can fire asynchronously — no undefined-capture risk.
  • The chained .then(markPayloadIfSuccessful, evictPromiseIfRejected) produces a rejected promise (because evictPromiseIfRejected re-throws), which is stored in the cache under key. The guard fetchRSCPromisesRef.current[key] === promise compares against exactly this object.
  • A refetchComponent call that overwrites the cache entry before the setTimeout fires makes the guard false — the stale rejection does not evict the newer promise (test 0c covers this).
  • Tests 0a / 0b / 0c are well-scoped and cover the three critical invariants (dedup on success, retry after rejection, no eviction of newer refetch).

No correctness blockers found in the implementation.

@@ -119,7 +119,31 @@ export const createRSCProvider = ({
}
return payload;

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.

DISCUSS: Error-as-value payloads are permanently cached with no retry path

When getServerComponent resolves (rather than rejects) with an Error instance, the fulfillment branch fires: markPayloadIfSuccessful skips markSuccessfulPromise (correct — it is not a successful payload) but returns the Error, so the .then() chain resolves successfully. evictPromiseIfRejected is never called. The cache entry keeps the resolved-Error promise indefinitely.

After this PR, getComponent for that key will always return the same resolved-Error promise — no retry, no eviction. This is the open product question noted in the PR body. It needs an explicit decision before undraft:

  • Option A (deliberate boundary): close the unresolved thread, document that getServerComponent must throw (not return) to get retry behavior, and note this in the RSCProvider contract.
  • Option B (follow-up fix): track a follow-up issue to also evict on resolved-Error payloads (likely needs the same setTimeout(0) treatment to let Suspense/consumers observe the error value before eviction).

Comment thread packages/react-on-rails-pro/src/RSCProvider.tsx
@justin808

Copy link
Copy Markdown
Member Author

CI process update after #4036 merged:

Please rebase or otherwise update this PR onto current main before relying on CI results. #4036 changed the hosted-CI commands, labels, local CI defaults, and required PR gate behavior.

Post-merge audit tracker: #4055

This PR is currently a draft; no immediate action is required until it is resumed, but it should still use the new process before it is considered merge-ready.

Legacy labels currently present here: full-ci. Please replace them during the rebase/update pass.

Recommended update path:

  1. Rebase/update onto current main (git fetch origin main && git rebase origin/main, then git push --force-with-lease if you own the branch; otherwise use the GitHub update/rebase path appropriate for the branch owner).
  2. Run bin/ci-local before pushing; it now auto-detects the PR base and runs optimized local CI by default. Use bin/ci-local --all only when broad local coverage is warranted.
  3. Make sure the PR body has a self-contained Why section; issue links are helpful context, but the PR description should stand on its own.
  4. Use +ci-status before requesting hosted CI.
  5. Use +ci-run-hosted for optimized hosted GitHub Actions after the final push.
  6. Use +ci-force-full only when intentionally bypassing optimized selection.

Vocabulary changes:

  • full-ci -> ready-for-hosted-ci only when optimized hosted CI should persist.
  • full-ci-no-benchmarks -> hosted-ci-no-benchmarks when suppressing benchmark labels.
  • force-full intent -> force-full-hosted-ci or +ci-force-full.
  • Legacy commands +ci-run-full, +ci-skip-full, /run-skipped-ci, /run-skipped-tests, and /stop-run-skipped-ci are removed.

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Code Review: Fix RSCProvider retry after rejected getComponent promise

Overview

The fix is sound. Caching rejected promises indefinitely was a real bug — any getComponent call after an initial failure would return the same stale rejection with no recovery path. The setTimeout(0) macrotask eviction approach is the correct solution given Suspense's scheduling model, and the identity guard correctly handles the superseded-refetch case.

Correctness ✅

  • Identity guard works correctly. evictPromiseIfRejected closes over the chained promise (the return value of .then()), which is exactly what gets stored in the cache. When a superseded refetch replaces the entry, the stale failure's eviction sees fetchRSCPromisesRef.current[key] !== promise and correctly bails out.
  • Unmount safety is correct. A remounted provider gets a fresh useRef object, so the stale setTimeout closure cannot interfere with the new instance's cache.
  • Tests cover the three critical cases: dedup (0a), post-rejection retry (0b), and stale-failure-cannot-evict-superseded-refetch (0c). This is exactly the right test surface for this behavior.

Issues

1. Asymmetry: refetchComponent rejections are NOT auto-evicted

getComponent now evicts rejected promises so the caller can retry. refetchComponent rejections are intentionally left in the cache (per the new comment: "Non-recovering refetches keep the rejected promise so the caller observes the failure"). The implication: after a failed refetchComponent, subsequent getComponent calls for the same key return the cached rejection — there is no passive recovery path without another explicit refetchComponent. This asymmetry is easy to miss for integrators. Consider adding a 0d test documenting this behavior explicitly, or expanding the inline comment to spell out the consequence.

2. promise variable closes over unassigned binding (valid JS, but subtle)

let promise! is declared, then evictPromiseIfRejected is defined as a closure capturing promise, then promise is assigned. This is valid because the closure runs asynchronously — by the time the rejection fires, promise is already assigned. However, the non-null assertion (!) silences TypeScript's concern without documenting the invariant. A brief note at the declaration site ("assigned synchronously before any rejection can fire") would help future readers.

3. FIFO timer-ordering assumption in waitForRejectedGetComponentEviction

The helper is clever and the FIFO guarantee holds in Node.js/V8. The fake-timer guard is a good safety net. The comment explains how it works but not why it is safe in this environment. Adding "FIFO is guaranteed by the event loop spec and holds in Node.js/V8/JSDOM" locks down the assumption for future test maintainers.

4. Test naming: 0a, 0b, 0c prefix

These were inserted before the existing 1., 2., 3.... series. The numeric prefix works but is non-standard and will require reshuffling if more tests are added between 0c and 1. A nested describe('getComponent initial-fetch caching', ...) block for the new trio would be cleaner and easier to extend.

Minor

  • The // eslint-disable-next-line @typescript-eslint/no-dynamic-delete at line 138 is correctly placed — same pattern already used in refetchComponent.
  • RSCProbe.displayName = 'RSCProbe' is good practice even in test code.
  • The new comment at line 195–196 ("Non-recovering refetches...") is welcome, but tying it to the asymmetry in point 1 above would help future readers.

Bundle size CI failure

Per the PR description, the failing check reports unchanged OSS bundle entries (react-on-rails/client), making this a pre-existing infra issue unrelated to the two Pro-package files touched here. Rerun or waive before undrafting.

Verdict

The implementation is correct and well-tested. The two main actionable items are: (1) a test or expanded comment documenting the refetchComponent rejection asymmetry, and (2) a comment on the let promise! declaration clarifying the async-assignment invariant. Happy to approve once those are addressed or acknowledged as out-of-scope.

@@ -168,6 +192,8 @@ export const createRSCProvider = ({
return payload;
},
(error: unknown) => {

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 comment correctly describes the intent, but consider adding the downstream implication for getComponent callers: after a non-recovering refetchComponent failure the rejected promise remains cached, so any subsequent getComponent call for the same key will return the cached rejection until another refetchComponent is issued. This is the asymmetry with getComponent's auto-eviction and could surprise integrators:

Suggested change
(error: unknown) => {
// Non-recovering refetches keep the rejected promise so the
// caller observes the failure; route refetches recover in production.
// Note: unlike getComponent (which auto-evicts after one macrotask),
// this rejection stays cached — subsequent getComponent calls for the
// same key will return this failure until the next refetchComponent.

}, 0);
throw error;
};
promise = getServerComponent({ componentName, componentProps }).then(

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 let promise! declaration a few lines above this assignment is captured by the evictPromiseIfRejected closure before it's assigned here. This is valid because the closure only executes asynchronously (on rejection), so promise is always fully assigned by the time it runs. The non-null assertion (!) silences TypeScript's concern but doesn't document the invariant — a short note at the let promise! declaration site ("assigned synchronously below; closure runs async so promise is always set by the time it executes") would help future readers avoid a potential confusion.

setTimeout(resolve, 0);
});
};

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 FIFO macrotask ordering assumption is sound (guaranteed by the event loop spec, holds in Node.js/V8/JSDOM), but it could silently pass in environments where timers are not truly FIFO. The existing fake-timer guard is a good safety net. Worth appending to the comment: "FIFO is guaranteed by the HTML event loop spec and holds in V8/JSDOM" so future maintainers know the exact invariant this relies on.

@justin808 justin808 force-pushed the codex/3929-rscprovider-retry branch from a00d251 to 704b7f9 Compare June 18, 2026 09:26
@justin808

Copy link
Copy Markdown
Member Author

Bundle-size blocker: resolved (stale-base artifact, not a real regression)

The previously failing Bundle Size / check-bundle-size on head a00d2510e was not a real bundle increase from this PR, so there is no size budget to justify or product decision to make.

Evidence. The failing run reported every one of the 8 tracked bundles growing ~700–1100 bytes, including the OSS react-on-rails/client bundle:

Bundle base PR head over limit
react-on-rails/client (gzip) 62,880 63,845 +453
react-on-rails/client (brotli) 53,894 54,774 +368
react-on-rails-pro/client (gzip) 64,076 65,095 +507
registerServerComponent/client (gzip) 74,479 75,555 +564

This PR changes only two Pro files (packages/react-on-rails-pro/src/RSCProvider.tsx, tests/imperativeRefetch.client.test.tsx). A change to RSCProvider.tsx cannot alter packages/react-on-rails/lib/ReactOnRails.client.js, yet the OSS bundle moved by +965 bytes. The uniform shift across every bundle is the signature of a base-vs-head baseline skew, not source growth.

Root cause. The branch was 7 commits behind main (merge-base 93fa46be, 2026-06-13). Since then main landed substantial OSS source (rootErrorHandlers.ts, useRailsForm.ts, captureReactOwnerStack.ts, …). The dynamic-limit job (base + 0.5KB) compared two different main baselines, so the OSS delta is the gap between those baselines — unrelated to this change.

Fix applied. Rebased onto current origin/main (a00d2510e704b7f9e5), no conflicts, net diff unchanged (the same two Pro files). The bundle check now builds base and PR head from the same baseline; the only real delta is the small Pro retry-logic change in RSCProvider.tsx (a setTimeout-based cache eviction + identity guard — comments strip on minify), which is well within the 0.5KB per-bundle budget. The OSS react-on-rails/client bundle is byte-identical to base.

No .bundle-size-skip-branch waiver or budget bump was needed.

Note: This resolves only the bundle-size blocker. The PR remains draft pending the other tracked items (Error-as-value retryability product/contract decision, open review threads, and #3929 assignee/maintainer coordination). Not merging here.

@@ -168,6 +192,8 @@ export const createRSCProvider = ({
return payload;
},
(error: unknown) => {
// Non-recovering refetches keep the rejected promise so the
// caller observes the failure; route refetches recover in production.
if (recoverOnError) {

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.

When recoverOnError is false (the default), the rejected refetch promise stays in fetchRSCPromisesRef.current[key] (set just below at line 203). Because getComponent checks key in fetchRSCPromisesRef.current before doing anything (line 111), it will return this stuck rejected promise on every subsequent call — with no eviction timer scheduled and no new fetch triggered.

This is the opposite of what this PR does for getComponent rejections: they get a deferred eviction timer so the next call can retry. A failed non-recovering refetchComponent leaves the cache poisoned until another refetchComponent is issued. If any part of the UI calls getComponent (rather than refetchComponent) after this, it will be permanently broken for that key.

Consider either:

  1. Scheduling a setTimeout(0) eviction here with the same identity guard, or
  2. Adding a test that exercises getComponent after a non-recovering refetchComponent failure and documents the current behaviour as an explicit contract.

}

return new Promise<void>((resolve) => {
setTimeout(resolve, 0);

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 FIFO guarantee here is coupled to the production implementation using setTimeout(0) in evictPromiseIfRejected. If that ever changes to setImmediate, queueMicrotask, or a longer delay, this helper silently stops being sufficient — the eviction would either run later or sooner than expected, and the test could flake or produce false positives.

Consider a brief note in the comment (or the function's JSDoc) that explicitly states: "this helper must be kept in sync with the eviction mechanism in RSCProvider.tsx." The fake-timer guard is already a good defensive check; this would make the coupling explicit for future maintainers.

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Code Review: Fix RSCProvider retry after rejected getComponent promise

The fix addresses a correctness bug where a rejected getComponent promise was permanently cached. The approach — scheduling eviction via setTimeout(0) and guarding with promise identity — is sound and well-reasoned. The three new tests cover the critical invariants cleanly.

The fix is correct:

  • setTimeout(0) over queueMicrotask is the right call. Microtasks run before the runtime yields to React's reconciler, so queueMicrotask could clear the cache entry before Suspense has seen the throw.
  • The closure over promise is temporally safe: the setTimeout fires after promise is assigned, so the identity check uses the correct reference.
  • The unmount safety analysis is accurate — a remounted provider creates a fresh useRef(), so the old closure cannot affect the new instance.

Key concern: refetchComponent rejection permanently poisons the cache

This PR fixes getComponent rejections but leaves an asymmetry in refetchComponent. When refetchComponent rejects without recoverOnError:

  1. fetchRSCPromisesRef.current[key] is set to the rejected refetch promise (line 203).
  2. Subsequent getComponent calls hit the key in fetchRSCPromisesRef.current guard at line 111 and return that stuck rejected promise.
  3. No eviction timer is scheduled — so no automatic retry is possible via getComponent.

The comment at lines 195-196 says this is intentional, but getComponent callers cannot distinguish a rejection from an initial fetch from one caused by a failed refetch. A component calling getComponent after a non-recovering refetchComponent failure will be permanently stuck. See inline comment at RSCProvider.tsx:197 for specifics.

Either apply the same deferred-eviction pattern here, or add a test documenting the permanent-rejection behaviour as an explicit contract.

Test quality:
The RSCProbe pattern (imperative ref exposing context methods without rendering UI) is clean for unit-level cache testing. The fake-timer guard in waitForRejectedGetComponentEviction is a good defensive check. Tests 0a/0b/0c are tightly focused.

Missing test: No test covers the poisoned-cache case after a non-recovering refetchComponent rejection. Adding this would either expose the gap above or document the intentional contract explicitly.

@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.55 ▲15.3% (1759.88) 4.43 ▼4.0% (4.61) 5.56 ▼46.7% (10.43) 200=60889
Pro Node Renderer: react_ssr (non-RSC) 1873.1 ▲15.4% (1622.58) 4.84 ▼7.8% (5.25) 5.97 ▼38.5% (9.71) 200=56202

▲/▼ 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

Core Benchmark Summary

Benchmark RPS p50(ms) p90(ms) Status
/: Core 3.81 ▲5.6% (3.61) 2181.63 ▲5.9% (2060.08) 2678.03 ▲1.2% (2645.46) 200=123
/client_side_hello_world: Core 620.13 ▼5.0% (652.67) 9.54 ▲4.0% (9.17) 25.02 ▲29.4% (19.33) 200=18736
/client_side_rescript_hello_world: Core 679.78 ▲1.4% (670.25) 9.87 ▲7.1% (9.22) 19.65 ▲3.9% (18.91) 200=20538
/client_side_hello_world_shared_store: Core 671.77 ▲5.5% (636.71) 9.85 ▲0.1% (9.84) 24.43 ▲24.7% (19.59) 200=20293
/client_side_hello_world_shared_store_controller: Core 651.82 ▲3.5% (629.5) 12.57 ▲28.4% (9.79) 22.55 ▲8.2% (20.83) 200=19690
/client_side_hello_world_shared_store_defer: Core 640.96 ▼1.3% (649.51) 7.96 ▼19.0% (9.83) 17.69 ▼11.1% (19.89) 200=19368
/server_side_hello_world_shared_store: Core 16.35 ▲5.3% (15.53) 510.89 ▲3.1% (495.64) 643.96 ▼2.0% (657.27) 200=502
/server_side_hello_world_shared_store_controller: Core 16.42 ▲6.2% (15.46) 493.14 ▲2.0% (483.58) 660.22 ▲0.3% (658.12) 200=503
/server_side_hello_world_shared_store_defer: Core 16.38 ▲5.8% (15.48) 515.97 ▲5.4% (489.39) 681.63 ▲2.9% (662.23) 200=498
/server_side_hello_world: Core 33.25 ▲6.5% (31.22) 246.17 ▼1.0% (248.57) 299.54 ▼1.4% (303.83) 200=1014
/server_side_hello_world_hooks: Core 33.81 ▲10.4% (30.63) 247.4 ▲0.7% (245.74) 303.3 ▲0.1% (303.03) 200=1025
/server_side_hello_world_props: Core 24.05 ▼20.9% (30.39) 256.83 ▲4.0% (246.84) 296.31 ▼4.3% (309.78) 200=733
/client_side_log_throw: Core 671.69 ▼1.6% (682.91) 9.11 ▼2.6% (9.35) 19.8 ▲3.6% (19.12) 200=20294
/server_side_log_throw: Core 32.26 ▲6.5% (30.29) 254.45 0.0% (254.57) 309.06 ▼0.6% (310.96) 200=982
/server_side_log_throw_plain_js: Core 32.86 ▲8.0% (30.41) 200.54 ▼19.3% (248.46) 296.38 ▼4.5% (310.4) 200=1001
/server_side_log_throw_raise: Core 32.97 ▲7.6% (30.65) 278.12 ▲11.2% (250.07) 309.23 ▲1.0% (306.06) 3xx=999
/server_side_log_throw_raise_invoker: Core 821.3 ▲5.4% (779.31) 7.63 ▼8.8% (8.36) 12.88 ▼21.9% (16.49) 200=24816
/server_side_hello_world_es5: Core 33.05 ▲7.3% (30.8) 181.1 ▼26.6% (246.77) 288.82 ▼4.9% (303.82) 200=1010
/server_side_redux_app: Core 32.26 ▲7.6% (29.98) 254.23 ▲2.2% (248.85) 310.85 0.0% (310.93) 200=980
/server_side_hello_world_with_options: Core 33.47 ▲7.0% (31.27) 250.81 ▲1.3% (247.48) 306.86 ▼0.2% (307.51) 200=1016
/server_side_redux_app_cached: Core 502.38 ▼22.5% (648.14) 11.34 ▲14.1% (9.94) 13.69 ▼24.2% (18.07) 200=15278
/client_side_manual_render: Core 728.66 ▲10.6% (659.1) 9.07 ▼4.9% (9.53) 18.35 ▼8.5% (20.06) 200=22015
/render_js: Core 36.24 ▲8.9% (33.27) 229.73 ▼2.0% (234.37) 282.53 ▼0.5% (283.91) 200=1100
/react_router: Core 31.5 ▲8.3% (29.09) 267.94 ▲3.6% (258.58) 324.76 ▼0.6% (326.69) 200=955
/pure_component: Core 34.16 ▲6.9% (31.94) 241.86 ▼5.1% (254.88) 298.51 ▼1.5% (303.07) 200=1036
/react_compiler_example: Core 33.23 ▲7.0% (31.05) 253.17 ▲2.2% (247.76) 310.0 ▼0.1% (310.45) 200=1007
/css_modules_images_fonts_example: Core 33.49 ▲8.9% (30.76) 246.9 ▼2.5% (253.27) 304.59 0.0% (304.48) 200=1018
/turbolinks_cache_disabled: Core 701.81 ▲1.9% (688.57) 8.66 ▼6.4% (9.25) 22.02 ▲12.7% (19.54) 200=21199
/rendered_html: Core 34.07 ▲7.9% (31.58) 242.32 ▼3.0% (249.77) 295.42 ▼2.9% (304.12) 200=1035
/xhr_refresh: Core 17.19 ▲9.2% (15.75) 480.4 ▲2.3% (469.81) 611.1 ▼3.8% (634.98) 200=527
/react_helmet: Core 32.76 ▲5.9% (30.95) 252.65 ▲0.8% (250.67) 307.2 ▼1.5% (311.83) 200=995
/broken_app: Core 32.42 ▲5.8% (30.63) 256.08 ▲0.2% (255.47) 313.96 ▼0.2% (314.6) 200=984
/image_example: Core 32.95 ▲6.2% (31.02) 246.78 ▼2.9% (254.06) 303.2 ▼2.4% (310.7) 200=1004
/font_optimization_example: Core 842.97 ▲9.1% (772.71) 7.99 ▼3.8% (8.31) 15.88 ▼3.2% (16.41) 200=25300
/client_side_activity: Core 694.26 ▲6.8% (649.84) 8.69 ▼11.6% (9.83) 22.17 ▲12.8% (19.66) 200=20978
/server_side_activity: Core 33.36 ▲10.4% (30.21) 248.2 ▼2.9% (255.5) 301.85 ▼7.2% (325.18) 200=1014
/turbo_frame_tag_hello_world: Core 811.68 ▲13.7% (713.72) 7.57 ▼11.4% (8.54) 12.96 ▼26.4% (17.61) 200=24521
/manual_render_test: Core 720.51 ▲9.2% (659.92) 8.55 ▼8.3% (9.33) 20.86 ▲12.3% (18.57) 200=21768
/root_error_callbacks: Core 33.7 ▲11.1% (30.35) 247.02 ▼4.6% (259.05) 301.59 ▼4.3% (315.14) 200=1025
/rails_form: Core 725.97 ▲7.3% (676.63) 9.11 ▼0.7% (9.18) 21.6 ▲13.4% (19.05) 200=21930

▲/▼ 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 1306.03 ▲7.2% (1218.03) 6.11 ▲3.1% (5.93) 8.84 ▼9.7% (9.79) 200=39451
/ssr_shell_error: Pro 148.03 ▼45.4% (271.17) 39.7 ▲27.9% (31.04) 105.57 ▲110.1% (50.25) 200=4476
/ssr_sync_error: Pro 171.39 ▼36.0% (267.89) 42.92 ▲37.0% (31.32) 68.73 ▲35.1% (50.87) 200=5182
/rsc_component_error: Pro 122.21 ▼52.5% (257.21) 36.75 ▲21.6% (30.22) 120.67 ▲136.3% (51.08) 200=4490
/non_existing_stream_react_component: Pro 190.68 ▼32.9% (284.22) 39.27 ▲37.5% (28.56) 62.9 ▲30.1% (48.33) 200=5766
/server_side_redux_app_cached: Pro 406.64 ▲10.6% (367.69) 19.15 ▼0.5% (19.25) 28.33 ▼11.3% (31.92) 200=12289
/loadable: Pro 159.02 ▼36.8% (251.61) 45.86 ▲40.7% (32.59) 74.41 ▲37.3% (54.2) 200=4808
/apollo_graphql: Pro 142.06 ▲8.1% (131.42) 41.46 ▼18.9% (51.14) 69.35 ▼18.1% (84.73) 200=4288,3xx=8
/console_logs_in_async_server: Pro 3.16 ▲2.7% (3.08) 2119.54 ▼0.2% (2123.05) 2136.86 ▼2.9% (2199.89) 200=108
/stream_error_demo: Pro 2.9 ▼98.6% (211.33) 2013.12 ▲152.1% (798.46) 2063.32 ▲152.3% (817.7) 200=94
/stream_async_components: Pro 156.74 ▼39.3% (258.29) 44.8 ▲40.6% (31.86) 68.03 ▲30.8% (52.03) 200=4720,3xx=19
/rsc_posts_page_over_http: Pro 149.69 ▼42.5% (260.45) 47.91 ▲43.2% (33.46) 79.4 ▲38.8% (57.19) 200=4524
/rsc_echo_props: Pro 61.33 ▼62.7% (164.43) 96.74 ▲41.7% (68.26) 204.83 ▲93.3% (105.99) 200=1857
/client_side_fouc_probe: Pro 397.52 ▲8.9% (365.17) 18.81 ▼3.0% (19.39) 28.53 ▼7.7% (30.9) 200=12013
/async_on_server_sync_on_client_client_render: Pro 384.67 ▲11.2% (345.9) 19.55 ▼2.7% (20.09) 33.22 ▼5.3% (35.06) 200=11623
/server_router_client_render: Pro 321.69 ▼10.4% (359.18) 17.82 ▼12.6% (20.4) 45.75 ▲44.1% (31.75) 200=9720
/unwrapped_rsc_route_stream_render: Pro 187.61 ▼32.9% (279.5) 39.76 ▲32.6% (29.99) 64.9 ▲29.3% (50.19) 200=5673
/async_render_function_returns_component: Pro 201.92 ▼28.7% (283.36) 15.73 ▼45.2% (28.72) 48.79 ▲9.4% (44.6) 200=6150
/native_metadata: Pro 162.13 ▼41.1% (275.22) 35.11 ▲29.2% (27.18) 110.6 ▲139.8% (46.13) 200=4902
/hybrid_metadata_streaming: Pro 186.37 ▼31.2% (270.88) 40.38 ▲36.2% (29.64) 64.86 ▲31.1% (49.46) 200=5634
/cache_demo: Pro 138.8 ▼45.9% (256.61) 54.42 ▲55.9% (34.9) 85.23 ▲48.6% (57.37) 200=4197
/client_side_hello_world: Pro 352.98 ▼2.5% (362.1) 7.14 🟢 64.2% (19.93) 18.44 ▼42.8% (32.22) 200=10740
/client_side_hello_world_shared_store_controller: Pro 364.51 ▲9.2% (333.89) 20.19 ▼2.9% (20.78) 31.37 ▼5.0% (33.01) 200=11015
/server_side_hello_world_shared_store: Pro 130.74 ▼38.7% (213.15) 66.63 ▲65.9% (40.16) 90.23 ▲47.0% (61.39) 200=3927
/server_side_hello_world_shared_store_defer: Pro 133.84 ▼39.4% (220.68) 57.21 ▲40.3% (40.78) 87.7 ▲36.9% (64.08) 200=4050
/server_side_hello_world_hooks: Pro 206.31 ▼25.8% (277.91) 35.99 ▲30.0% (27.68) 53.65 ▲16.3% (46.15) 200=6237
/server_side_log_throw: Pro 182.52 ▼34.3% (277.93) 28.27 ▼3.8% (29.38) 55.6 ▲10.9% (50.13) 200=5549,3xx=8
/server_side_log_throw_raise: Pro 277.84 ▼45.7% (511.26) 27.44 ▲48.9% (18.43) 43.72 ▲45.0% (30.15) 3xx=8399
/server_side_hello_world_es5: Pro 205.68 ▼26.6% (280.07) 26.42 ▼11.1% (29.73) 49.98 ▲8.9% (45.9) 200=6259
/server_side_hello_world_with_options: Pro 173.15 ▼35.3% (267.75) 26.32 ▼8.7% (28.83) 40.27 ▼17.8% (48.99) 200=5327
/client_side_manual_render: Pro 403.84 ▲9.4% (369.14) 19.17 ▼0.8% (19.33) 30.03 ▼0.1% (30.07) 200=12202
/react_router: Pro 206.52 ▼34.5% (315.23) 35.48 ▲31.8% (26.92) 57.78 ▲30.7% (44.22) 200=6243
/css_modules_images_fonts_example: Pro 174.21 ▼36.6% (274.89) 33.38 ▲11.1% (30.05) 74.46 ▲43.6% (51.87) 200=5268
/rendered_html: Pro 198.47 ▼28.1% (275.97) 26.32 ▼9.6% (29.12) 49.8 ▲6.9% (46.58) 200=6032,3xx=11
/react_helmet: Pro 169.11 ▼32.7% (251.22) 44.06 ▲16.5% (37.82) 63.66 ▲10.3% (57.7) 200=5113
/image_example: Pro 199.48 ▼28.3% (278.35) 38.93 ▲37.0% (28.42) 57.28 ▲22.6% (46.7) 200=6027
/posts_page: Pro 148.31 ▼24.2% (195.59) 58.15 ▲47.0% (39.55) 79.48 ▲25.7% (63.21) 200=4456

▲/▼ 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 49.97 ▼61.4% (129.57) 159.66 ▲81.4% (88.03) 218.21 ▲72.3% (126.64) 200=1512
/error_scenarios_hub: Pro 319.57 ▼12.4% (364.69) 17.8 ▼7.2% (19.18) 20.81 ▼33.1% (31.1) 200=9721
/ssr_async_error: Pro 3.33 ▼98.4% (213.39) 2011.38 ▲152.0% (798.22) 2037.82 ▲149.3% (817.29) 200=108
/ssr_async_prop_error: Pro 1.11 ▼99.5% (207.03) 5025.13 ▲154.7% (1972.93) 8425.12 ▲208.7% (2729.29) 200=40
/non_existing_react_component: Pro 191.83 ▼33.8% (289.77) 39.04 ▲36.3% (28.65) 58.45 ▲25.0% (46.75) 200=5801
/non_existing_rsc_payload: Pro 202.49 ▼33.2% (303.09) 30.97 ▲10.1% (28.14) 52.24 ▲13.3% (46.11) 200=6121
/cached_react_helmet: Pro 406.29 ▲5.8% (383.99) 19.45 ▲3.0% (18.88) 30.82 ▼3.1% (31.81) 200=12276
/cached_redux_component: Pro 405.26 ▲4.7% (387.11) 19.32 ▲2.8% (18.79) 27.53 ▼12.8% (31.56) 200=12245
/lazy_apollo_graphql: Pro 152.22 ▲9.3% (139.25) 47.09 ▼7.8% (51.09) 70.5 ▼14.5% (82.45) 200=4604
/redis_receiver: Pro 102.1 ▲15.3% (88.54) 66.49 ▼2.8% (68.4) 126.57 ▼15.8% (150.26) 200=3089
/stream_shell_error_demo: Pro 163.15 ▼38.3% (264.35) 43.69 ▲44.3% (30.27) 71.92 ▲35.6% (53.03) 200=4911,3xx=23
/test_incremental_rendering: Pro 2.9 ▼98.7% (217.08) 2008.83 ▲152.0% (797.31) 2042.23 ▲151.1% (813.24) 200=94
/rsc_posts_page_over_redis: Pro 93.93 ▲0.2% (93.71) 62.97 ▼14.0% (73.22) 133.78 ▲11.8% (119.7) 200=2842
/rsc_fouc_probe: Pro 149.75 ▼19.1% (185.11) 39.04 ▼4.6% (40.9) 94.86 ▲40.7% (67.43) 200=4529
/async_on_server_sync_on_client: Pro 1.73 ▼99.1% (203.19) 3010.04 ▲153.2% (1188.99) 3352.25 ▲53.3% (2186.12) 200=64
/server_router: Pro 144.28 ▼46.2% (268.17) 40.61 ▲34.4% (30.21) 107.0 ▲99.2% (53.72) 200=4363
/unwrapped_rsc_route_client_render: Pro 389.18 ▲3.0% (378.0) 19.49 ▲5.5% (18.48) 31.8 ▲3.3% (30.78) 200=11760
/async_render_function_returns_string: Pro 191.39 ▼30.3% (274.51) 38.39 ▲37.3% (27.95) 57.07 ▲23.1% (46.35) 200=5791,3xx=5
/async_components_demo: Pro 5.76 ▼95.7% (135.0) 1025.26 ▲141.5% (424.61) 1077.39 ▲136.8% (454.98) 200=184
/stream_native_metadata: Pro 157.33 ▼43.3% (277.31) 36.51 ▲22.2% (29.87) 49.41 ▲1.8% (48.55) 200=4788
/rsc_native_metadata: Pro 5.86 ▼97.2% (211.46) 1010.15 ▲147.9% (407.55) 1029.85 ▲142.6% (424.54) 200=184
/react_intl_rsc_demo: Pro 89.62 ▼63.5% (245.59) 73.49 ▲76.7% (41.59) 140.68 ▲101.0% (69.99) 200=2712
/client_side_hello_world_shared_store: Pro 367.25 ▲4.5% (351.33) 23.36 ▲14.0% (20.5) 32.05 ▼4.3% (33.47) 200=11027
/client_side_hello_world_shared_store_defer: Pro 365.48 ▲7.6% (339.57) 20.71 ▲1.0% (20.51) 33.27 ▲1.7% (32.7) 200=11044
/server_side_hello_world_shared_store_controller: Pro 105.48 ▼52.3% (221.15) 55.35 ▲44.0% (38.44) 125.7 ▲98.6% (63.3) 200=3190
/server_side_hello_world: Pro 158.7 ▼41.7% (272.07) 35.05 ▲25.1% (28.01) 46.95 ▼2.2% (48.02) 200=4829
/client_side_log_throw: Pro 393.89 ▲4.9% (375.55) 14.17 ▼22.9% (18.37) 26.33 ▼14.9% (30.96) 200=11905
/server_side_log_throw_plain_js: Pro 411.83 ▲7.1% (384.46) 19.05 ▼0.5% (19.14) 27.01 ▼13.2% (31.13) 200=12444
/server_side_log_throw_raise_invoker: Pro 450.49 ▲7.1% (420.47) 17.09 ▲4.5% (16.35) 24.87 ▼6.3% (26.54) 200=13611
/server_side_redux_app: Pro 178.64 ▼35.5% (276.85) 41.24 ▲36.0% (30.32) 66.59 ▲39.9% (47.6) 200=5382,3xx=19
/server_side_redux_app_cached: Pro 397.0 ▲8.0% (367.69) 19.62 ▲1.9% (19.25) 28.37 ▼11.1% (31.92) 200=11995
/render_js: Pro 423.45 ▲9.3% (387.58) 18.7 ▲1.0% (18.51) 29.28 ▼2.1% (29.91) 200=12714
/pure_component: Pro 201.78 ▼28.4% (281.64) 38.23 ▲36.2% (28.07) 57.64 ▲16.8% (49.35) 200=6099
/turbolinks_cache_disabled: Pro 399.45 ▲3.9% (384.5) 19.17 ▼0.3% (19.22) 28.26 ▼5.8% (30.01) 200=12071
/xhr_refresh: Pro 133.63 ▼40.4% (224.04) 57.05 ▲47.6% (38.65) 86.0 ▲38.9% (61.9) 200=4050
/broken_app: Pro 196.98 ▼30.7% (284.09) 38.91 ▲32.9% (29.27) 60.17 ▲28.9% (46.7) 200=5955
/server_render_with_timeout: Pro 51.12 ▼78.4% (237.11) 112.9 ▲97.6% (57.14) 121.29 ▲69.1% (71.73) 200=1559

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

justin808 added a commit that referenced this pull request Jun 19, 2026
…4110)

## Problem

`check-bundle-size` produced false-positive failures for PRs whose
branch had fallen behind the base branch. The job built **base** sizes
from `github.event.pull_request.base.sha` but built **PR-head** sizes
from `github.sha` — the `refs/pull/N/merge` commit (the head merged into
the base-branch tip).

When the PR branch is several commits behind `main`, those two values
reference **different base-branch baselines**. Growth that landed on
`main` after the branch point gets absorbed into the delta and
mis-attributed to the PR, producing a uniform ~700–1100 byte "increase"
across **every** tracked bundle — including the OSS
`react-on-rails/client` bundle a Pro-only PR never touched. Hit
concretely by #4022 and #4023; the only workaround was a manual rebase
onto current `main`.

## Root cause (verified)

For `pull_request` events `github.sha` is the ephemeral merge commit
`refs/pull/N/merge`, which has two parents:
- **parent 1** = the base-branch commit the head was merged into
- **parent 2** = the PR head

The PR-head build already uses this merge commit, so its parent 1 is the
*exact* base baseline the merge was computed against. Building base from
the separate, frozen `base.sha` lets the two drift apart as `main`
advances.

## Fix (Option 3, anchored to the merge commit)

Derive base from **`github.sha`'s first parent** instead of `base.sha`:

- Base and PR-head builds now share the same base-branch baseline **by
construction** — skew is provably impossible, with no frozen-payload
value trusted and no live-tip fetch (which would reintroduce a race).
- Self-heals stale branches: a behind-`main` PR is always measured
against the baseline its own merge ref uses.

vs. the other options:
- **Option 1 (`git merge-base`)** pairs against the branch point, but
the head build is the *merge ref* — so merge-base vs merge-ref still
skews.
- **Option 2 (fail-soft "rebase and re-run")** leaves the manual toil in
place.

### Changes (`.github/workflows/bundle-size.yml` only)

- PR-runtime checkout now uses `fetch-depth: 0` so the merge commit's
parents are real (a shallow checkout grafts the tip and hides them).
- New **Determine base SHA** step validates `github.sha` is a 2-parent
merge commit whose second parent matches the PR head, and fails with an
actionable message when the PR is not mergeable (`github.sha == head
sha`, i.e. base-branch conflicts).
- Base checkout consumes the derived SHA with `fetch-depth: 0`.

No changes to `scripts/bundle-size.mjs` or `.size-limit.json`; the
`set-limits` flow is unchanged.

## Validation

- `actionlint .github/workflows/bundle-size.yml` — clean (SC2207 fixed
via `read -ra`).
- Derivation logic unit-tested under bash against the real
`refs/pull/4022/merge` ref:
  - real merge ref → base = correct first parent ✓
  - unmergeable (`github.sha == head`) → actionable error ✓
  - non-merge (1-parent) commit → caught ✓
  - merge ref with mismatched head → caught ✓
- `prettier --check` clean (and `*.yml` is prettier-ignored).

## Workflow Change Audit

- **Secret references:** none added/changed.
- **`permissions:`** unchanged (`contents: read`; job retains
`pull-requests: write`).
- **`on:` triggers:** unchanged.
- **Third-party actions:** none added or version-changed
(`actions/checkout@v4`, `actions/setup-node@v4`, `pnpm/action-setup@v4`,
`andresz1/size-limit-action@v1` all unchanged).
- **Command injection:** new `run:` block reads only SHAs, passed via
`env:` and quoted (`"$MERGE_SHA"`, `"$HEAD_SHA"`); `ref:` consumes a
git-validated SHA, not free text.

### New-gate / stale-base rollout note

This change does not add or broaden enforcement — it makes the existing
gate **immune to stale-base skew by construction**, so it can only
reduce false positives. Stale-based open PRs that touch `packages/**`
will, on their next `check-bundle-size` run, be measured against their
own merge-ref baseline and get accurate results (the fix is
self-applying; re-running the check is the race control). No sweep of
in-flight PRs is required to land safely.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> CI-only workflow change with no secrets or app code; it should reduce
false positives and only fails early on unmergeable PRs.
> 
> **Overview**
> Fixes **false-positive bundle size failures** when a PR branch is
behind the base branch. The job used to measure the PR from `github.sha`
(the ephemeral merge commit) but measured the baseline from
`github.event.pull_request.base.sha`, so unrelated growth on `main`
could look like a uniform increase across every tracked bundle.
> 
> The initial PR checkout now uses **`fetch-depth: 0`** so the merge
commit’s parents are visible. A new **Determine base SHA** step reads
the **first parent** of `github.sha`, checks it is a two-parent merge
whose second parent matches the PR head, and fails with a clear message
when the PR is not mergeable. The **base branch checkout** uses that
derived SHA instead of `base.sha`, so base and PR-head builds share the
same baseline by construction.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
52ae4bd. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

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

* **Chores**
* Improved bundle size checking accuracy and reliability by enhancing
how the CI workflow determines and checks out the baseline SHA for pull
requests.
* Added validation and clearer failure messages when pull request merge
structure is unexpected or not mergeable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Merge confidence note

Merged with delegated authority. Rationale:

- **Scope is contained and low-risk.** Single-file change to
`.github/workflows/bundle-size.yml`; no product/runtime code, no
`permissions:`/`on:`/secret/third-party-action changes (per the Workflow
Change Audit comment). The gate only loosens — it removes a
false-positive source and cannot make a real size regression pass (the
`base + 0.5KB` threshold logic is unchanged).
- **Proven end-to-end, not just reasoned.** `check-bundle-size` ran
green on this PR's own head, exercising the new
merge-commit-first-parent base derivation and the base checkout at
default depth. Root cause was verified empirically against the real
`refs/pull/4022/merge` ref (parent1 = base, parent2 = head).
- **Full required gate green:** `required-pr-gate`, `check-bundle-size`,
`actionlint`, `zizmor`, `CodeQL`, `Analyze (ruby/js)` all SUCCESS.
- **Independent review satisfied:** CodeRabbit approved; Cursor Bugbot
green; `claude-review` green for the current head; both inline review
findings (error-message underflow, base `fetch-depth`) addressed in
`52ae4bdb8` and replied to. No unresolved actionable feedback.
- **Stale-base rollout handled:** the change is self-applying — open
behind-`main` PRs touching `packages/**` get accurate results on their
next `check-bundle-size` run; no pre-land sweep required.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant