Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-ssr-server-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"react-call": patch
---

Fix React's `"The result of getServerSnapshot should be cached to avoid an infinite loop"` warning logged on every render in any SSR consumer (Next.js App Router included).

`createStackStore`'s `getServerSnapshot` returned a fresh `[]` each call, so `useSyncExternalStore`'s `Object.is` comparison always reported "changed" and React entered the recovery path. Fixed by returning a single stable empty-stack reference per store. No runtime behaviour change for client-only consumers (Vite CSR, CRA, etc.) — they never touched the SSR snapshot path.

Surfaced by the new `apps/nextjs/` playground introduced in the same release; shipped briefly in `2.0.0-next.1`.
6 changes: 6 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "vite-playground", "run", "dev"],
"port": 5174
},
{
"name": "nextjs-playground",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "nextjs-playground", "run", "dev"],
"port": 3001
}
]
}
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,11 +338,12 @@ However, bear in mind that because the call() method is meant to be triggered by

## Next.js / RSC

If the original setup is not working for you, export the Root and the rest separately:
Mark the file where you call `createCallable(...)` as a Client Component (the lib uses `useSyncExternalStore`):

```diff
+ 'use client'

- export const Confirm = createCallable(...)
+ export const { Root, ...Confirm } = createCallable(...)
export const Confirm = createCallable(...)
```

Then `<Confirm />` mounts cleanly from any Server Component (e.g. `app/layout.tsx`). Verified end-to-end against the Next.js 15 App Router in [`apps/nextjs/`](./apps/nextjs/).
4 changes: 4 additions & 0 deletions apps/nextjs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.next/
node_modules/
*.tsbuildinfo
.env*.local
32 changes: 32 additions & 0 deletions apps/nextjs/app/Confirm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client'

import { createCallable } from 'react-call'

// `'use client'` is required because react-call uses
// useSyncExternalStore. The Callable itself becomes a client
// reference when imported from a Server Component.
export const Confirm = createCallable<{ message: string }, boolean>(
({ call, message }) => (
<div
role="dialog"
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '1rem',
background: 'lightblue',
border: '4px dashed red',
zIndex: 10,
}}
>
<p>{message}</p>
<button type="button" onClick={() => call.end(true)}>
Yes
</button>
<button type="button" onClick={() => call.end(false)}>
No
</button>
</div>
),
)
17 changes: 17 additions & 0 deletions apps/nextjs/app/OpenButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client'

import { Confirm } from './Confirm'

export function OpenButton() {
return (
<button
type="button"
onClick={async () => {
const ok = await Confirm.call({ message: 'Continue?' })
console.log('response:', ok)
}}
>
Open dialog
</button>
)
}
32 changes: 32 additions & 0 deletions apps/nextjs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Metadata } from 'next'
import { Confirm } from './Confirm'

export const metadata: Metadata = {
title: 'react-call Next.js / RSC playground',
}

// SERVER COMPONENT (no `'use client'`). This is the exact shape
// from issue #39: layout.tsx renders the Callable mount from a
// Server Component context, with the Callable imported from a
// `'use client'` module.
//
// ADR-0013 form (`<Confirm />`): expected to work because the bare
// component IS the client reference Next.js can render.
//
// Original form (`<Confirm.Root />`): expected to fail with
// "Unsupported Server Component type: undefined" because property
// access on a client reference resolves to undefined.
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
{children}
<Confirm />
</body>
</html>
)
}
19 changes: 19 additions & 0 deletions apps/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { OpenButton } from './OpenButton'

// SERVER COMPONENT. The actual `.call()` invocation lives in
// OpenButton (a Client Component) since `.call()` must run on the
// client — but the page itself is allowed to be a Server Component.
export default async function Home() {
return (
<main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
<h1>react-call Next.js / RSC playground</h1>
<p>
Reproduces issue{' '}
<a href="https://github.com/desko27/react-call/issues/39">#39</a>. The
layout.tsx is a Server Component that mounts the Callable imported from
a Client Component module.
</p>
<OpenButton />
</main>
)
}
6 changes: 6 additions & 0 deletions apps/nextjs/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
9 changes: 9 additions & 0 deletions apps/nextjs/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { NextConfig } from 'next'

// Minimal repro of issue #39: createCallable in a `'use client'`
// module, mounted from a Server Component layout. Started from
// react-call@2.0.0-next.1 to verify ADR-0009's function-shape return
// + ADR-0013's bare-form recommendation fix the original RSC bug.
const config: NextConfig = {}

export default config
21 changes: 21 additions & 0 deletions apps/nextjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"private": true,
"name": "nextjs-playground",
"type": "module",
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"check:types": "tsc --noEmit"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.2.6",
"react-call": "workspace:*",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3"
}
}
14 changes: 14 additions & 0 deletions apps/nextjs/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"incremental": true,
"resolveJsonModule": true,
"jsx": "preserve",
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
12 changes: 12 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@
}
}
}
},
{
"includes": [
"**/apps/nextjs/app/**/{page,layout,template,loading,error,not-found,default,route}.{ts,tsx}"
],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
]
}
15 changes: 15 additions & 0 deletions packages/react-call/src/__tests__/ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { renderToString } from 'react-dom/server'
import { describe, expect, test } from 'vitest'
import { createStackStore } from '../createCallable/store'
import { Confirm } from './shared/Confirm'

// React Native + Next.js / RSC consumers care that the lib's Root component
Expand All @@ -23,4 +24,18 @@ describe('SSR — getServerSnapshot', () => {
expect(renderToString(<Confirm />)).toBe('')
expect(renderToString(<Confirm />)).toBe('')
})

test('getServerSnapshot returns a stable reference across calls', () => {
// React's useSyncExternalStore compares snapshots with Object.is
// and throws "The result of getServerSnapshot should be cached to
// avoid an infinite loop" if a fresh value comes back each call.
// The output array would be `[]` in both cases (so a renderToString
// diff would still pass), but the reference must be stable. The
// apps/nextjs playground surfaced this when the unstable variant
// shipped briefly in 2.0.0-next.1.
const store = createStackStore<void, void>()
expect(
Object.is(store.getServerSnapshot(), store.getServerSnapshot()),
).toBe(true)
})
})
11 changes: 10 additions & 1 deletion packages/react-call/src/createCallable/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ export type CallItemPublicProperties<_, Response> = {
ended: boolean
}

// React's useSyncExternalStore compares snapshots with Object.is and
// throws "The result of getServerSnapshot should be cached to avoid an
// infinite loop" if the function returns a fresh value every call.
// A single per-store stable reference is enough — on the server the
// stack is always empty (no `call()` can run before hydration), and
// hydration switches the hook to `getSnapshot` immediately. Surfaced
// by the apps/nextjs playground; Vite CSR never hit this path.
const EMPTY_STACK: Stack<unknown, unknown> = []

export function createStackStore<Props, Response>() {
let nextKey = 0
let stack: Stack<Props, Response> = []
Expand Down Expand Up @@ -56,7 +65,7 @@ export function createStackStore<Props, Response>() {
}
},
getSnapshot: () => stack,
getServerSnapshot: () => [],
getServerSnapshot: () => EMPTY_STACK as Stack<Props, Response>,
getListenersSize: () => listeners.size,
getUpsertPromise: () => upsertPromise,
setUpsertPromise: (p: Promise<Response> | null) => {
Expand Down
Loading
Loading