internal/planning/ppr-investigation-findings.md
Date: 2026-05-18 Status: Research Phase Related Issue: #3311
This document summarizes our investigation into Partial Pre-rendering (PPR) for React Server Components. The goal is to understand how PPR can be implemented in React on Rails Pro to enable instant page loads with streaming dynamic content.
"use cache" directive is essential: It's the only mechanism to prevent component re-executionPPR implementations require coordinating two separate rendering layers:
react-server-dom-webpack packageprerender() returns { prelude } only — NO postponed statereact-dom/static and react-dom/server packagesprerender() returns { prelude, postponed } — CAN be resumedBUILD TIME:
┌─────────────────────────────────────────────────────────────────────────┐
│ RSC Layer: Executes ALL components → Flight data │
│ │ │
│ ↓ │
│ Fizz Layer: prerender() → { HTML prelude, postponed state } │
│ │
│ STORED: HTML shell + postponed state + flight data │
└─────────────────────────────────────────────────────────────────────────┘
REQUEST TIME:
┌─────────────────────────────────────────────────────────────────────────┐
│ RSC Layer: Re-executes ALL components → Fresh flight data │
│ │ ("use cache" prevents execution via cache lookup) │
│ ↓ │
│ Fizz Layer: resume(postponed) → Only renders dynamic parts │
│ │
│ RESULT: Static HTML merged with dynamic HTML + fresh flight data │
└─────────────────────────────────────────────────────────────────────────┘
| API | Package | Returns | Postpone Support | Resume Support |
|---|---|---|---|---|
prerender() | react-server-dom-webpack/static | { prelude } | onPostpone callback only | NO |
prerender() | react-dom/static | { prelude, postponed } | YES | YES |
PPR at the RSC layer is not directly supported by current React architecture.
RSC prerender returns only
{ prelude }with no way to resume. The RSC layer must execute ALL components on every request unless caching is explicitly implemented.
Without explicit caching, ALL components (sync or async) re-execute on EVERY request:
We tested various component types across multiple requests:
| Component Type | Cache Directive | Render Count (3 requests) |
|---|---|---|
| Sync pure component | none | 3 (every request) |
| Sync with side effects | none | 3 (every request) |
| Async without directive | none | 3 (every request) |
Async with "use cache" | "use cache" | 0 (served from cache) |
Sync with "use cache" | "use cache" | 0 (served from cache) |
| Cached function | "use cache" | 1 (cached after first call) |
Request 1:
[RENDER] StaticSibling - count: 1
[RENDER] DynamicComponent - count: 1
Request 2:
[RENDER] StaticSibling - count: 2 ← Re-executed!
[RENDER] DynamicComponent - count: 2
Request 3:
[RENDER] StaticSibling - count: 3 ← Re-executed again!
[RENDER] DynamicComponent - count: 3
There is NO automatic "static shell" detection based on component type or content. The mental model is:
"Dynamic by default, opt-in caching with
use cache"
"use cache" DirectiveThe "use cache" directive is essential for PPR performance. Other RSC frameworks implement this as a build-time transformation.
┌─────────────────────────────────────────────────────────────────────────┐
│ Call: CachedComponent() │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Generate cache key from: │
│ - Function identifier (hash) │
│ - Serialized arguments │
│ │
│ 2. Check cache: │
│ │ │
│ ├─ HIT: Return cached RSC payload │
│ │ └─ Component function NOT executed! │
│ │ │
│ └─ MISS: Execute original function │
│ ├─ Serialize result to RSC flight format │
│ └─ Store in cache with configured lifetime │
│ │
└─────────────────────────────────────────────────────────────────────────┘
The cached value is serialized RSC flight data, not the raw function result:
0:["$","div",null,{"children":["Time: ","2026-05-18T07:23:18.052Z"]}]
Other RSC frameworks transform "use cache" components at build time:
// Source
async function CachedComponent() {
'use cache';
const data = await fetchData();
return <div>{data}</div>;
}
// Transformed (conceptual)
async function CachedComponent() {
const cacheKey = computeCacheKey(CachedComponent, arguments);
const cached = await cache.get(cacheKey);
if (cached) return cached;
const result = await originalFunction();
await cache.set(cacheKey, result);
return result;
}
To create dynamic boundaries during prerender, frameworks use "hanging promises" — promises that never resolve naturally:
function makeHangingPromise<T>(signal: AbortSignal, expression: string): Promise<T> {
return new Promise<T>((_, reject) => {
signal.addEventListener('abort', () => {
reject(new Error(`${expression} rejects when prerender completes.`));
});
});
}
Other RSC frameworks use a signal mechanism to track when all cached components finish:
class CacheSignal {
private pendingCacheReads = 0;
private resolvers: Array<() => void> = [];
beginRead() {
this.pendingCacheReads++;
}
endRead() {
this.pendingCacheReads--;
if (this.pendingCacheReads === 0) {
this.resolvers.forEach((resolve) => resolve());
}
}
cacheReady(): Promise<void> {
if (this.pendingCacheReads === 0) return Promise.resolve();
return new Promise((resolve) => this.resolvers.push(resolve));
}
}
const controller = new AbortController();
const cacheSignal = new CacheSignal();
// Start prerender
const { prelude } = await prerenderToNodeStream(<App />, {
signal: controller.signal,
});
// Wait for all caches to fill
await cacheSignal.cacheReady();
// Signal completion - hanging promises reject
controller.abort();
The browser receives HTML with placeholders, then JavaScript chunks fill them:
<!-- 1. Initial HTML with placeholders -->
<main>
<div>Cached content from build</div>
<template id="B:0"></template>
<div data-fallback>Loading...</div>
</main>
<!-- 2. Streamed chunks as dynamic components complete -->
<script>
$RC('B:0', '<div>Dynamic content</div>');
</script>
The $RC function (provided by React) replaces placeholders:
function $RC(id, html) {
const template = document.getElementById(id);
const fallback = template.nextSibling;
const content = parseHTML(html);
fallback.replaceWith(content);
template.remove();
}
Operations like Date.now(), Math.random(), crypto.randomUUID() in non-cached components cause build errors. They must be explicitly handled:
"use cache"You cannot use request-time APIs inside a cached component or its children:
// ERROR!
async function CachedParent() {
'use cache';
return <DynamicChild />; // DynamicChild uses cookies() - fails!
}
Nested "use cache" components are cached independently with separate cache entries.
| Aspect | Finding |
|---|---|
| Static detection | None — all components dynamic by default |
| Caching mechanism | "use cache" directive only |
| RSC resumption | Not supported — must re-execute all components |
| Fizz resumption | Supported via postponed state |
| Build HTML | Not served at runtime — page re-renders |
| Cache format | Serialized RSC flight data |
| Dynamic boundaries | Created via hanging promises |
| Coordination | CacheSignal tracks cache completion |
Problem: We have not found a specific API that integrates partial prerendering with renderToPipeableStream in react-server-dom-webpack.
Current Understanding:
react-server-dom-webpack/static.prerender() returns only { prelude } with no postponed stateInvestigation Needed:
"use cache" DirectiveProblem: The "use cache" directive requires:
Investigation Needed:
react-server-dom-webpack package sourcereact-dom/static prerender APIThis document represents findings from May 2026. React and RSC APIs may evolve.