From 4a5ca1cf11068eefe8969bf5aaa8fb729cbd5bb3 Mon Sep 17 00:00:00 2001 From: Mattia Crovero Date: Sun, 22 Mar 2026 14:32:56 +0100 Subject: [PATCH 1/5] feat: new implementation and integration tests --- .gitignore | 3 + README.md | 30 +- .../next-app/app/api/no-react-cache/route.ts | 15 + fixtures/next-app/app/behaviors/page.tsx | 143 ++++++++++ fixtures/next-app/app/layout.tsx | 9 + fixtures/next-app/app/page.tsx | 3 + .../next-app/app/request-isolation/page.tsx | 22 ++ fixtures/next-app/app/tree/layout.tsx | 16 ++ fixtures/next-app/app/tree/page.tsx | 10 + fixtures/next-app/lib/cache-fixture.ts | 149 ++++++++++ fixtures/next-app/next-env.d.ts | 6 + fixtures/next-app/next.config.ts | 11 + fixtures/next-app/package.json | 22 ++ fixtures/next-app/tsconfig.json | 20 ++ integration/next-app.test.ts | 260 ++++++++++++++++++ package.json | 3 +- src/ReactCache.ts | 98 +++---- test/ReactCache.test.ts | 126 ++++++++- tsconfig.integration.json | 13 + tsconfig.json | 1 + vitest.next.config.ts | 11 + 21 files changed, 883 insertions(+), 88 deletions(-) create mode 100644 fixtures/next-app/app/api/no-react-cache/route.ts create mode 100644 fixtures/next-app/app/behaviors/page.tsx create mode 100644 fixtures/next-app/app/layout.tsx create mode 100644 fixtures/next-app/app/page.tsx create mode 100644 fixtures/next-app/app/request-isolation/page.tsx create mode 100644 fixtures/next-app/app/tree/layout.tsx create mode 100644 fixtures/next-app/app/tree/page.tsx create mode 100644 fixtures/next-app/lib/cache-fixture.ts create mode 100644 fixtures/next-app/next-env.d.ts create mode 100644 fixtures/next-app/next.config.ts create mode 100644 fixtures/next-app/package.json create mode 100644 fixtures/next-app/tsconfig.json create mode 100644 integration/next-app.test.ts create mode 100644 tsconfig.integration.json create mode 100644 vitest.next.config.ts diff --git a/.gitignore b/.gitignore index cf8c4cc..658a60a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ coverage/ *.tsbuildinfo node_modules/ +.pnpm-store/ +.next/ .DS_Store tmp/ dist/ @@ -15,3 +17,4 @@ scratchpad/* .env.development.local .env.test.local .env.production.local +fixtures/next-app/pnpm-lock.yaml diff --git a/README.md b/README.md index 7b9b271..c94411d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > This library is in early alpha and not yet ready for production use. -Typed helpers to compose React’s `cache` with `Effect` in a type-safe, ergonomic way. +Typed helpers to compose React’s server `cache` with `Effect` in a type-safe, ergonomic way. ### Install @@ -15,7 +15,7 @@ pnpm add @mcrovero/effect-react-cache effect react ## Why -React exposes a low-level `cache` primitive to memoize async work by argument tuple. This library wraps an `Effect`-returning function with React’s `cache` so you can: +React exposes a low-level `cache` primitive to memoize async work by argument tuple during a React Server Component render. This library wraps an `Effect`-returning function with React’s `cache` so you can: - Deduplicate concurrent calls: share the same pending promise across callers - Memoize by arguments: same args → same result without re-running the effect @@ -37,6 +37,8 @@ const fetchUser = (id: string) => const cachedFetchUser = reactCache(fetchUser) // 2) Use it like any other Effect +// React memoization only happens when this Effect is executed +// from an active React server render. await Effect.runPromise(cachedFetchUser("u-1")) ``` @@ -56,7 +58,8 @@ const getUser = (id: string) => export const cachedGetUser = reactCache(getUser) -// Same args → computed once, then memoized +// When executed inside the same React server render: +// same args → computed once, then memoized await Effect.runPromise(cachedGetUser("42")) await Effect.runPromise(cachedGetUser("42")) // reuses cached promise ``` @@ -91,7 +94,8 @@ export const cachedWithRequirements = reactCache(() => }) ) -// First call for a given args tuple determines the cached value +// Inside the same React server render, the first call for a given args tuple +// determines the cached value await Effect.runPromise(cachedWithRequirements().pipe(Effect.provideService(Random, { next: Effect.succeed(111) }))) // Subsequent calls with the same args reuse the first result, @@ -102,9 +106,9 @@ await Effect.runPromise(cachedWithRequirements().pipe(Effect.provideService(Rand ## API ```ts -declare const reactCache: >( - effect: (...args: Args) => Effect.Effect> -) => (...args: Args) => Effect.Effect> +declare const reactCache: ) => Effect.Effect>( + effect: F +) => (...args: Parameters) => ReturnType ``` - 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..4ee2c3f --- /dev/null +++ b/fixtures/next-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "@mcrovero/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": { + "@mcrovero/effect-react-cache": "link:../../dist", + "effect": "^3.17.7", + "next": "15.5.4", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2" + } +} 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..781122d 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>
+    p?: Map>
+    s?: 0 | 1 | 2
+    v?: A | unknown
+  }
+
+  const createCacheNode = (): CacheNode => ({})
+
   return {
     cache: ) => any>(fn: F) => {
-      const memo = new Map>()
+      let root = createCacheNode>()
+
       return ((...args: Array) => {
-        const key = JSON.stringify(args, (_k, v) => {
-          if (typeof v === "function") return `fn:${v.name || "anon"}`
-          if (typeof v === "symbol") return v.toString()
-          return v
-        })
-        if (!memo.has(key)) {
-          memo.set(key, fn(...args))
+        let node = root
+
+        for (const arg of args) {
+          if (typeof arg === "function" || (typeof arg === "object" && arg !== null)) {
+            if (!node.o) {
+              node.o = new WeakMap>>()
+            }
+            let next = node.o.get(arg)
+            if (!next) {
+              next = createCacheNode()
+              node.o.set(arg, next)
+            }
+            node = next
+            continue
+          }
+
+          if (!node.p) {
+            node.p = new Map>>()
+          }
+          let next = node.p.get(arg)
+          if (!next) {
+            next = createCacheNode()
+            node.p.set(arg, next)
+          }
+          node = next
+        }
+
+        if (node.s === 1) {
+          return node.v as ReturnType
+        }
+        if (node.s === 2) {
+          throw node.v
+        }
+
+        try {
+          const result = fn(...args)
+          node.s = 1
+          node.v = result
+          return result
+        } catch (error) {
+          node.s = 2
+          node.v = error
+          throw error
         }
-        return memo.get(key) as ReturnType
       }) as F
     }
   }
@@ -187,6 +232,67 @@ describe("reactCache", () => {
     expect(runCount).toBe(1)
   })
 
+  it("preserves falsy success values", async () => {
+    let runCount = 0
+
+    const cached = reactCache(() =>
+      Effect.sync(() => {
+        runCount += 1
+        return false
+      }))
+
+    const result1 = await Effect.runPromise(cached())
+    const result2 = await Effect.runPromise(cached())
+
+    expect(result1).toBe(false)
+    expect(result2).toBe(false)
+    expect(runCount).toBe(1)
+  })
+
+  it("preserves falsy typed errors", async () => {
+    const cached = reactCache(() => Effect.fail(""))
+
+    const exit = await Effect.runPromiseExit(cached())
+
+    expect(Exit.isFailure(exit)).toBe(true)
+    if (Exit.isFailure(exit)) {
+      expect(Chunk.toReadonlyArray(Cause.failures(exit.cause))).toEqual([""])
+    }
+  })
+
+  it("preserves composed causes", async () => {
+    const cached = reactCache(() =>
+      Effect.failCause(Cause.sequential(Cause.fail("boom"), Cause.fail("cleanup"))))
+
+    const exit = await Effect.runPromiseExit(cached())
+
+    expect(Exit.isFailure(exit)).toBe(true)
+    if (Exit.isFailure(exit)) {
+      const pretty = Cause.pretty(exit.cause)
+      expect(pretty).toContain("boom")
+      expect(pretty).toContain("cleanup")
+    }
+  })
+
+  it("uses React-style identity semantics for object arguments", async () => {
+    let runCount = 0
+
+    const cached = reactCache((input: { readonly id: string }) =>
+      Effect.sync(() => {
+        runCount += 1
+        return input.id
+      }))
+
+    const stableInput = { id: "same-ref" }
+
+    await Effect.runPromise(cached(stableInput))
+    await Effect.runPromise(cached(stableInput))
+    await Effect.runPromise(cached({ id: "same-shape" }))
+    await Effect.runPromise(cached({ id: "same-shape" }))
+
+    expect(runCount).toBe(3)
+  })
+
   it("preserves current span across cached execution", async () => {
     const traced = () =>
       Effect.gen(function*() {
diff --git a/tsconfig.integration.json b/tsconfig.integration.json
new file mode 100644
index 0000000..4b60020
--- /dev/null
+++ b/tsconfig.integration.json
@@ -0,0 +1,13 @@
+{
+  "extends": "./tsconfig.base.json",
+  "include": ["integration"],
+  "references": [
+    { "path": "tsconfig.src.json" }
+  ],
+  "compilerOptions": {
+    "types": ["node"],
+    "tsBuildInfoFile": ".tsbuildinfo/integration.tsbuildinfo",
+    "rootDir": "integration",
+    "noEmit": true
+  }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 1429b93..3042574 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,6 +4,7 @@
   "references": [
     { "path": "tsconfig.src.json" },
     { "path": "tsconfig.test.json" },
+    { "path": "tsconfig.integration.json" },
     { "path": "tsconfig.examples.json" }
   ]
 }
diff --git a/vitest.next.config.ts b/vitest.next.config.ts
new file mode 100644
index 0000000..8b51f49
--- /dev/null
+++ b/vitest.next.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from "vitest/config"
+
+export default defineConfig({
+  test: {
+    include: ["./integration/**/*.test.ts"],
+    fileParallelism: false,
+    globals: true,
+    hookTimeout: 180_000,
+    testTimeout: 180_000
+  }
+})

