From 0a2172f0686d46d4be687c4b36b6ebb524f68712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Thu, 26 Mar 2026 10:11:29 +0100 Subject: [PATCH] feat: use cache request provider support in ssr layer --- .github/workflows/release-experimental.yml | 2 +- docs/src/components/Terminal.module.css | 13 +- .../src/pages/en/(pages)/features/caching.mdx | 126 +- .../src/pages/ja/(pages)/features/caching.mdx | 316 ++++- examples/use-worker/globals.css | 5 +- packages/react-server/cache/client.mjs | 96 +- packages/react-server/cache/index.mjs | 139 ++- .../cache/request-cache-shared.mjs | 333 +++++ packages/react-server/cache/ssr.mjs | 135 ++ .../react-server/cache/storage-cache.d.ts | 3 + packages/react-server/cache/storage-cache.mjs | 81 +- packages/react-server/lib/build/server.mjs | 4 + .../react-server/lib/dev/create-server.mjs | 6 + packages/react-server/lib/dev/ssr-handler.mjs | 24 +- .../lib/plugins/use-cache-inline.mjs | 44 +- .../react-server/lib/start/ssr-handler.mjs | 35 +- .../react-server/server/create-worker.mjs | 18 +- packages/react-server/server/render-dom.mjs | 1086 +++++++++-------- packages/react-server/server/render-rsc.jsx | 5 + .../server/request-cache-context.mjs | 12 + packages/react-server/server/symbols.mjs | 2 + packages/rsc/__tests__/flight-sync.test.mjs | 617 ++++++++++ packages/rsc/client/index.mjs | 1 + packages/rsc/client/shared.mjs | 66 + packages/rsc/server/index.mjs | 1 + packages/rsc/server/shared.mjs | 54 + skills/react-server/SKILL.md | 44 +- test/__test__/use-cache-request.spec.mjs | 198 +++ test/fixtures/use-cache-request-client.jsx | 17 + test/fixtures/use-cache-request-data.mjs | 41 + .../use-cache-request-no-hydrate-client.jsx | 17 + .../fixtures/use-cache-request-no-hydrate.jsx | 24 + .../use-cache-request-suspense-client.jsx | 14 + test/fixtures/use-cache-request-suspense.jsx | 37 + test/fixtures/use-cache-request.jsx | 38 + 35 files changed, 3095 insertions(+), 559 deletions(-) create mode 100644 packages/react-server/cache/request-cache-shared.mjs create mode 100644 packages/react-server/cache/ssr.mjs create mode 100644 packages/react-server/server/request-cache-context.mjs create mode 100644 packages/rsc/__tests__/flight-sync.test.mjs create mode 100644 test/__test__/use-cache-request.spec.mjs create mode 100644 test/fixtures/use-cache-request-client.jsx create mode 100644 test/fixtures/use-cache-request-data.mjs create mode 100644 test/fixtures/use-cache-request-no-hydrate-client.jsx create mode 100644 test/fixtures/use-cache-request-no-hydrate.jsx create mode 100644 test/fixtures/use-cache-request-suspense-client.jsx create mode 100644 test/fixtures/use-cache-request-suspense.jsx create mode 100644 test/fixtures/use-cache-request.jsx diff --git a/.github/workflows/release-experimental.yml b/.github/workflows/release-experimental.yml index cf1171bb..6aa69d27 100644 --- a/.github/workflows/release-experimental.yml +++ b/.github/workflows/release-experimental.yml @@ -131,4 +131,4 @@ jobs: - name: Publish @lazarv/create-react-server if: steps.prepare-create-react-server.outcome == 'success' working-directory: ./packages/create-react-server - run: pnpm publish --provenance --access=public --tag=latest \ No newline at end of file + run: pnpm publish --provenance --access=public --tag=latest diff --git a/docs/src/components/Terminal.module.css b/docs/src/components/Terminal.module.css index 11f8dd3a..79b7530c 100644 --- a/docs/src/components/Terminal.module.css +++ b/docs/src/components/Terminal.module.css @@ -235,7 +235,9 @@ background: #1e1e2e; color: #cdd6f4; padding: 1rem 1.25rem; - font-family: "SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", "Menlo", "Consolas", monospace; + font-family: + "SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", "Menlo", + "Consolas", monospace; font-size: 0.95rem; line-height: 1.6; white-space: nowrap; @@ -263,6 +265,11 @@ } @keyframes blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } } diff --git a/docs/src/pages/en/(pages)/features/caching.mdx b/docs/src/pages/en/(pages)/features/caching.mdx index 0c5c73be..3e526158 100644 --- a/docs/src/pages/en/(pages)/features/caching.mdx +++ b/docs/src/pages/en/(pages)/features/caching.mdx @@ -260,11 +260,135 @@ export default { `@lazarv/react-server` provides a few built-in cache providers that you can use out of the box without any configuration. These are: - `memory`: A simple in-memory cache provider. This is the default cache provider. -- `request`: A cache provider that only lives for the duration of the request. This is useful for caching data that is only needed for the current request. +- `request`: A cache provider that only lives for the duration of the request. This is useful for deduplicating expensive computations within a single request. The cached value is shared across both RSC and SSR environments, so the function body runs only once per request regardless of how many components consume it. See [Request-scoped caching](#request-scoped-caching) for details. - `null`: A cache provider that does not store any data. This is useful for disabling caching in specific parts of your application. Useful with a cache provider alias. - `local`: A cache provider that uses the browser's local storage. This is useful for caching data that needs to persist across page reloads. - `session`: A cache provider that uses the browser's session storage. This is useful for caching data that needs to persist across page reloads, but only for the current session. + +## Request-scoped caching + + +The `request` cache provider deduplicates function calls within a single HTTP request. When you mark a function with `"use cache: request"`, the function body executes only once per request — every subsequent call with the same arguments returns the same cached result, even across RSC and SSR rendering environments. + +This is useful for expensive computations or data fetching that multiple components depend on within the same page render. + +### Defining a request-cached function + +Use the `"use cache: request"` directive at the top of any function. The function can be async and can return any RSC-serializable value, including `Date` objects, nested objects, and arrays. + +```mjs filename="get-request-data.mjs" +let computeCount = 0; + +export async function getRequestData() { + "use cache: request"; + // Simulate an async operation + await new Promise((resolve) => setTimeout(resolve, 5)); + computeCount++; + return { + timestamp: Date.now(), + random: Math.random(), + computeCount, + createdAt: new Date(), + }; +} +``` + +In this example, `getRequestData` will only execute once per request. Every component that calls it during the same request receives identical `timestamp`, `random`, and `computeCount` values. + +### Using in server components + +Server components can `await` a request-cached function directly. Multiple server components calling the same function will share the result. + +```jsx filename="App.jsx" +import { getRequestData } from "./get-request-data.mjs"; + +async function First() { + const data = await getRequestData(); + return
{JSON.stringify(data)}
; +} + +async function Second() { + const data = await getRequestData(); + return
{JSON.stringify(data)}
; +} + +export default async function App() { + return ( +
+ + +
+ ); +} +``` + +Both `` and `` will render the same data — the function body runs only once. + +### Using in client components + +Client components can also consume request-cached functions using React's `use` hook. The cached value is shared between RSC and SSR environments, so the client component receives the same result that was already computed on the server. + +```jsx filename="ClientDisplay.jsx" +"use client"; + +import { use } from "react"; +import { getRequestData } from "./get-request-data.mjs"; + +export default function ClientDisplay() { + const data = use(getRequestData()); + return ( +
+
{data.timestamp}
+
{data.random}
+
+ ); +} +``` + +### Hydration + +By default, request-cached values are automatically dehydrated into the HTML response and rehydrated on the browser during React hydration. This means client components that consume a request-cached function via `use()` will receive the exact same value that was computed on the server — no recomputation, no hydration mismatch. + +The cached values are serialized using React's RSC Flight protocol, which preserves all RSC-supported types including `Date`, `Map`, `Set`, `RegExp`, `URL`, and more. The serialized data is embedded in an inline `${ + hmr + ? "" + : bootstrapModules + .map( + (mod) => + `` + ) + .join("") + }` + ); + yield script; + hydrated = true; + } else if ( + !hmr && + isDevelopment && + contentLength > 0 && + bootstrapModules.length > 0 + ) { + const script = encoder.encode( + `${bootstrapModules + .map( + (mod) => + `` + ) + .join("")}` + ); + yield script; + hmr = true; + } } - const script = encoder.encode( - `${ - hmr - ? "" - : bootstrapModules - .map( - (mod) => - `` - ) - .join("") - }` - ); - yield script; - hydrated = true; - } else if ( - !hmr && - isDevelopment && - contentLength > 0 && - bootstrapModules.length > 0 - ) { - const script = encoder.encode( - `${bootstrapModules - .map( - (mod) => - `` + _resolve(); + }; + + let process; + const passThrough = (value) => value; + + const importMapScript = ``; + const injectImportMap = (value) => { + const chunk = decoder.decode(value); + if (chunk.includes("]*)>/, + `${importMapScript}` ) - .join("")}` - ); - yield script; - hmr = true; - } - } - - _resolve(); - }; - - let process; - const passThrough = (value) => value; - - const importMapScript = ``; - const injectImportMap = (value) => { - const chunk = decoder.decode(value); - if (chunk.includes("]*)>/, - `${importMapScript}` - ) - ); - } else if (chunk.startsWith(" 0) { - const links = Array.from(linkQueue); - linkQueue.clear(); - for (const link of links) { - if (!linkSent.has(link)) { - linkSent.add(link); - yield encoder.encode( - `` - ); + }; + + process = + typeof importMap === "object" && importMap !== null + ? injectImportMap + : passThrough; + + // ── Incremental request cache hydration injection ── + // Tracks which keys have already been emitted so each + // entry is injected exactly once. Uses Object.assign + // with nullish-coalescing so streamed Suspense chunks + // append to (not overwrite) the global. + const injectedCacheKeys = new Set(); + + /** + * Scan the shared cache for new hydration-eligible + * entries and yield a ` + `` ); } } - const parser = Parser.getFragmentParser(); - for await (const value of htmlWorker()) { - if (tokenize) { - const html = decoder.decode(value); - parser.tokenizer.write(html); + const worker = async function* () { + while (!(forwardDone && htmlDone)) { + for await (const value of forwardWorker()) { + if (!isPrerender) { + yield value; + } + } + + for await (const value of htmlWorker()) { + yield process(value); + } + + if (linkQueue.size > 0) { + const links = Array.from(linkQueue); + linkQueue.clear(); + for (const link of links) { + if (!linkSent.has(link)) { + linkSent.add(link); + yield encoder.encode( + `` + ); + } + } + } + + // Inject any new cache entries that appeared during + // this render cycle (e.g. Suspense boundaries resolving). + yield* flushCacheEntries(); + + if (!started) { + started = true; + parentPort.postMessage({ + id, + start: true, + error: error?.message, + stack: error?.stack, + digest: error?.digest, + }); + } } - } - tokenize = false; - - if (linkQueue.size > 0) { - const links = Array.from(linkQueue); - linkQueue.clear(); - for (const link of links) { - if (!linkSent.has(link)) { - linkSent.add(link); - parser.tokenizer.write( - `` + + // ── Inject remaining request cache entries for browser hydration ── + // Final sweep after all rendering completes. + yield* flushCacheEntries(); + }; + + const remoteWorker = async function* () { + let line = 1; + let tokenize = true; + while (!(forwardDone && htmlDone)) { + for await (const value of forwardWorker()) { + if (hydrated) { + yield encoder.encode( + `` + ); + } + } + + const parser = Parser.getFragmentParser(); + for await (const value of htmlWorker()) { + if (tokenize) { + const html = decoder.decode(value); + parser.tokenizer.write(html); + } + } + tokenize = false; + + if (linkQueue.size > 0) { + const links = Array.from(linkQueue); + linkQueue.clear(); + for (const link of links) { + if (!linkSent.has(link)) { + linkSent.add(link); + parser.tokenizer.write( + `` + ); + } + } + } + + if ( + !defer && + (hasClientComponent || hasServerAction) + ) { + while (bootstrapScripts.length > 0) { + const textContent = bootstrapScripts.shift(); + parser.tokenizer.write( + `` + ); + } + } + + parser.tokenizer.write("", true); + const fragment = parser.getFragment(); + if (fragment.childNodes.length > 0) { + const tree = dom2flight(fragment, { + origin, + defer, + }); + yield encoder.encode( + `${line++}:${JSON.stringify(tree)}\n` ); } - } - } - if (!defer && (hasClientComponent || hasServerAction)) { - while (bootstrapScripts.length > 0) { - const textContent = bootstrapScripts.shift(); - parser.tokenizer.write( - `` - ); + if (!started) { + started = true; + parentPort.postMessage({ + id, + start: true, + error: error?.message, + stack: error?.stack, + digest: error?.digest, + }); + } } - } - parser.tokenizer.write("", true); - const fragment = parser.getFragment(); - if (fragment.childNodes.length > 0) { - const tree = dom2flight(fragment, { origin, defer }); yield encoder.encode( - `${line++}:${JSON.stringify(tree)}\n` + `0:[${Array.from({ length: line - 1 }) + .map((_, i) => `"$${i + 1}"`) + .join(",")}]\n` ); - } - - if (!started) { - started = true; - parentPort.postMessage({ - id, - start: true, - error: error?.message, - stack: error?.stack, - digest: error?.digest, - }); - } - } + }; - yield encoder.encode( - `0:[${Array.from({ length: line - 1 }) - .map((_, i) => `"$${i + 1}"`) - .join(",")}]\n` - ); - }; + const render = async () => { + try { + const iterator = remote ? remoteWorker() : worker(); + for await (const value of iterator) { + controller.enqueue(value); + } - const render = async () => { - try { - const iterator = remote ? remoteWorker() : worker(); - for await (const value of iterator) { - controller.enqueue(value); - } + controller.close(); + parentPort.postMessage({ id, done: true }); + } catch (e) { + parentPort.postMessage({ + id, + done: true, + error: e.message, + stack: e.stack, + digest: e.digest, + }); + } + }; - controller.close(); - parentPort.postMessage({ id, done: true }); - } catch (e) { + render(); + } catch (error) { parentPort.postMessage({ id, done: true, - error: e.message, - stack: e.stack, - digest: e.digest, + error: error.message, + stack: error.stack, + digest: error.digest, }); } - }; - - render(); - } catch (error) { - parentPort.postMessage({ - id, - done: true, - error: error.message, - stack: error.stack, - digest: error.digest, - }); - } - }, - }); - - try { - parentPort.postMessage({ id, stream }, [stream]); - } catch { - // Send the stream data back via the parent port - parentPort.postMessage({ id, stream: true }); - (async () => { - const reader = stream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - parentPort.postMessage({ id, stream: true, value }); + }, + }); + + try { + parentPort.postMessage({ id, stream }, [stream]); + } catch { + // Send the stream data back via the parent port + parentPort.postMessage({ id, stream: true }); + (async () => { + const reader = stream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + parentPort.postMessage({ id, stream: true, value }); + } + })(); } - })(); + } catch (error) { + parentPort.postMessage({ + id, + done: true, + error: error.message, + stack: error.stack, + digest: error.digest, + }); + } } - } catch (error) { - parentPort.postMessage({ - id, - done: true, - error: error.message, - stack: error.stack, - digest: error.digest, - }); - } - } - ); + ); + }); + }); }); }); }; diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx index 6a699deb..19498da8 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -45,6 +45,7 @@ import { RENDER_STREAM, RENDER_TEMPORARY_REFERENCES, RENDER_WAIT, + REQUEST_CACHE_SHARED, RESPONSE_BUFFER, STYLES_CONTEXT, SERVER_FUNCTION_NOT_FOUND, @@ -909,6 +910,10 @@ export async function render(Component, props = {}, options = {}) { origin, importMap, body, + requestCacheBuffer: + getContext(REQUEST_CACHE_SHARED)?.buffer ?? + getContext(REQUEST_CACHE_SHARED) ?? + null, httpContext: { request: { method: context.request.method, diff --git a/packages/react-server/server/request-cache-context.mjs b/packages/react-server/server/request-cache-context.mjs new file mode 100644 index 00000000..775ba674 --- /dev/null +++ b/packages/react-server/server/request-cache-context.mjs @@ -0,0 +1,12 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +// Dedicated ALS for the request cache reader, independent of ContextStorage. +// In Edge mode the main ContextStorage.run() chain can break across bundled +// modules; this standalone ALS guarantees cache modules can always find +// the reader during SSR rendering. +export const RequestCacheStorage = (globalThis.__react_server_request_cache__ = + globalThis.__react_server_request_cache__ || new AsyncLocalStorage()); + +export function getRequestCacheStore() { + return RequestCacheStorage.getStore() ?? null; +} diff --git a/packages/react-server/server/symbols.mjs b/packages/react-server/server/symbols.mjs index 578799a6..661448d2 100644 --- a/packages/react-server/server/symbols.mjs +++ b/packages/react-server/server/symbols.mjs @@ -66,4 +66,6 @@ export const OTEL_METER = Symbol.for("OTEL_METER"); export const OTEL_SPAN = Symbol.for("OTEL_SPAN"); export const OTEL_CONTEXT = Symbol.for("OTEL_CONTEXT"); export const OTEL_SDK = Symbol.for("OTEL_SDK"); +export const REQUEST_CACHE_CONTEXT = Symbol.for("REQUEST_CACHE_CONTEXT"); +export const REQUEST_CACHE_SHARED = Symbol.for("REQUEST_CACHE_SHARED"); export const RESPONSE_BUFFER = Symbol.for("RESPONSE_BUFFER"); diff --git a/packages/rsc/__tests__/flight-sync.test.mjs b/packages/rsc/__tests__/flight-sync.test.mjs new file mode 100644 index 00000000..bada7a05 --- /dev/null +++ b/packages/rsc/__tests__/flight-sync.test.mjs @@ -0,0 +1,617 @@ +/** + * @lazarv/rsc - Sync API Tests (syncToBuffer / syncFromBuffer) + * + * Verifies that syncToBuffer and syncFromBuffer correctly round-trip + * all RSC-supported types synchronously, and that async types + * (Promises, ReadableStream, AsyncIterable) are preserved as + * Promises/wrappers in the deserialized output. + */ + +import { describe, expect, it } from "vitest"; + +import { syncFromBuffer } from "../client/index.mjs"; +import { registerServerReference, syncToBuffer } from "../server/index.mjs"; + +// Helper: round-trip a value through syncToBuffer → syncFromBuffer +function roundTrip(value, serverOptions, clientOptions) { + const buffer = syncToBuffer(value, serverOptions); + return syncFromBuffer(buffer, clientOptions); +} + +// ── Primitives ────────────────────────────────────────────────────── + +describe("syncToBuffer / syncFromBuffer - Primitives", () => { + it("should round-trip null", () => { + expect(roundTrip(null)).toBe(null); + }); + + it("should round-trip undefined", () => { + expect(roundTrip(undefined)).toBeUndefined(); + }); + + it("should round-trip boolean true", () => { + expect(roundTrip(true)).toBe(true); + }); + + it("should round-trip boolean false", () => { + expect(roundTrip(false)).toBe(false); + }); + + it("should round-trip positive integer", () => { + expect(roundTrip(42)).toBe(42); + }); + + it("should round-trip negative integer", () => { + expect(roundTrip(-123)).toBe(-123); + }); + + it("should round-trip zero", () => { + expect(roundTrip(0)).toBe(0); + }); + + it("should round-trip float", () => { + expect(roundTrip(Math.PI)).toBe(Math.PI); + }); + + it("should round-trip NaN", () => { + expect(roundTrip(NaN)).toBeNaN(); + }); + + it("should round-trip Infinity", () => { + expect(roundTrip(Infinity)).toBe(Infinity); + }); + + it("should round-trip -Infinity", () => { + expect(roundTrip(-Infinity)).toBe(-Infinity); + }); + + it("should round-trip -0", () => { + expect(Object.is(roundTrip(-0), -0)).toBe(true); + }); + + it("should round-trip empty string", () => { + expect(roundTrip("")).toBe(""); + }); + + it("should round-trip regular string", () => { + expect(roundTrip("hello world")).toBe("hello world"); + }); + + it("should round-trip string starting with $", () => { + expect(roundTrip("$special")).toBe("$special"); + }); + + it("should round-trip string starting with @", () => { + expect(roundTrip("@mention")).toBe("@mention"); + }); + + it("should round-trip BigInt", () => { + expect(roundTrip(BigInt("9007199254740993"))).toBe( + BigInt("9007199254740993") + ); + }); +}); + +// ── Built-in Types ────────────────────────────────────────────────── + +describe("syncToBuffer / syncFromBuffer - Built-in Types", () => { + it("should round-trip Date and preserve type", () => { + const date = new Date("2026-03-25T12:00:00.000Z"); + const result = roundTrip(date); + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toBe("2026-03-25T12:00:00.000Z"); + }); + + it("should round-trip RegExp", () => { + const regex = /foo.*bar/gi; + const result = roundTrip(regex); + expect(result).toBeInstanceOf(RegExp); + expect(result.source).toBe("foo.*bar"); + expect(result.flags).toBe("gi"); + }); + + it("should round-trip Symbol.for()", () => { + const sym = Symbol.for("my.symbol"); + const result = roundTrip(sym); + expect(result).toBe(Symbol.for("my.symbol")); + }); + + it("should round-trip URL", () => { + const url = new URL("https://example.com/path?q=1#hash"); + const result = roundTrip(url); + expect(result).toBeInstanceOf(URL); + expect(result.href).toBe("https://example.com/path?q=1#hash"); + }); + + it("should round-trip URLSearchParams", () => { + const params = new URLSearchParams(); + params.append("a", "1"); + params.append("b", "2"); + params.append("a", "3"); // duplicate key + const result = roundTrip(params); + expect(result).toBeInstanceOf(URLSearchParams); + expect(result.getAll("a")).toEqual(["1", "3"]); + expect(result.get("b")).toBe("2"); + }); + + it("should round-trip Error", () => { + const err = new Error("something broke"); + const result = roundTrip(err); + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("something broke"); + expect(result.stack).toBeDefined(); + }); + + it("should round-trip TypeError", () => { + const err = new TypeError("bad type"); + const result = roundTrip(err); + expect(result).toBeInstanceOf(TypeError); + expect(result.message).toBe("bad type"); + }); + + it("should round-trip Map", () => { + const map = new Map([ + ["a", 1], + ["b", 2], + [3, "three"], + ]); + const result = roundTrip(map); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(3); + expect(result.get("a")).toBe(1); + expect(result.get("b")).toBe(2); + expect(result.get(3)).toBe("three"); + }); + + it("should round-trip Set", () => { + const set = new Set([1, "two", 3, true]); + const result = roundTrip(set); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(4); + expect(result.has(1)).toBe(true); + expect(result.has("two")).toBe(true); + expect(result.has(3)).toBe(true); + expect(result.has(true)).toBe(true); + }); + + it("should round-trip FormData (no Blobs)", () => { + const form = new FormData(); + form.append("name", "Alice"); + form.append("age", "30"); + const result = roundTrip(form); + expect(result).toBeInstanceOf(FormData); + expect(result.get("name")).toBe("Alice"); + expect(result.get("age")).toBe("30"); + }); +}); + +// ── TypedArrays and ArrayBuffer ───────────────────────────────────── + +describe("syncToBuffer / syncFromBuffer - Binary Types", () => { + it("should round-trip Uint8Array", () => { + const arr = new Uint8Array([1, 2, 3, 4, 5]); + const result = roundTrip(arr); + expect(result).toBeInstanceOf(Uint8Array); + expect(Array.from(result)).toEqual([1, 2, 3, 4, 5]); + }); + + it("should round-trip Int32Array", () => { + const arr = new Int32Array([100, -200, 300]); + const result = roundTrip(arr); + expect(result).toBeInstanceOf(Int32Array); + expect(Array.from(result)).toEqual([100, -200, 300]); + }); + + it("should round-trip Float64Array", () => { + const arr = new Float64Array([1.1, 2.2, 3.3]); + const result = roundTrip(arr); + expect(result).toBeInstanceOf(Float64Array); + expect(Array.from(result)).toEqual([1.1, 2.2, 3.3]); + }); + + it("should round-trip ArrayBuffer", () => { + const buf = new Uint8Array([10, 20, 30]).buffer; + const result = roundTrip(buf); + expect(result).toBeInstanceOf(ArrayBuffer); + expect(Array.from(new Uint8Array(result))).toEqual([10, 20, 30]); + }); + + it("should round-trip empty Uint8Array", () => { + const arr = new Uint8Array([]); + const result = roundTrip(arr); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(0); + }); +}); + +// ── Objects and Arrays ────────────────────────────────────────────── + +describe("syncToBuffer / syncFromBuffer - Objects and Arrays", () => { + it("should round-trip plain object", () => { + const obj = { a: 1, b: "hello", c: true }; + const result = roundTrip(obj); + expect(result).toEqual({ a: 1, b: "hello", c: true }); + }); + + it("should round-trip nested objects", () => { + const obj = { outer: { inner: { deep: 42 } } }; + const result = roundTrip(obj); + expect(result.outer.inner.deep).toBe(42); + }); + + it("should round-trip array", () => { + const arr = [1, "two", true, null]; + const result = roundTrip(arr); + expect(result).toEqual([1, "two", true, null]); + }); + + it("should round-trip nested arrays", () => { + const arr = [ + [1, 2], + [3, [4, 5]], + ]; + const result = roundTrip(arr); + expect(result).toEqual([ + [1, 2], + [3, [4, 5]], + ]); + }); + + it("should round-trip empty object", () => { + expect(roundTrip({})).toEqual({}); + }); + + it("should round-trip empty array", () => { + expect(roundTrip([])).toEqual([]); + }); + + it("should round-trip object with mixed value types", () => { + const obj = { + str: "hello", + num: 42, + bool: false, + nil: null, + undef: undefined, + date: new Date("2026-01-01T00:00:00.000Z"), + regex: /test/i, + bigint: BigInt(123), + sym: Symbol.for("test"), + url: new URL("https://example.com"), + set: new Set([1, 2]), + map: new Map([["k", "v"]]), + }; + const result = roundTrip(obj); + expect(result.str).toBe("hello"); + expect(result.num).toBe(42); + expect(result.bool).toBe(false); + expect(result.nil).toBe(null); + expect(result.undef).toBeUndefined(); + expect(result.date).toBeInstanceOf(Date); + expect(result.date.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(result.regex).toBeInstanceOf(RegExp); + expect(result.regex.source).toBe("test"); + expect(result.bigint).toBe(BigInt(123)); + expect(result.sym).toBe(Symbol.for("test")); + expect(result.url).toBeInstanceOf(URL); + expect(result.set).toBeInstanceOf(Set); + expect(result.set.size).toBe(2); + expect(result.map).toBeInstanceOf(Map); + expect(result.map.get("k")).toBe("v"); + }); +}); + +// ── Map and Set with complex values ───────────────────────────────── + +describe("syncToBuffer / syncFromBuffer - Complex Map/Set", () => { + it("should round-trip Map with Date values", () => { + const map = new Map([ + ["created", new Date("2026-01-01T00:00:00.000Z")], + ["updated", new Date("2026-03-25T12:00:00.000Z")], + ]); + const result = roundTrip(map); + expect(result).toBeInstanceOf(Map); + expect(result.get("created")).toBeInstanceOf(Date); + expect(result.get("updated")).toBeInstanceOf(Date); + expect(result.get("created").toISOString()).toBe( + "2026-01-01T00:00:00.000Z" + ); + }); + + it("should round-trip Set with mixed types", () => { + const set = new Set(["a", 1, true, null]); + const result = roundTrip(set); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(4); + expect(result.has("a")).toBe(true); + expect(result.has(null)).toBe(true); + }); + + it("should round-trip nested Map inside object", () => { + const obj = { + data: new Map([["key", { nested: true }]]), + }; + const result = roundTrip(obj); + expect(result.data).toBeInstanceOf(Map); + expect(result.data.get("key")).toEqual({ nested: true }); + }); +}); + +// ── Async types (remain as Promises) ──────────────────────────────── + +describe("syncToBuffer / syncFromBuffer - Async Types", () => { + it("should serialize a Promise and deserialize as a Promise", async () => { + const value = { data: Promise.resolve(42) }; + const buffer = syncToBuffer(value); + const result = syncFromBuffer(buffer); + // The promise reference becomes a Promise in the output + expect(result.data).toBeDefined(); + expect(typeof result.data.then).toBe("function"); + // The promise resolves asynchronously (the resolution chunk is NOT + // in the sync buffer), so it will remain pending. + // We just verify it's a thenable. + }); + + it("should handle a root-level resolved value (not a Promise)", () => { + const buffer = syncToBuffer("simple"); + const result = syncFromBuffer(buffer); + expect(result).toBe("simple"); + }); +}); + +// ── Server References ─────────────────────────────────────────────── + +describe("syncToBuffer / syncFromBuffer - Server References", () => { + it("should round-trip a server reference", () => { + function myAction() {} + const ref = registerServerReference(myAction, "module", "myAction"); + + const buffer = syncToBuffer(ref); + const result = syncFromBuffer(buffer, { + callServer: () => Promise.resolve(), + }); + + // Server references are deserialized as functions + expect(typeof result).toBe("function"); + }); + + it("should round-trip a server reference with bound args", () => { + function myAction() {} + const ref = registerServerReference(myAction, "module", "myAction"); + const boundRef = ref.bind(null, 1, "two", true); + + const buffer = syncToBuffer(boundRef); + const result = syncFromBuffer(buffer, { + callServer: () => Promise.resolve(), + }); + + expect(typeof result).toBe("function"); + }); +}); + +// ── Edge cases ────────────────────────────────────────────────────── + +describe("syncToBuffer / syncFromBuffer - Edge Cases", () => { + it("should produce a Uint8Array from syncToBuffer", () => { + const buffer = syncToBuffer({ test: true }); + expect(buffer).toBeInstanceOf(Uint8Array); + expect(buffer.length).toBeGreaterThan(0); + }); + + it("should accept ArrayBuffer in syncFromBuffer", () => { + const buffer = syncToBuffer(42); + // Pass as ArrayBuffer instead of Uint8Array + const result = syncFromBuffer(buffer.buffer); + expect(result).toBe(42); + }); + + it("should round-trip deeply nested structure", () => { + const deep = { a: { b: { c: { d: { e: { f: 99 } } } } } }; + const result = roundTrip(deep); + expect(result.a.b.c.d.e.f).toBe(99); + }); + + it("should round-trip large array", () => { + const arr = Array.from({ length: 1000 }, (_, i) => i); + const result = roundTrip(arr); + expect(result).toHaveLength(1000); + expect(result[0]).toBe(0); + expect(result[999]).toBe(999); + }); + + it("should round-trip object with many keys", () => { + const obj = {}; + for (let i = 0; i < 100; i++) { + obj[`key_${i}`] = i; + } + const result = roundTrip(obj); + expect(Object.keys(result)).toHaveLength(100); + expect(result.key_0).toBe(0); + expect(result.key_99).toBe(99); + }); + + it("should round-trip string with unicode", () => { + const str = "Hello 🌍 こんにちは العالم"; + expect(roundTrip(str)).toBe(str); + }); + + it("should round-trip string with newlines and special chars", () => { + const str = "line1\nline2\ttab\r\nwindows"; + expect(roundTrip(str)).toBe(str); + }); + + it("should throw on rejected root chunk", () => { + // Create a buffer with an error row. + // The internal FlightResponse creates a promise that rejects — + // catch it to prevent an unhandled rejection leak. + const errorPayload = `0:E{"message":"test error"}\n`; + const bytes = new TextEncoder().encode(errorPayload); + + let thrown; + try { + syncFromBuffer(bytes); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(thrown.message).toBe("test error"); + + // Swallow the async rejection that leaks from the internal chunk promise + return new Promise((resolve) => setTimeout(resolve, 10)); + }); +}); + +// ── Comparison with JSON (proving RSC serialization preserves types) ─ + +describe("syncToBuffer / syncFromBuffer vs JSON - Type Preservation", () => { + it("Date survives RSC round-trip but not JSON round-trip", () => { + const date = new Date("2026-06-15T10:30:00.000Z"); + + // RSC preserves the Date type + const rscResult = roundTrip(date); + expect(rscResult).toBeInstanceOf(Date); + + // JSON loses the Date type (becomes a string) + const jsonResult = JSON.parse(JSON.stringify(date)); + expect(jsonResult).not.toBeInstanceOf(Date); + expect(typeof jsonResult).toBe("string"); + }); + + it("Map survives RSC round-trip but not JSON round-trip", () => { + const map = new Map([["key", "value"]]); + + // RSC preserves the Map type + const rscResult = roundTrip(map); + expect(rscResult).toBeInstanceOf(Map); + expect(rscResult.get("key")).toBe("value"); + + // JSON loses the Map entirely (becomes empty object) + const jsonResult = JSON.parse(JSON.stringify(map)); + expect(jsonResult).not.toBeInstanceOf(Map); + }); + + it("Set survives RSC round-trip but not JSON round-trip", () => { + const set = new Set([1, 2, 3]); + + // RSC preserves the Set type + const rscResult = roundTrip(set); + expect(rscResult).toBeInstanceOf(Set); + expect(rscResult.size).toBe(3); + + // JSON loses the Set entirely (becomes empty object) + const jsonResult = JSON.parse(JSON.stringify(set)); + expect(jsonResult).not.toBeInstanceOf(Set); + }); + + it("RegExp survives RSC round-trip but not JSON round-trip", () => { + const regex = /test/gi; + + // RSC preserves the RegExp type + const rscResult = roundTrip(regex); + expect(rscResult).toBeInstanceOf(RegExp); + expect(rscResult.source).toBe("test"); + expect(rscResult.flags).toBe("gi"); + + // JSON loses the RegExp (becomes empty object) + const jsonResult = JSON.parse(JSON.stringify(regex)); + expect(jsonResult).not.toBeInstanceOf(RegExp); + }); + + it("BigInt survives RSC round-trip but throws with JSON", () => { + const bigint = BigInt("12345678901234567890"); + + // RSC preserves BigInt + const rscResult = roundTrip(bigint); + expect(rscResult).toBe(bigint); + + // JSON throws on BigInt + expect(() => JSON.stringify(bigint)).toThrow(); + }); + + it("undefined in object survives RSC round-trip but not JSON", () => { + const obj = { a: 1, b: undefined, c: 3 }; + + // RSC preserves undefined values + const rscResult = roundTrip(obj); + expect("b" in rscResult).toBe(true); + expect(rscResult.b).toBeUndefined(); + + // JSON strips undefined values + const jsonResult = JSON.parse(JSON.stringify(obj)); + expect("b" in jsonResult).toBe(false); + }); + + it("NaN survives RSC round-trip but becomes null in JSON", () => { + const obj = { value: NaN }; + + // RSC preserves NaN + const rscResult = roundTrip(obj); + expect(rscResult.value).toBeNaN(); + + // JSON turns NaN into null + const jsonResult = JSON.parse(JSON.stringify(obj)); + expect(jsonResult.value).toBe(null); + }); + + it("-0 survives RSC round-trip but becomes 0 in JSON", () => { + // RSC preserves -0 + const rscResult = roundTrip(-0); + expect(Object.is(rscResult, -0)).toBe(true); + + // JSON loses the sign + const jsonResult = JSON.parse(JSON.stringify(-0)); + expect(Object.is(jsonResult, -0)).toBe(false); + expect(jsonResult).toBe(0); + }); + + it("Infinity survives RSC round-trip but becomes null in JSON", () => { + const obj = { value: Infinity }; + + // RSC preserves Infinity + const rscResult = roundTrip(obj); + expect(rscResult.value).toBe(Infinity); + + // JSON turns Infinity into null + const jsonResult = JSON.parse(JSON.stringify(obj)); + expect(jsonResult.value).toBe(null); + }); + + it("complex nested structure with mixed types preserves all types", () => { + const value = { + users: [ + { + name: "Alice", + joinedAt: new Date("2025-01-01T00:00:00.000Z"), + tags: new Set(["admin", "user"]), + metadata: new Map([ + ["score", 100], + ["level", 5], + ]), + id: BigInt(1), + pattern: /alice/i, + }, + ], + config: { + timeout: Infinity, + delta: -0, + missing: undefined, + invalid: NaN, + }, + }; + + const result = roundTrip(value); + + // Verify all types are preserved + expect(result.users[0].name).toBe("Alice"); + expect(result.users[0].joinedAt).toBeInstanceOf(Date); + expect(result.users[0].tags).toBeInstanceOf(Set); + expect(result.users[0].tags.has("admin")).toBe(true); + expect(result.users[0].metadata).toBeInstanceOf(Map); + expect(result.users[0].metadata.get("score")).toBe(100); + expect(result.users[0].id).toBe(BigInt(1)); + expect(result.users[0].pattern).toBeInstanceOf(RegExp); + expect(result.config.timeout).toBe(Infinity); + expect(Object.is(result.config.delta, -0)).toBe(true); + expect(result.config.missing).toBeUndefined(); + expect(result.config.invalid).toBeNaN(); + }); +}); diff --git a/packages/rsc/client/index.mjs b/packages/rsc/client/index.mjs index c5a62d79..37162bb3 100644 --- a/packages/rsc/client/index.mjs +++ b/packages/rsc/client/index.mjs @@ -13,4 +13,5 @@ export { encodeReply, createServerReference, createTemporaryReferenceSet, + syncFromBuffer, } from "./shared.mjs"; diff --git a/packages/rsc/client/shared.mjs b/packages/rsc/client/shared.mjs index bce9dd33..74c7852b 100644 --- a/packages/rsc/client/shared.mjs +++ b/packages/rsc/client/shared.mjs @@ -2338,3 +2338,69 @@ export function createServerReference(id, callServer) { export function createTemporaryReferenceSet() { return new Map(); } + +/** + * Synchronously deserialize a value from an RSC Flight protocol buffer. + * + * Processes all rows in a single pass — no streams, no async iteration. + * Sync-compatible types (primitives, Date, Map, Set, RegExp, URL, Error, + * TypedArray, plain objects, arrays, React elements, etc.) are returned + * as their concrete values. + * + * Async types remain as Promises in the output value tree: + * - Promise references ($@) → Promise + * - ReadableStream ($r) → ReadableStream (streaming wrapper) + * - AsyncIterable ($i) → AsyncIterable (streaming wrapper) + * - Blob ($B) → Promise + * - Large binary ($b) → Promise + * - Client references ($L) → React.lazy wrapper + * + * The consumer can use React's use() for Promise values or pass them + * to client components for dehydration. + * + * @param {Uint8Array | ArrayBuffer} buffer - The RSC payload buffer + * @param {import('../types').CreateFromReadableStreamOptions} [options] - Options + * @returns {unknown} The deserialized root value (synchronous) + */ +export function syncFromBuffer(buffer, options = {}) { + const response = new FlightResponse(options); + + // Ensure we have a Uint8Array + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + + // Process all data in one shot + response.processData(bytes); + + // Process any remaining binary buffer + if (response.binaryBuffer && response.binaryBuffer.length > 0) { + const line = new TextDecoder().decode(response.binaryBuffer); + response.processLine(line); + response.binaryBuffer = null; + } + + // Resolve all deferred chunks (forward references, path refs, etc.) + response.resolveDeferredChunks(); + + // The root chunk (id 0) should be resolved synchronously now. + // For sync values, chunk.value is the deserialized result. + // For async values nested inside, they remain as Promises in the tree. + const rootChunk = response.rootChunk; + if (rootChunk.status === RESOLVED) { + return rootChunk.value; + } + + // If the root itself is a promise reference, return the promise + if (rootChunk.status === PENDING) { + return rootChunk.promise; + } + + // Rejected — throw the error. + // Suppress the unhandled rejection on the internal chunk promise + // since we are re-throwing synchronously. + if (rootChunk.status === REJECTED) { + rootChunk.promise?.catch?.(() => {}); + throw rootChunk.value; + } + + return rootChunk.value; +} diff --git a/packages/rsc/server/index.mjs b/packages/rsc/server/index.mjs index 69be598f..8b46fe77 100644 --- a/packages/rsc/server/index.mjs +++ b/packages/rsc/server/index.mjs @@ -8,6 +8,7 @@ export { renderToReadableStream, + syncToBuffer, decodeReply, decodeReplyFromAsyncIterable, decodeAction, diff --git a/packages/rsc/server/shared.mjs b/packages/rsc/server/shared.mjs index 77671aee..ab7eaae4 100644 --- a/packages/rsc/server/shared.mjs +++ b/packages/rsc/server/shared.mjs @@ -2597,3 +2597,57 @@ export function logToConsole(request, methodName, args) { request.emitConsoleLog(methodName, args); } } + +/** + * Synchronously serialize a value to a buffer using the RSC Flight protocol. + * + * Unlike renderToReadableStream, this drains all synchronous work immediately + * and returns a Uint8Array. Async types (Promise, ReadableStream, Blob, + * AsyncIterable) are serialized as references ($@, $r, $B, $i) — their + * async data will NOT be included in the buffer; they remain as pending + * chunk references that the consumer sees as Promises after deserialization. + * + * @param {unknown} model - The value to serialize + * @param {import('../types').RenderToReadableStreamOptions} [options] - Options + * @returns {Uint8Array} The serialized RSC payload + */ +export function syncToBuffer(model, options = {}) { + const request = new FlightRequest(model, options); + + // Collect all synchronous output into a byte array instead of + // pushing to a ReadableStream controller. + const chunks = []; + + // Use a fake destination that collects chunks + request.destination = { + enqueue(chunk) { + if (chunk instanceof Uint8Array) { + chunks.push(chunk); + } else { + chunks.push(encoder.encode(chunk)); + } + }, + close() {}, + error() {}, + }; + request.flowing = true; + + // Run serialization synchronously (same as startWork but inline) + startWork(request); + + // Flush any remaining completed chunks + request.flushChunks(); + + // Concatenate all chunks into a single Uint8Array + let totalLength = 0; + for (const chunk of chunks) { + totalLength += chunk.length; + } + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} diff --git a/skills/react-server/SKILL.md b/skills/react-server/SKILL.md index 4419e060..e518776f 100644 --- a/skills/react-server/SKILL.md +++ b/skills/react-server/SKILL.md @@ -42,7 +42,7 @@ These directives go at the top of a file or inside a function body (lexically sc - `"use server"` — Server function (callable from client, receives FormData as first arg) - `"use live"` — Live component (async generator that yields JSX, streams updates via WebSocket) - `"use worker"` — Worker module (offloads to Worker Thread on server, Web Worker on client) -- `"use cache"` — Cached function with options: `"use cache; ttl=200; tags=todos"` or `"use cache; profile=todos"` or `"use cache: file; tags=todos"` +- `"use cache"` — Cached function with options: `"use cache; ttl=200; tags=todos"` or `"use cache; profile=todos"` or `"use cache: file; tags=todos"` or `"use cache: request"` (per-request dedup) or `"use cache: request; no-hydrate"` (no browser hydration) - `"use dynamic"` — Force dynamic rendering (opt out of static/prerender) - `"use static"` — Force static rendering at build time @@ -318,6 +318,48 @@ import { invalidate } from "@lazarv/react-server"; await invalidate(getPosts); ``` +### Built-in cache providers + +- `memory` — default in-memory cache +- `request` — per-request deduplication, shared across RSC and SSR (see below) +- `null` — no-op cache (useful with aliases to disable caching) +- `local` — browser localStorage +- `session` — browser sessionStorage + +### Request-scoped caching + +The `request` provider deduplicates calls within a single HTTP request. The function body runs once — all subsequent calls (including across RSC and SSR environments) return the cached result. Values are automatically dehydrated into the HTML and rehydrated in the browser during React hydration. + +```jsx +async function getPageData() { + "use cache: request"; + return await fetchExpensiveData(); +} + +// Server component — awaits the cached value +async function ServerPart() { + const data = await getPageData(); + return
{data.title}
; +} + +// Client component — uses React's use() with the cached value +"use client"; +import { use } from "react"; +function ClientPart() { + const data = use(getPageData()); + return
{data.title}
; +} +``` + +Disable hydration (don't embed value in HTML) with either syntax: +```jsx +"use cache: request; hydrate=false"; +// or equivalently: +"use cache: request; no-hydrate"; +``` + +When `hydrate=false` / `no-hydrate` is set, SSR deduplication still works but the value is not embedded in the HTML — the client component recomputes it in the browser. + ## Configuration ```js diff --git a/test/__test__/use-cache-request.spec.mjs b/test/__test__/use-cache-request.spec.mjs new file mode 100644 index 00000000..d8632d32 --- /dev/null +++ b/test/__test__/use-cache-request.spec.mjs @@ -0,0 +1,198 @@ +import { hostname, page, server } from "playground/utils"; +import { expect, test } from "vitest"; + +test("use cache request - same value within single request", async () => { + await server("fixtures/use-cache-request.jsx"); + await page.goto(hostname); + + const first = JSON.parse(await page.textContent("#first")); + const second = JSON.parse(await page.textContent("#second")); + + // Both server components should get the same cached value + expect(first.timestamp).toBe(second.timestamp); + expect(first.random).toBe(second.random); + expect(first.computeCount).toBe(second.computeCount); +}); + +test("use cache request - different value across requests", async () => { + await server("fixtures/use-cache-request.jsx"); + + await page.goto(hostname); + const first = JSON.parse(await page.textContent("#first")); + + await page.reload(); + const firstAfterReload = JSON.parse(await page.textContent("#first")); + + // Different requests should get different values (request-scoped, not persistent) + expect(firstAfterReload.random).not.toBe(first.random); +}); + +test("use cache request - client component reads RSC cached value without props", async () => { + await server("fixtures/use-cache-request.jsx"); + + // Fetch raw HTML to inspect SSR output before hydration replaces it. + // In production, hydration is fast enough that page.textContent() may + // return browser-computed values instead of SSR-rendered ones. + const res = await fetch(hostname, { headers: { accept: "text/html" } }); + const html = await res.text(); + + // Extract plain-text values from dedicated SSR-rendered divs. + // Server components render #first-timestamp and #first-random as + // plain numbers (no JSON encoding issues). + const serverTimestamp = html.match( + /
([^<]+)<\/div>/ + ); + const serverRandom = html.match(/
([^<]+)<\/div>/); + const clientTimestamp = html.match( + /
([^<]+)<\/div>/ + ); + const clientRandom = html.match(/
([^<]+)<\/div>/); + + expect(serverTimestamp).not.toBeNull(); + expect(serverRandom).not.toBeNull(); + expect(clientTimestamp).not.toBeNull(); + expect(clientRandom).not.toBeNull(); + + // Client component called the same getRequestData() during SSR + // — no props passed, it imported and called the function directly + // — should get the same value via SharedArrayBuffer + expect(clientTimestamp[1]).toBe(serverTimestamp[1]); + expect(clientRandom[1]).toBe(serverRandom[1]); +}); + +test("use cache request - Date type preserved across RSC/SSR boundary", async () => { + await server("fixtures/use-cache-request.jsx"); + await page.goto(hostname); + + // Server components should see createdAt as a real Date instance + // (RSC serialization preserves Date via $D prefix, unlike JSON.stringify) + const firstType = await page.textContent("#first-type"); + const secondType = await page.textContent("#second-type"); + expect(firstType).toBe("Date"); + expect(secondType).toBe("Date"); + + // Client component receives the value via SharedArrayBuffer + syncFromBuffer + // — Date should be preserved across the cross-thread transfer + const clientType = await page.textContent("#client-type"); + expect(clientType).toBe("Date"); +}); + +test("use cache request - hydrated content matches SSR in browser", async () => { + await server("fixtures/use-cache-request.jsx"); + + // Verify that cache entries are embedded in the HTML for hydration + const res = await fetch(hostname, { headers: { accept: "text/html" } }); + const html = await res.text(); + expect(html).toContain("__react_server_request_cache_entries__"); + + // Load in a real browser — server and client values come from the + // same request, so we can compare them directly after hydration. + await page.goto(hostname); + await page.waitForFunction(() => typeof document !== "undefined"); + + // Server-rendered values (from RSC) + const serverTimestamp = await page.textContent("#first-timestamp"); + const serverRandom = await page.textContent("#first-random"); + + // Client component values (hydrated from the same request cache) + const clientTimestamp = await page.textContent("#client-timestamp"); + const clientRandom = await page.textContent("#client-random"); + + // Both should be identical — the client component used the hydrated + // cache entry instead of recomputing + expect(clientTimestamp).toBe(serverTimestamp); + expect(clientRandom).toBe(serverRandom); + + // Date type should survive hydration + const clientType = await page.textContent("#client-type"); + expect(clientType).toBe("Date"); +}); + +test("use cache request - hydrate=false does not embed cache entries for that function", async () => { + await server("fixtures/use-cache-request-no-hydrate.jsx"); + + // Fetch raw HTML to inspect SSR output + const res = await fetch(hostname, { headers: { accept: "text/html" } }); + const html = await res.text(); + + // Server-rendered values should be present in the HTML + const ssrTimestamp = html.match(/
([^<]+)<\/div>/); + const ssrRandom = html.match(/
([^<]+)<\/div>/); + + expect(ssrTimestamp).not.toBeNull(); + expect(ssrRandom).not.toBeNull(); + + // The SSR-rendered client values should match server values + // (SharedArrayBuffer still works for SSR deduplication) + const ssrClientTimestamp = html.match( + /
([^<]+)<\/div>/ + ); + const ssrClientRandom = html.match(/
([^<]+)<\/div>/); + + expect(ssrClientTimestamp).not.toBeNull(); + expect(ssrClientRandom).not.toBeNull(); + expect(ssrClientTimestamp[1]).toBe(ssrTimestamp[1]); + expect(ssrClientRandom[1]).toBe(ssrRandom[1]); +}); + +test("use cache request - streamed Suspense cache entries hydrate correctly", async () => { + await server("fixtures/use-cache-request-suspense.jsx"); + + // Verify incremental injection uses Object.assign + const res = await fetch(hostname, { headers: { accept: "text/html" } }); + const html = await res.text(); + expect(html).toContain( + "Object.assign(self.__react_server_request_cache_entries__" + ); + + // Load in a real browser — all values from the same request + await page.goto(hostname); + + // Wait for the Suspense boundary to resolve + await page.waitForSelector("#suspense-client"); + + // Eager: server component and client component should share the same value + const eagerTimestamp = await page.textContent("#eager-timestamp"); + const eagerRandom = await page.textContent("#eager-random"); + const clientTimestamp = await page.textContent("#client-timestamp"); + const clientRandom = await page.textContent("#client-random"); + + expect(clientTimestamp).toBe(eagerTimestamp); + expect(clientRandom).toBe(eagerRandom); + + // Delayed (Suspense-streamed): server component and client component + // should share the same value — proving the incrementally-injected + // cache entry was picked up during hydration + const delayedTimestamp = await page.textContent("#delayed-timestamp"); + const delayedRandom = await page.textContent("#delayed-random"); + const suspenseClientTimestamp = await page.textContent( + "#suspense-client-timestamp" + ); + const suspenseClientRandom = await page.textContent( + "#suspense-client-random" + ); + + expect(suspenseClientTimestamp).toBe(delayedTimestamp); + expect(suspenseClientRandom).toBe(delayedRandom); +}); + +test("use cache request - hydrate=false client recomputes on browser", async () => { + await server("fixtures/use-cache-request-no-hydrate.jsx"); + + // Fetch raw HTML to get SSR values + const res = await fetch(hostname, { headers: { accept: "text/html" } }); + const html = await res.text(); + + const ssrTimestamp = html.match(/
([^<]+)<\/div>/); + + // Load in browser — client component should recompute since hydrate=false + // means the cache value is NOT embedded in the HTML + await page.goto(hostname); + await page.waitForFunction(() => typeof document !== "undefined"); + + const clientTimestamp = await page.textContent("#client-timestamp"); + + // With hydrate=false, the browser recomputes the value, so it should differ + // from the SSR value (different timestamp = different computation) + expect(clientTimestamp).not.toBe(ssrTimestamp[1]); +}); diff --git a/test/fixtures/use-cache-request-client.jsx b/test/fixtures/use-cache-request-client.jsx new file mode 100644 index 00000000..21e58722 --- /dev/null +++ b/test/fixtures/use-cache-request-client.jsx @@ -0,0 +1,17 @@ +"use client"; + +import { use } from "react"; +import { getRequestData } from "./use-cache-request-data.mjs"; + +export default function ClientDisplay() { + const data = use(getRequestData()); + return ( +
+
{data.timestamp}
+
{data.random}
+
+ {data.createdAt instanceof Date ? "Date" : typeof data.createdAt} +
+
+ ); +} diff --git a/test/fixtures/use-cache-request-data.mjs b/test/fixtures/use-cache-request-data.mjs new file mode 100644 index 00000000..a747b2b8 --- /dev/null +++ b/test/fixtures/use-cache-request-data.mjs @@ -0,0 +1,41 @@ +let computeCount = 0; + +export async function getRequestData() { + "use cache: request"; + await new Promise((resolve) => setTimeout(resolve, 5)); + computeCount++; + return { + timestamp: Date.now(), + random: Math.random(), + computeCount, + createdAt: new Date(), + }; +} + +let noHydrateCount = 0; + +export async function getNoHydrateData() { + "use cache: request; hydrate=false"; + await new Promise((resolve) => setTimeout(resolve, 5)); + noHydrateCount++; + return { + timestamp: Date.now(), + random: Math.random(), + computeCount: noHydrateCount, + createdAt: new Date(), + }; +} + +let suspenseCount = 0; + +export async function getSuspenseData() { + "use cache: request"; + // Longer delay to ensure this resolves after the initial Suspense shell + await new Promise((resolve) => setTimeout(resolve, 200)); + suspenseCount++; + return { + timestamp: Date.now(), + random: Math.random(), + computeCount: suspenseCount, + }; +} diff --git a/test/fixtures/use-cache-request-no-hydrate-client.jsx b/test/fixtures/use-cache-request-no-hydrate-client.jsx new file mode 100644 index 00000000..8ae0f106 --- /dev/null +++ b/test/fixtures/use-cache-request-no-hydrate-client.jsx @@ -0,0 +1,17 @@ +"use client"; + +import { use } from "react"; +import { getNoHydrateData } from "./use-cache-request-data.mjs"; + +export default function NoHydrateClient() { + const data = use(getNoHydrateData()); + return ( +
+
{data.timestamp}
+
{data.random}
+
+ {data.createdAt instanceof Date ? "Date" : typeof data.createdAt} +
+
+ ); +} diff --git a/test/fixtures/use-cache-request-no-hydrate.jsx b/test/fixtures/use-cache-request-no-hydrate.jsx new file mode 100644 index 00000000..0c6f5310 --- /dev/null +++ b/test/fixtures/use-cache-request-no-hydrate.jsx @@ -0,0 +1,24 @@ +import { getNoHydrateData } from "./use-cache-request-data.mjs"; +import NoHydrateClient from "./use-cache-request-no-hydrate-client.jsx"; + +async function ServerDisplay() { + const data = await getNoHydrateData(); + return ( + <> +
{data.timestamp}
+
{data.random}
+
+ {data.createdAt instanceof Date ? "Date" : typeof data.createdAt} +
+ + ); +} + +export default async function App() { + return ( +
+ + +
+ ); +} diff --git a/test/fixtures/use-cache-request-suspense-client.jsx b/test/fixtures/use-cache-request-suspense-client.jsx new file mode 100644 index 00000000..44890982 --- /dev/null +++ b/test/fixtures/use-cache-request-suspense-client.jsx @@ -0,0 +1,14 @@ +"use client"; + +import { use } from "react"; +import { getSuspenseData } from "./use-cache-request-data.mjs"; + +export default function SuspenseClientDisplay() { + const data = use(getSuspenseData()); + return ( +
+
{data.timestamp}
+
{data.random}
+
+ ); +} diff --git a/test/fixtures/use-cache-request-suspense.jsx b/test/fixtures/use-cache-request-suspense.jsx new file mode 100644 index 00000000..2b866eb0 --- /dev/null +++ b/test/fixtures/use-cache-request-suspense.jsx @@ -0,0 +1,37 @@ +import { Suspense } from "react"; +import { getRequestData, getSuspenseData } from "./use-cache-request-data.mjs"; +import ClientDisplay from "./use-cache-request-client.jsx"; +import SuspenseClientDisplay from "./use-cache-request-suspense-client.jsx"; + +async function Eager() { + const data = await getRequestData(); + return ( + <> +
{data.timestamp}
+
{data.random}
+ + ); +} + +async function Delayed() { + const data = await getSuspenseData(); + return ( + <> +
{data.timestamp}
+
{data.random}
+ + + ); +} + +export default async function App() { + return ( +
+ + + Loading...
}> + + +
+ ); +} diff --git a/test/fixtures/use-cache-request.jsx b/test/fixtures/use-cache-request.jsx new file mode 100644 index 00000000..5c5366e4 --- /dev/null +++ b/test/fixtures/use-cache-request.jsx @@ -0,0 +1,38 @@ +import { getRequestData } from "./use-cache-request-data.mjs"; +import ClientDisplay from "./use-cache-request-client.jsx"; + +async function First() { + const data = await getRequestData(); + return ( + <> +
{JSON.stringify(data)}
+
{data.timestamp}
+
{data.random}
+
+ {data.createdAt instanceof Date ? "Date" : typeof data.createdAt} +
+ + ); +} + +async function Second() { + const data = await getRequestData(); + return ( + <> +
{JSON.stringify(data)}
+
+ {data.createdAt instanceof Date ? "Date" : typeof data.createdAt} +
+ + ); +} + +export default async function App() { + return ( +
+ + + +
+ ); +}