```
- Input: an `Effect`-returning function
@@ -114,7 +118,7 @@ declare const reactCache: >(
- Internally uses `react/cache` to memoize by the argument tuple.
- For each unique args tuple, the first evaluation creates a single promise that is reused by all subsequent calls (including concurrent calls).
-- The `Effect` context (`R`) is captured at call time, but for a given args tuple the first successful or failed promise is reused for the lifetime of the process.
+- The `Effect` context (`R`) is captured at call time, but for a given args tuple the first completed `Exit` is reused for the lifetime of the current React request/render cache.
### Important behaviors
@@ -138,7 +142,7 @@ declare const reactCache: >(
## Testing
-When running tests outside a React runtime, you may want to mock `react`’s `cache` to ensure deterministic, in-memory memoization:
+When running tests outside a React server render, you may want to mock `react`’s `cache` to ensure deterministic, in-memory memoization. React’s default non-server build treats `cache` as a passthrough, so plain `Effect.runPromise(...)` calls will not memoize on their own. A simple primitive-oriented mock looks like this:
```ts
import { vi } from "vitest"
@@ -159,14 +163,14 @@ vi.mock("react", () => {
})
```
-See `test/ReactCache.test.ts` for examples covering caching, argument sensitivity, context provisioning, and concurrency.
+See `test/ReactCache.test.ts` for a more faithful identity-based mock and examples covering caching, argument sensitivity, context provisioning, and concurrency.
## Caveats and tips
-- The cache is keyed by the argument tuple using React’s semantics. Prefer using primitives or stable/serializable values as arguments.
+- The cache is keyed by the argument tuple using React’s semantics. Prefer primitives or stable object identities as arguments.
- Since the first outcome is cached, design your effects such that this is acceptable for your use case. For context-sensitive computations, include discriminators in the argument list.
-- This library is designed for server-side usage (e.g., React Server Components / server actions) where React’s `cache` is meaningful.
+- This library is designed for React Server Components. Outside a React server render, `react`’s default `cache` implementation is effectively a passthrough.
## Works with Next.js
-You can use this library together with [@mcrovero/effect-nextjs](https://www.npmjs.com/package/@mcrovero/effect-nextjs) to cache `Effect`-based functions between Next.js pages, layouts, and server components.
+You can use this library together with [@mcrovero/effect-nextjs](https://www.npmjs.com/package/@mcrovero/effect-nextjs) to deduplicate `Effect`-based functions within the same Next.js server render across pages, layouts, and server components.
diff --git a/fixtures/next-app/app/api/no-react-cache/route.ts b/fixtures/next-app/app/api/no-react-cache/route.ts
new file mode 100644
index 0000000..07a969d
--- /dev/null
+++ b/fixtures/next-app/app/api/no-react-cache/route.ts
@@ -0,0 +1,15 @@
+import { Effect } from "effect"
+import { cachedRouteHandler, getRouteExecutions } from "../../../lib/cache-fixture"
+
+export const dynamic = "force-dynamic"
+
+export async function GET() {
+ const first = await Effect.runPromise(cachedRouteHandler("route"))
+ const second = await Effect.runPromise(cachedRouteHandler("route"))
+
+ return Response.json({
+ first,
+ second,
+ executions: getRouteExecutions()
+ })
+}
diff --git a/fixtures/next-app/app/behaviors/page.tsx b/fixtures/next-app/app/behaviors/page.tsx
new file mode 100644
index 0000000..8780589
--- /dev/null
+++ b/fixtures/next-app/app/behaviors/page.tsx
@@ -0,0 +1,143 @@
+import { Effect } from "effect"
+import {
+ Random,
+ cachedComposedCause,
+ cachedConcurrent,
+ cachedContext,
+ cachedDifferentArgs,
+ cachedErrorDifferentArgs,
+ cachedErrorSameArgs,
+ cachedFalsyError,
+ cachedFalsySuccess,
+ cachedIdentity,
+ cachedSameArgs,
+ cachedSpan,
+ failuresFromExit,
+ getComposedCauseExecutions,
+ getConcurrentExecutions,
+ getContextExecutions,
+ getDifferentArgsExecutions,
+ getErrorDifferentArgsExecutions,
+ getErrorSameArgsExecutions,
+ getFalsyErrorExecutions,
+ getFalsySuccessExecutions,
+ getIdentityExecutions,
+ getSameArgsExecutions,
+ getSpanExecutions,
+ prettyCauseFromExit
+} from "../../lib/cache-fixture"
+
+export const dynamic = "force-dynamic"
+
+export default async function BehaviorsPage() {
+ const sameFirst = await Effect.runPromise(cachedSameArgs("same"))
+ const sameSecond = await Effect.runPromise(cachedSameArgs("same"))
+
+ const differentFirst = await Effect.runPromise(cachedDifferentArgs("a"))
+ const differentSecond = await Effect.runPromise(cachedDifferentArgs("b"))
+
+ const [concurrentFirst, concurrentSecond] = await Promise.all([
+ Effect.runPromise(cachedConcurrent("shared")),
+ Effect.runPromise(cachedConcurrent("shared"))
+ ])
+
+ const contextFirst = await Effect.runPromise(
+ cachedContext().pipe(
+ Effect.provideService(Random, {
+ next: Effect.succeed(111)
+ })
+ )
+ )
+ const contextSecond = await Effect.runPromise(
+ cachedContext().pipe(
+ Effect.provideService(Random, {
+ next: Effect.succeed(222)
+ })
+ )
+ )
+
+ const spanFirst = await Effect.runPromise(
+ cachedSpan().pipe(Effect.withSpan("outer-span"))
+ )
+ const spanSecond = await Effect.runPromise(
+ cachedSpan().pipe(Effect.withSpan("inner-span"))
+ )
+
+ const falsySuccessFirst = await Effect.runPromise(cachedFalsySuccess())
+ const falsySuccessSecond = await Effect.runPromise(cachedFalsySuccess())
+
+ const errorSameArgsFirst = await Effect.runPromiseExit(cachedErrorSameArgs("x"))
+ const errorSameArgsSecond = await Effect.runPromiseExit(cachedErrorSameArgs("x"))
+
+ const errorDifferentArgsFirst = await Effect.runPromiseExit(cachedErrorDifferentArgs("a"))
+ const errorDifferentArgsSecond = await Effect.runPromiseExit(cachedErrorDifferentArgs("b"))
+
+ const falsyError = await Effect.runPromiseExit(cachedFalsyError())
+ const composedCause = await Effect.runPromiseExit(cachedComposedCause())
+
+ const stableInput = { id: "stable" }
+ const identityStableFirst = await Effect.runPromise(cachedIdentity(stableInput))
+ const identityStableSecond = await Effect.runPromise(cachedIdentity(stableInput))
+ const identityFreshFirst = await Effect.runPromise(cachedIdentity({ id: "fresh" }))
+ const identityFreshSecond = await Effect.runPromise(cachedIdentity({ id: "fresh" }))
+
+ const payload = {
+ sameArgs: {
+ first: sameFirst,
+ second: sameSecond,
+ executions: getSameArgsExecutions()
+ },
+ differentArgs: {
+ first: differentFirst,
+ second: differentSecond,
+ executions: getDifferentArgsExecutions()
+ },
+ concurrent: {
+ first: concurrentFirst,
+ second: concurrentSecond,
+ executions: getConcurrentExecutions()
+ },
+ context: {
+ first: contextFirst,
+ second: contextSecond,
+ executions: getContextExecutions()
+ },
+ span: {
+ first: spanFirst,
+ second: spanSecond,
+ executions: getSpanExecutions()
+ },
+ falsySuccess: {
+ first: falsySuccessFirst,
+ second: falsySuccessSecond,
+ executions: getFalsySuccessExecutions()
+ },
+ errorSameArgs: {
+ first: failuresFromExit(errorSameArgsFirst),
+ second: failuresFromExit(errorSameArgsSecond),
+ executions: getErrorSameArgsExecutions()
+ },
+ errorDifferentArgs: {
+ first: failuresFromExit(errorDifferentArgsFirst),
+ second: failuresFromExit(errorDifferentArgsSecond),
+ executions: getErrorDifferentArgsExecutions()
+ },
+ falsyError: {
+ failures: failuresFromExit(falsyError),
+ executions: getFalsyErrorExecutions()
+ },
+ composedCause: {
+ pretty: prettyCauseFromExit(composedCause),
+ executions: getComposedCauseExecutions()
+ },
+ identity: {
+ stableFirst: identityStableFirst,
+ stableSecond: identityStableSecond,
+ freshFirst: identityFreshFirst,
+ freshSecond: identityFreshSecond,
+ executions: getIdentityExecutions()
+ }
+ }
+
+ return {JSON.stringify(payload)}
+}
diff --git a/fixtures/next-app/app/layout.tsx b/fixtures/next-app/app/layout.tsx
new file mode 100644
index 0000000..17c52d5
--- /dev/null
+++ b/fixtures/next-app/app/layout.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from "react"
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/fixtures/next-app/app/page.tsx b/fixtures/next-app/app/page.tsx
new file mode 100644
index 0000000..b8aa328
--- /dev/null
+++ b/fixtures/next-app/app/page.tsx
@@ -0,0 +1,3 @@
+export default function HomePage() {
+ return effect-react-cache Next integration fixture
+}
diff --git a/fixtures/next-app/app/request-isolation/page.tsx b/fixtures/next-app/app/request-isolation/page.tsx
new file mode 100644
index 0000000..4b869c1
--- /dev/null
+++ b/fixtures/next-app/app/request-isolation/page.tsx
@@ -0,0 +1,22 @@
+import { Effect } from "effect"
+import {
+ cachedRequestScoped,
+ getRequestExecutions
+} from "../../lib/cache-fixture"
+
+export const dynamic = "force-dynamic"
+
+export default async function RequestIsolationPage() {
+ const first = await Effect.runPromise(cachedRequestScoped("request"))
+ const second = await Effect.runPromise(cachedRequestScoped("request"))
+
+ return (
+
+ {JSON.stringify({
+ first,
+ second,
+ executions: getRequestExecutions()
+ })}
+
+ )
+}
diff --git a/fixtures/next-app/app/tree/layout.tsx b/fixtures/next-app/app/tree/layout.tsx
new file mode 100644
index 0000000..5dfd619
--- /dev/null
+++ b/fixtures/next-app/app/tree/layout.tsx
@@ -0,0 +1,16 @@
+import type { ReactNode } from "react"
+import { Effect } from "effect"
+import { cachedLayoutPage } from "../../lib/cache-fixture"
+
+export const dynamic = "force-dynamic"
+
+export default async function TreeLayout({ children }: { children: ReactNode }) {
+ const payload = await Effect.runPromise(cachedLayoutPage("layout-page"))
+
+ return (
+ <>
+ {JSON.stringify(payload)}
+ {children}
+ >
+ )
+}
diff --git a/fixtures/next-app/app/tree/page.tsx b/fixtures/next-app/app/tree/page.tsx
new file mode 100644
index 0000000..64c8936
--- /dev/null
+++ b/fixtures/next-app/app/tree/page.tsx
@@ -0,0 +1,10 @@
+import { Effect } from "effect"
+import { cachedLayoutPage } from "../../lib/cache-fixture"
+
+export const dynamic = "force-dynamic"
+
+export default async function TreePage() {
+ const payload = await Effect.runPromise(cachedLayoutPage("layout-page"))
+
+ return {JSON.stringify(payload)}
+}
diff --git a/fixtures/next-app/lib/cache-fixture.ts b/fixtures/next-app/lib/cache-fixture.ts
new file mode 100644
index 0000000..5db8b6a
--- /dev/null
+++ b/fixtures/next-app/lib/cache-fixture.ts
@@ -0,0 +1,149 @@
+import { Cause, Chunk, Context, Effect, Exit } from "effect"
+import { reactCache as reactCacheUnsafe } from "@mcrovero/effect-react-cache/ReactCache"
+
+const reactCache = reactCacheUnsafe as any
+
+export class Random extends Context.Tag("fixtures/next-app/Random")<
+ Random,
+ { readonly next: Effect.Effect }
+>() {}
+
+type CountedResult = {
+ readonly id: string
+ readonly run: number
+}
+
+type ContextResult = {
+ readonly run: number
+ readonly value: number
+}
+
+type SpanResult = {
+ readonly run: number
+ readonly span: string
+}
+
+let sameArgsExecutions = 0
+let differentArgsExecutions = 0
+let concurrentExecutions = 0
+let contextExecutions = 0
+let spanExecutions = 0
+let falsySuccessExecutions = 0
+let errorSameArgsExecutions = 0
+let errorDifferentArgsExecutions = 0
+let falsyErrorExecutions = 0
+let composedCauseExecutions = 0
+let identityExecutions = 0
+let requestExecutions = 0
+let layoutExecutions = 0
+let routeExecutions = 0
+
+export const cachedSameArgs = reactCache((id: string) =>
+ Effect.sync(() => {
+ sameArgsExecutions += 1
+ return { id, run: sameArgsExecutions }
+ })) as (id: string) => Effect.Effect
+
+export const cachedDifferentArgs = reactCache((id: string) =>
+ Effect.sync(() => {
+ differentArgsExecutions += 1
+ return { id, run: differentArgsExecutions }
+ })) as (id: string) => Effect.Effect
+
+export const cachedConcurrent = reactCache((id: string) =>
+ Effect.gen(function*() {
+ concurrentExecutions += 1
+ yield* Effect.sleep(20)
+ return { id, run: concurrentExecutions }
+ })) as (id: string) => Effect.Effect
+
+export const cachedContext = reactCache(() =>
+ Effect.gen(function*() {
+ contextExecutions += 1
+ const random = yield* Random
+ const value = yield* random.next
+ return { run: contextExecutions, value }
+ })) as () => Effect.Effect
+
+export const cachedSpan = reactCache(() =>
+ Effect.gen(function*() {
+ spanExecutions += 1
+ const span = yield* Effect.currentSpan
+ return { run: spanExecutions, span: span.name }
+ })) as () => Effect.Effect
+
+export const cachedFalsySuccess = reactCache(() =>
+ Effect.sync(() => {
+ falsySuccessExecutions += 1
+ return false
+ })) as () => Effect.Effect
+
+export const cachedErrorSameArgs = reactCache((id: string) =>
+ Effect.gen(function*() {
+ errorSameArgsExecutions += 1
+ return yield* Effect.fail(`boom:${id}` as const)
+ })) as (id: string) => Effect.Effect
+
+export const cachedErrorDifferentArgs = reactCache((id: string) =>
+ Effect.gen(function*() {
+ errorDifferentArgsExecutions += 1
+ return yield* Effect.fail(`boom:${id}` as const)
+ })) as (id: string) => Effect.Effect
+
+export const cachedFalsyError = reactCache(() =>
+ Effect.gen(function*() {
+ falsyErrorExecutions += 1
+ return yield* Effect.fail("" as const)
+ })) as () => Effect.Effect
+
+export const cachedComposedCause = reactCache(() =>
+ Effect.gen(function*() {
+ composedCauseExecutions += 1
+ return yield* Effect.failCause(Cause.sequential(Cause.fail("boom"), Cause.fail("cleanup")))
+ })) as () => Effect.Effect
+
+export const cachedIdentity = reactCache(
+ (input: { readonly id: string }) =>
+ Effect.sync(() => {
+ identityExecutions += 1
+ return { id: input.id, run: identityExecutions }
+ })
+) as (input: { readonly id: string }) => Effect.Effect
+
+export const cachedRequestScoped = reactCache((id: string) =>
+ Effect.sync(() => {
+ requestExecutions += 1
+ return { id, run: requestExecutions }
+ })) as (id: string) => Effect.Effect
+
+export const cachedLayoutPage = reactCache((id: string) =>
+ Effect.sync(() => {
+ layoutExecutions += 1
+ return { id, run: layoutExecutions }
+ })) as (id: string) => Effect.Effect
+
+export const cachedRouteHandler = reactCache((id: string) =>
+ Effect.sync(() => {
+ routeExecutions += 1
+ return { id, run: routeExecutions }
+ })) as (id: string) => Effect.Effect
+
+export const getSameArgsExecutions = () => sameArgsExecutions
+export const getDifferentArgsExecutions = () => differentArgsExecutions
+export const getConcurrentExecutions = () => concurrentExecutions
+export const getContextExecutions = () => contextExecutions
+export const getSpanExecutions = () => spanExecutions
+export const getFalsySuccessExecutions = () => falsySuccessExecutions
+export const getErrorSameArgsExecutions = () => errorSameArgsExecutions
+export const getErrorDifferentArgsExecutions = () => errorDifferentArgsExecutions
+export const getFalsyErrorExecutions = () => falsyErrorExecutions
+export const getComposedCauseExecutions = () => composedCauseExecutions
+export const getIdentityExecutions = () => identityExecutions
+export const getRequestExecutions = () => requestExecutions
+export const getRouteExecutions = () => routeExecutions
+
+export const failuresFromExit = (exit: Exit.Exit) =>
+ Exit.isFailure(exit) ? Chunk.toReadonlyArray(Cause.failures(exit.cause)) : []
+
+export const prettyCauseFromExit = (exit: Exit.Exit) =>
+ Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""
diff --git a/fixtures/next-app/next-env.d.ts b/fixtures/next-app/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/fixtures/next-app/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/fixtures/next-app/next.config.ts b/fixtures/next-app/next.config.ts
new file mode 100644
index 0000000..0c985c2
--- /dev/null
+++ b/fixtures/next-app/next.config.ts
@@ -0,0 +1,11 @@
+import * as path from "node:path"
+import type { NextConfig } from "next"
+
+const nextConfig: NextConfig = {
+ eslint: {
+ ignoreDuringBuilds: true
+ },
+ outputFileTracingRoot: path.join(process.cwd(), "../..")
+}
+
+export default nextConfig
diff --git a/fixtures/next-app/package.json b/fixtures/next-app/package.json
new file mode 100644
index 0000000..c94dc53
--- /dev/null
+++ b/fixtures/next-app/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "private-effect-react-cache-next-fixture",
+ "private": true,
+ "type": "module",
+ "packageManager": "pnpm@9.10.0",
+ "scripts": {
+ "build": "next build",
+ "start": "next start",
+ "dev": "next dev"
+ },
+ "dependencies": {
+ "effect": "^3.17.7",
+ "next": "15.5.4",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@mcrovero/effect-react-cache": "link:../../dist",
+ "@types/react": "^19.2.2",
+ "@types/react-dom": "^19.2.2"
+ }
+}
\ No newline at end of file
diff --git a/fixtures/next-app/tsconfig.json b/fixtures/next-app/tsconfig.json
new file mode 100644
index 0000000..423f708
--- /dev/null
+++ b/fixtures/next-app/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }]
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/integration/next-app.test.ts b/integration/next-app.test.ts
new file mode 100644
index 0000000..fc911f4
--- /dev/null
+++ b/integration/next-app.test.ts
@@ -0,0 +1,260 @@
+import { spawn } from "node:child_process"
+import type { ChildProcessWithoutNullStreams } from "node:child_process"
+import { existsSync } from "node:fs"
+import { once } from "node:events"
+import { createServer } from "node:net"
+import * as path from "node:path"
+import { fileURLToPath } from "node:url"
+import { afterAll, beforeAll, describe, expect, it } from "vitest"
+
+const currentDir = path.dirname(fileURLToPath(import.meta.url))
+const fixtureDir = path.join(currentDir, "..", "fixtures", "next-app")
+
+const decodeHtml = (value: string) =>
+ value
+ .replaceAll(""", "\"")
+ .replaceAll("'", "'")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("&", "&")
+
+const extractPayload = (html: string, id = "payload") => {
+ const match = html.match(new RegExp(`([\\s\\S]*?)<\\/pre>`))
+ if (!match) {
+ throw new Error(`Could not find payload "${id}" in HTML:\n${html}`)
+ }
+ return JSON.parse(decodeHtml(match[1]))
+}
+
+const getAvailablePort = () =>
+ new Promise((resolve, reject) => {
+ const server = createServer()
+ server.once("error", reject)
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address()
+ if (address === null || typeof address === "string") {
+ server.close(() => reject(new Error("Could not allocate a local port")))
+ return
+ }
+ const { port } = address
+ server.close((error) => error ? reject(error) : resolve(port))
+ })
+ })
+
+type RunResult = {
+ stdout: string
+ stderr: string
+}
+
+const runCommand = (cwd: string, args: ReadonlyArray) =>
+ new Promise((resolve, reject) => {
+ const child = spawn("pnpm", args, {
+ cwd,
+ env: {
+ ...process.env,
+ CI: "true",
+ NEXT_TELEMETRY_DISABLED: "1"
+ },
+ stdio: "pipe"
+ })
+
+ let stdout = ""
+ let stderr = ""
+
+ child.stdout.on("data", (chunk: Buffer | string) => {
+ stdout += chunk.toString()
+ })
+ child.stderr.on("data", (chunk: Buffer | string) => {
+ stderr += chunk.toString()
+ })
+ child.on("error", reject)
+ child.on("exit", (code) => {
+ if (code === 0) {
+ resolve({ stdout, stderr })
+ return
+ }
+ reject(new Error(`pnpm ${args.join(" ")} failed with code ${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
+ })
+ })
+
+const waitForExit = async(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals) => {
+ child.kill(signal)
+ const timer = setTimeout(() => {
+ if (!child.killed) {
+ child.kill("SIGKILL")
+ }
+ }, 5_000)
+ try {
+ await once(child, "exit")
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+const waitForServer = async(baseUrl: string, child: ChildProcessWithoutNullStreams, output: () => string) => {
+ const deadline = Date.now() + 30_000
+ while (Date.now() < deadline) {
+ if (child.exitCode !== null) {
+ throw new Error(`Next server exited early with code ${child.exitCode}\n${output()}`)
+ }
+ try {
+ const response = await fetch(baseUrl)
+ await response.text()
+ return
+ } catch {
+ await new Promise((resolve) => setTimeout(resolve, 250))
+ }
+ }
+
+ throw new Error(`Timed out waiting for Next server at ${baseUrl}\n${output()}`)
+}
+
+const ensureFixtureInstalled = async() => {
+ if (existsSync(path.join(fixtureDir, "node_modules"))) {
+ return
+ }
+ await runCommand(fixtureDir, ["install", "--no-frozen-lockfile"])
+}
+
+describe("real Next.js integration", () => {
+ let server: ChildProcessWithoutNullStreams | undefined
+ let port = 0
+ let baseUrl = ""
+ let serverOutput = ""
+
+ beforeAll(async () => {
+ await ensureFixtureInstalled()
+ await runCommand(fixtureDir, ["exec", "next", "build"])
+
+ port = await getAvailablePort()
+ baseUrl = `http://127.0.0.1:${port}`
+
+ server = spawn("pnpm", ["exec", "next", "start", "-p", String(port), "-H", "127.0.0.1"], {
+ cwd: fixtureDir,
+ env: {
+ ...process.env,
+ CI: "true",
+ NEXT_TELEMETRY_DISABLED: "1"
+ },
+ stdio: "pipe"
+ })
+
+ server.stdout.on("data", (chunk: Buffer | string) => {
+ serverOutput += chunk.toString()
+ })
+ server.stderr.on("data", (chunk: Buffer | string) => {
+ serverOutput += chunk.toString()
+ })
+
+ await waitForServer(baseUrl, server, () => serverOutput)
+ })
+
+ afterAll(async () => {
+ if (server && server.exitCode === null) {
+ await waitForExit(server, "SIGTERM")
+ }
+ })
+
+ it("preserves reactCache behavior during a real server render", async () => {
+ const response = await fetch(`${baseUrl}/behaviors`)
+
+ expect(response.status).toBe(200)
+
+ const payload = extractPayload(await response.text())
+
+ expect(payload.sameArgs).toEqual({
+ first: { id: "same", run: 1 },
+ second: { id: "same", run: 1 },
+ executions: 1
+ })
+ expect(payload.differentArgs).toEqual({
+ first: { id: "a", run: 1 },
+ second: { id: "b", run: 2 },
+ executions: 2
+ })
+ expect(payload.concurrent).toEqual({
+ first: { id: "shared", run: 1 },
+ second: { id: "shared", run: 1 },
+ executions: 1
+ })
+ expect(payload.context).toEqual({
+ first: { run: 1, value: 111 },
+ second: { run: 1, value: 111 },
+ executions: 1
+ })
+ expect(payload.span).toEqual({
+ first: { run: 1, span: "outer-span" },
+ second: { run: 1, span: "outer-span" },
+ executions: 1
+ })
+ expect(payload.falsySuccess).toEqual({
+ first: false,
+ second: false,
+ executions: 1
+ })
+ expect(payload.errorSameArgs).toEqual({
+ first: ["boom:x"],
+ second: ["boom:x"],
+ executions: 1
+ })
+ expect(payload.errorDifferentArgs).toEqual({
+ first: ["boom:a"],
+ second: ["boom:b"],
+ executions: 2
+ })
+ expect(payload.falsyError).toEqual({
+ failures: [""],
+ executions: 1
+ })
+ expect(payload.composedCause.executions).toBe(1)
+ expect(payload.composedCause.pretty).toContain("boom")
+ expect(payload.composedCause.pretty).toContain("cleanup")
+ expect(payload.identity).toEqual({
+ stableFirst: { id: "stable", run: 1 },
+ stableSecond: { id: "stable", run: 1 },
+ freshFirst: { id: "fresh", run: 2 },
+ freshSecond: { id: "fresh", run: 3 },
+ executions: 3
+ })
+ })
+
+ it("isolates the cache between separate requests", async () => {
+ const first = await fetch(`${baseUrl}/request-isolation`)
+ const second = await fetch(`${baseUrl}/request-isolation`)
+
+ expect(first.status).toBe(200)
+ expect(second.status).toBe(200)
+
+ expect(extractPayload(await first.text())).toEqual({
+ first: { id: "request", run: 1 },
+ second: { id: "request", run: 1 },
+ executions: 1
+ })
+ expect(extractPayload(await second.text())).toEqual({
+ first: { id: "request", run: 2 },
+ second: { id: "request", run: 2 },
+ executions: 2
+ })
+ })
+
+ it("deduplicates shared work between a nested layout and page", async () => {
+ const response = await fetch(`${baseUrl}/tree`)
+
+ expect(response.status).toBe(200)
+
+ const html = await response.text()
+ expect(extractPayload(html, "layout-payload")).toEqual({ id: "layout-page", run: 1 })
+ expect(extractPayload(html, "page-payload")).toEqual({ id: "layout-page", run: 1 })
+ })
+
+ it("does not memoize in a route handler outside a React server render", async () => {
+ const response = await fetch(`${baseUrl}/api/no-react-cache`)
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({
+ first: { id: "route", run: 1 },
+ second: { id: "route", run: 2 },
+ executions: 2
+ })
+ })
+})
diff --git a/package.json b/package.json
index 9958d51..ed2ae59 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"lint": "eslint \"**/{src,test,examples,scripts,dtslint}/**/*.{ts,mjs}\"",
"lint-fix": "pnpm lint --fix",
"test": "vitest",
+ "test:next-integration": "pnpm build && vitest --config vitest.next.config.ts",
"coverage": "vitest --coverage",
"changeset-version": "changeset version",
"changeset-publish": "pnpm build && TEST_DIST= pnpm vitest && changeset publish"
@@ -80,4 +81,4 @@
"babel-plugin-annotate-pure-calls@0.4.0": "patches/babel-plugin-annotate-pure-calls@0.4.0.patch"
}
}
-}
\ No newline at end of file
+}
diff --git a/src/ReactCache.ts b/src/ReactCache.ts
index 9a0148a..7380238 100644
--- a/src/ReactCache.ts
+++ b/src/ReactCache.ts
@@ -1,17 +1,14 @@
-import { Cause, Effect, Exit, Runtime } from "effect"
+import { Effect, Exit, Runtime } from "effect"
import type * as Scope from "effect/Scope"
import { cache } from "react"
-type CauseResult = {
- error: E | undefined
- defect: unknown
-}
+type EffectFn = (...args: Array) => Effect.Effect
-type PromiseResult = {
- success: A | undefined
- error: E | undefined
- defect: unknown
-}
+type SuccessOf = ReturnType extends Effect.Effect ? A : never
+
+type ErrorOf = ReturnType extends Effect.Effect ? E : never
+
+type ContextOf = ReturnType extends Effect.Effect ? R : never
/**
* @since 1.0.0
@@ -21,53 +18,28 @@ export const TypeId: unique symbol = Symbol.for("@mcrovero/effect-react-cache/Re
/**
* Enforce that cached effects do not require `Scope`.
- * If `R` contains `Scope`, this resolves to a helpful error tuple.
+ * If `R` contains `Scope`, this adds a helpful phantom property that makes
+ * the call site require an impossible extra argument while preserving
+ * inference for external consumers of the generated `.d.ts`.
*/
-type NoScope = [Extract] extends [never] ? R
- : [
+type NoScopeArgs = [Extract, Scope.Scope>] extends [never] ? []
+ : [[
"⛔ reactCache: Effects requiring Scope cannot be cached.",
"Move resource acquisition outside, or memoize with a Layer instead."
- ]
+ ]]
-const runEffectFn = >(
- effect: (...args: Args) => Effect.Effect>,
- runtime: Runtime.Runtime>,
- ...args: Args
-): Promise> => {
- return Runtime.runPromiseExit(runtime, effect(...args)).then((exit: Exit.Exit) => {
- if (Exit.isSuccess(exit)) {
- return { success: exit.value, error: undefined, defect: undefined }
- }
- if (Exit.isFailure(exit)) {
- const cause = Cause.match(exit.cause, {
- onEmpty: { error: undefined, defect: undefined } as CauseResult,
- onFail: (error) => ({ error, defect: undefined }),
- onDie: (defect) => ({ error: undefined, defect }),
- onInterrupt: () => {
- throw new Error("Interrupt cause not supported")
- },
- onSequential: () => {
- throw new Error("Sequential cause not supported")
- },
- onParallel: () => {
- throw new Error("Parallel cause not supported")
- }
- })
- return { success: undefined, error: cause.error, defect: cause.defect }
- }
- return { success: undefined, error: undefined, defect: undefined }
- })
-}
+const runEffectFn = (
+ effect: F,
+ runtime: Runtime.Runtime>,
+ ...args: Parameters
+): Promise, ErrorOf>> => Runtime.runPromiseExit(runtime, effect(...args))
const runEffectCachedFn = cache(
- >(
- effect: (...args: Args) => Effect.Effect>,
- ...args: Args
- ) => {
- let promise: Promise>
- return (runtime: Runtime.Runtime>) => {
+ (effect: F, ...args: Parameters) => {
+ let promise: Promise, ErrorOf>>
+ return (runtime: Runtime.Runtime>) => {
if (!promise) {
- promise = runEffectFn(effect, runtime, ...args)
+ promise = runEffectFn(effect, runtime, ...args)
}
return promise
}
@@ -76,6 +48,9 @@ const runEffectCachedFn = cache(
/**
* Compose React's `cache` with an Effect-returning function, memoizing by argument tuple.
+ * Memoization only happens when React's server cache dispatcher is active
+ * (for example, during a React Server Component render). Outside that
+ * environment, React's default `cache` implementation is effectively a passthrough.
*
* Do:
* - Cache pure/idempotent computations that return plain data
@@ -85,21 +60,16 @@ const runEffectCachedFn = cache(
* - Pass effects that require `Scope` (resource acquisition); use a `Layer` or lift resources outside instead
* - Rely on per-call timeouts/cancellation or different Context for the same args; first call wins and is cached
*/
-export const reactCache = >(
- effect: (...args: Args) => Effect.Effect>
-) => {
+export const reactCache = (effect: F, ..._scopeError: NoScopeArgs) => {
return (
- ...args: Args
- ): Effect.Effect> =>
+ ...args: Parameters
+ ): ReturnType =>
Effect.gen(function*() {
- const runtime = yield* Effect.runtime>()
- const result = yield* Effect.promise(() => runEffectCachedFn(effect, ...args)(runtime))
- if (result.success) {
- return yield* Effect.succeed(result.success)
- }
- if (result.error) {
- return yield* Effect.fail(result.error)
+ const runtime = yield* Effect.runtime>()
+ const exit = yield* Effect.promise(() => runEffectCachedFn(effect, ...args)(runtime))
+ if (Exit.isSuccess(exit)) {
+ return exit.value as SuccessOf
}
- return yield* Effect.die(result.defect)
- })
+ return yield* Effect.failCause(exit.cause)
+ }) as ReturnType
}
diff --git a/test/ReactCache.test.ts b/test/ReactCache.test.ts
index 52b5dff..72f877f 100644
--- a/test/ReactCache.test.ts
+++ b/test/ReactCache.test.ts
@@ -1,21 +1,66 @@
-import { Context, Effect } from "effect"
+import { Cause, Chunk, Context, Effect, Exit } from "effect"
import { describe, expect, it, vi } from "vitest"
import { reactCache } from "../src/ReactCache.js"
vi.mock("react", () => {
+ type CacheNode = {
+ o?: WeakMap