From ec1f9169ec0643151e65691bc32f429315ee7ba2 Mon Sep 17 00:00:00 2001
From: Mattia Crovero 
Date: Sun, 22 Mar 2026 14:37:38 +0100
Subject: [PATCH 2/5] test: added unit tests

---
 test/ReactCache.test.ts  | 69 ++++++++++++++++++++++++++++++++++++++++
 test/ReactCache.types.ts | 19 +++++++++++
 2 files changed, 88 insertions(+)
 create mode 100644 test/ReactCache.types.ts

diff --git a/test/ReactCache.test.ts b/test/ReactCache.test.ts
index 781122d..35cc816 100644
--- a/test/ReactCache.test.ts
+++ b/test/ReactCache.test.ts
@@ -146,6 +146,46 @@ describe("reactCache", () => {
     expect(runCount).toBe(1)
   })
 
+  it("uses the first caller context for concurrent calls with the same arguments", async () => {
+    class Random extends Context.Tag("ConcurrentRandomService")<
+      Random,
+      { readonly next: Effect.Effect }
+    >() {}
+
+    let runCount = 0
+
+    const uncached = () =>
+      Effect.gen(function*() {
+        runCount += 1
+        const random = yield* Random
+        yield* Effect.sleep(20)
+        return yield* random.next
+      })
+
+    const cached = reactCache(uncached)
+
+    const [result1, result2] = await Promise.all([
+      Effect.runPromise(
+        cached().pipe(
+          Effect.provideService(Random, {
+            next: Effect.succeed(111)
+          })
+        )
+      ),
+      Effect.runPromise(
+        cached().pipe(
+          Effect.provideService(Random, {
+            next: Effect.succeed(222)
+          })
+        )
+      )
+    ])
+
+    expect(result1).toBe(111)
+    expect(result2).toBe(111)
+    expect(runCount).toBe(1)
+  })
+
   it("shares the same pending promise across concurrent calls", async () => {
     let runCount = 0
 
@@ -274,6 +314,35 @@ describe("reactCache", () => {
     }
   })
 
+  it("preserves and caches defects for the same arguments", async () => {
+    let runCount = 0
+
+    const cached = reactCache((id: string) =>
+      Effect.sync(() => {
+        runCount += 1
+        throw new Error(`defect:${id}`)
+      }))
+
+    const first = await Effect.runPromiseExit(cached("x"))
+    const second = await Effect.runPromiseExit(cached("x"))
+
+    expect(Exit.isFailure(first)).toBe(true)
+    expect(Exit.isFailure(second)).toBe(true)
+
+    if (Exit.isFailure(first) && Exit.isFailure(second)) {
+      const firstDefects = Chunk.toReadonlyArray(Cause.defects(first.cause))
+      const secondDefects = Chunk.toReadonlyArray(Cause.defects(second.cause))
+
+      expect(firstDefects).toHaveLength(1)
+      expect(secondDefects).toHaveLength(1)
+      expect(firstDefects[0]).toBeInstanceOf(Error)
+      expect(secondDefects[0]).toBe(firstDefects[0])
+      expect((firstDefects[0] as Error).message).toBe("defect:x")
+    }
+
+    expect(runCount).toBe(1)
+  })
+
   it("uses React-style identity semantics for object arguments", async () => {
     let runCount = 0
 
diff --git a/test/ReactCache.types.ts b/test/ReactCache.types.ts
new file mode 100644
index 0000000..962163f
--- /dev/null
+++ b/test/ReactCache.types.ts
@@ -0,0 +1,19 @@
+import { Effect } from "effect"
+import type * as Scope from "effect/Scope"
+import { reactCache } from "../src/ReactCache.js"
+
+const cachedNoScope = reactCache((id: string, attempt: number) =>
+  Effect.succeed({ id, attempt } as const))
+
+const sameSignature: (
+  id: string,
+  attempt: number
+) => Effect.Effect<{ readonly id: string; readonly attempt: number }, never, never> = cachedNoScope
+
+void sameSignature("user-1", 1)
+
+const requiresScope = (): Effect.Effect =>
+  Effect.succeed("scoped") as Effect.Effect
+
+// @ts-expect-error Effects requiring Scope must not be cacheable.
+reactCache(requiresScope)

From 6cf62fd2d53e6fa011545b1caa8b8dceca86f6c1 Mon Sep 17 00:00:00 2001
From: Mattia Crovero 
Date: Sun, 22 Mar 2026 14:40:52 +0100
Subject: [PATCH 3/5] chore: changeset and release trust publisher

---
 .changeset/bright-seals-cheat.md | 7 +++++++
 .github/workflows/release.yml    | 3 ++-
 2 files changed, 9 insertions(+), 1 deletion(-)
 create mode 100644 .changeset/bright-seals-cheat.md

diff --git a/.changeset/bright-seals-cheat.md b/.changeset/bright-seals-cheat.md
new file mode 100644
index 0000000..f25eb9d
--- /dev/null
+++ b/.changeset/bright-seals-cheat.md
@@ -0,0 +1,7 @@
+---
+"@mcrovero/effect-react-cache": patch
+---
+
+Fix `reactCache` to preserve full `Exit` information, including falsy values and composed causes, instead of collapsing failures into a lossy intermediate shape.
+
+Clarify the React and Next.js caching semantics in the docs and add real Next.js integration coverage for request scoping, cross-component deduplication, and non-render route-handler behavior.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c3a80ba..e42a30d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,6 +23,8 @@ jobs:
       - uses: actions/checkout@v4
       - name: Install dependencies
         uses: ./.github/actions/setup
+        with:
+          node-version: 24
       - name: Create Release Pull Request or Publish
         id: changesets
         uses: changesets/action@v1
@@ -31,4 +33,3 @@ jobs:
           publish: pnpm changeset-publish
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

From 85541eddccb5684d90601f55a126d2c7e6f4ec5a Mon Sep 17 00:00:00 2001
From: Mattia Crovero 
Date: Sun, 22 Mar 2026 14:42:59 +0100
Subject: [PATCH 4/5] fix: lint

---
 test/ReactCache.test.ts  | 18 ++++++++++--------
 test/ReactCache.types.ts |  3 +--
 2 files changed, 11 insertions(+), 10 deletions(-)

diff --git a/test/ReactCache.test.ts b/test/ReactCache.test.ts
index 35cc816..72f877f 100644
--- a/test/ReactCache.test.ts
+++ b/test/ReactCache.test.ts
@@ -4,7 +4,7 @@ import { reactCache } from "../src/ReactCache.js"
 
 vi.mock("react", () => {
   type CacheNode = {
-    o?: WeakMap>
+    o?: WeakMap>
     p?: Map>
     s?: 0 | 1 | 2
     v?: A | unknown
@@ -14,7 +14,7 @@ vi.mock("react", () => {
 
   return {
     cache: ) => any>(fn: F) => {
-      let root = createCacheNode>()
+      const root = createCacheNode>()
 
       return ((...args: Array) => {
         let node = root
@@ -22,7 +22,7 @@ vi.mock("react", () => {
         for (const arg of args) {
           if (typeof arg === "function" || (typeof arg === "object" && arg !== null)) {
             if (!node.o) {
-              node.o = new WeakMap>>()
+              node.o = new WeakMap>>()
             }
             let next = node.o.get(arg)
             if (!next) {
@@ -279,7 +279,8 @@ describe("reactCache", () => {
       Effect.sync(() => {
         runCount += 1
         return false
-      }))
+      })
+    )
 
     const result1 = await Effect.runPromise(cached())
     const result2 = await Effect.runPromise(cached())
@@ -301,8 +302,7 @@ describe("reactCache", () => {
   })
 
   it("preserves composed causes", async () => {
-    const cached = reactCache(() =>
-      Effect.failCause(Cause.sequential(Cause.fail("boom"), Cause.fail("cleanup"))))
+    const cached = reactCache(() => Effect.failCause(Cause.sequential(Cause.fail("boom"), Cause.fail("cleanup"))))
 
     const exit = await Effect.runPromiseExit(cached())
 
@@ -321,7 +321,8 @@ describe("reactCache", () => {
       Effect.sync(() => {
         runCount += 1
         throw new Error(`defect:${id}`)
-      }))
+      })
+    )
 
     const first = await Effect.runPromiseExit(cached("x"))
     const second = await Effect.runPromiseExit(cached("x"))
@@ -350,7 +351,8 @@ describe("reactCache", () => {
       Effect.sync(() => {
         runCount += 1
         return input.id
-      }))
+      })
+    )
 
     const stableInput = { id: "same-ref" }
 
diff --git a/test/ReactCache.types.ts b/test/ReactCache.types.ts
index 962163f..8627ea5 100644
--- a/test/ReactCache.types.ts
+++ b/test/ReactCache.types.ts
@@ -2,8 +2,7 @@ import { Effect } from "effect"
 import type * as Scope from "effect/Scope"
 import { reactCache } from "../src/ReactCache.js"
 
-const cachedNoScope = reactCache((id: string, attempt: number) =>
-  Effect.succeed({ id, attempt } as const))
+const cachedNoScope = reactCache((id: string, attempt: number) => Effect.succeed({ id, attempt } as const))
 
 const sameSignature: (
   id: string,

From 17df5f40401bfd5b8c08169fe1253f23e98c48f8 Mon Sep 17 00:00:00 2001
From: Mattia Crovero 
Date: Sun, 22 Mar 2026 14:46:47 +0100
Subject: [PATCH 5/5] chore: fix deps resolution changeset

---
 fixtures/next-app/package.json | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/fixtures/next-app/package.json b/fixtures/next-app/package.json
index 4ee2c3f..c94dc53 100644
--- a/fixtures/next-app/package.json
+++ b/fixtures/next-app/package.json
@@ -1,5 +1,5 @@
 {
-  "name": "@mcrovero/effect-react-cache-next-fixture",
+  "name": "private-effect-react-cache-next-fixture",
   "private": true,
   "type": "module",
   "packageManager": "pnpm@9.10.0",
@@ -9,14 +9,14 @@
     "dev": "next dev"
   },
   "dependencies": {
-    "@mcrovero/effect-react-cache": "link:../../dist",
     "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