Skip to content

Commit 713fed1

Browse files
hi-ogawaOpenCode
andauthored
chore(rsc): split use cache into dedicated example (#1312)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode <noreply@opencode.ai>
1 parent bd417ef commit 713fed1

20 files changed

Lines changed: 751 additions & 165 deletions

packages/plugin-rsc/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ npm create vite@latest -- --template rsc
2525

2626
**Integration examples:**
2727

28-
- [`./examples/basic`](./examples/basic) - Advanced RSC features and testing
29-
- This is mainly used for e2e testing and includes various advanced RSC usages (e.g. `"use cache"` example).
28+
- [`./examples/basic`](./examples/basic) - Comprehensive showcase of standard RSC features and the primary E2E test fixture.
29+
- [`./examples/use-cache`](./examples/use-cache) - Minimal cache feature inspired by Next.js's `"use cache"`, built with generic transform and RSC runtime APIs.
3030
- [`./examples/ssg`](./examples/ssg) - Static site generation with MDX and client components for interactivity.
3131
- [`./examples/ppr`](./examples/ppr) - Partial prerendering with a reusable static HTML shell and request-time RSC content.
3232
- [`./examples/no-ssr`](./examples/no-ssr) - RSC application without an SSR environment.

packages/plugin-rsc/e2e/basic.test.ts

Lines changed: 0 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1707,117 +1707,6 @@ function defineTest(f: Fixture) {
17071707
)
17081708
})
17091709

1710-
test('use cache function', async ({ page }) => {
1711-
await page.goto(f.url())
1712-
await waitForHydration(page)
1713-
const locator = page.getByTestId('test-use-cache-fn')
1714-
await expect(locator.locator('span')).toHaveText(
1715-
'(actionCount: 0, cacheFnCount: 0)',
1716-
)
1717-
await locator.getByRole('button').click()
1718-
await expect(locator.locator('span')).toHaveText(
1719-
'(actionCount: 1, cacheFnCount: 1)',
1720-
)
1721-
await locator.getByRole('button').click()
1722-
await expect(locator.locator('span')).toHaveText(
1723-
'(actionCount: 2, cacheFnCount: 1)',
1724-
)
1725-
await locator.getByRole('textbox').fill('test')
1726-
await locator.getByRole('button').click()
1727-
await expect(locator.locator('span')).toHaveText(
1728-
'(actionCount: 3, cacheFnCount: 2)',
1729-
)
1730-
await locator.getByRole('textbox').fill('test')
1731-
await locator.getByRole('button').click()
1732-
await expect(locator.locator('span')).toHaveText(
1733-
'(actionCount: 4, cacheFnCount: 2)',
1734-
)
1735-
1736-
// revalidate cache
1737-
await locator.getByRole('textbox').fill('revalidate')
1738-
await locator.getByRole('button').click()
1739-
await expect(locator.locator('span')).toHaveText(
1740-
'(actionCount: 5, cacheFnCount: 3)',
1741-
)
1742-
await locator.getByRole('textbox').fill('test')
1743-
await locator.getByRole('button').click()
1744-
await expect(locator.locator('span')).toHaveText(
1745-
'(actionCount: 6, cacheFnCount: 4)',
1746-
)
1747-
})
1748-
1749-
test('use cache component', async ({ page }) => {
1750-
await page.goto(f.url())
1751-
await waitForHydration(page)
1752-
const static1 = await page
1753-
.getByTestId('test-use-cache-component-static')
1754-
.textContent()
1755-
const dynamic1 = await page
1756-
.getByTestId('test-use-cache-component-dynamic')
1757-
.textContent()
1758-
await page.waitForTimeout(100)
1759-
await page.reload()
1760-
const static2 = await page
1761-
.getByTestId('test-use-cache-component-static')
1762-
.textContent()
1763-
const dynamic2 = await page
1764-
.getByTestId('test-use-cache-component-dynamic')
1765-
.textContent()
1766-
expect({ static2, dynamic2 }).toEqual({
1767-
static2: expect.stringMatching(static1!),
1768-
dynamic2: expect.not.stringMatching(dynamic1!),
1769-
})
1770-
})
1771-
1772-
test('use cache closure', async ({ page }) => {
1773-
await page.goto(f.url())
1774-
await waitForHydration(page)
1775-
const locator = page.getByTestId('test-use-cache-closure')
1776-
await expect(locator.locator('span')).toHaveText(
1777-
'(actionCount: 0, innerFnCount: 0)',
1778-
)
1779-
1780-
// (x, y)
1781-
await locator.getByPlaceholder('outer').fill('x')
1782-
await locator.getByPlaceholder('inner').fill('y')
1783-
await locator.getByRole('button').click()
1784-
await expect(locator.locator('span')).toHaveText(
1785-
'(actionCount: 1, innerFnCount: 1)',
1786-
)
1787-
1788-
// (x, y)
1789-
await locator.getByPlaceholder('outer').fill('x')
1790-
await locator.getByPlaceholder('inner').fill('y')
1791-
await locator.getByRole('button').click()
1792-
await expect(locator.locator('span')).toHaveText(
1793-
'(actionCount: 2, innerFnCount: 1)',
1794-
)
1795-
1796-
// (xx, y)
1797-
await locator.getByPlaceholder('outer').fill('xx')
1798-
await locator.getByPlaceholder('inner').fill('y')
1799-
await locator.getByRole('button').click()
1800-
await expect(locator.locator('span')).toHaveText(
1801-
'(actionCount: 3, innerFnCount: 2)',
1802-
)
1803-
1804-
// (xx, y)
1805-
await locator.getByPlaceholder('outer').fill('xx')
1806-
await locator.getByPlaceholder('inner').fill('y')
1807-
await locator.getByRole('button').click()
1808-
await expect(locator.locator('span')).toHaveText(
1809-
'(actionCount: 4, innerFnCount: 2)',
1810-
)
1811-
1812-
// (xx, yy)
1813-
await locator.getByPlaceholder('outer').fill('xx')
1814-
await locator.getByPlaceholder('inner').fill('yy')
1815-
await locator.getByRole('button').click()
1816-
await expect(locator.locator('span')).toHaveText(
1817-
'(actionCount: 5, innerFnCount: 3)',
1818-
)
1819-
})
1820-
18211710
test('hydration mismatch', async ({ page }) => {
18221711
const errors: Error[] = []
18231712
page.on('pageerror', (error) => {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { expect, test } from '@playwright/test'
2+
import { type Fixture, useFixture } from './fixture'
3+
import { waitForHydration } from './helper'
4+
5+
test.describe('dev', () => {
6+
const f = useFixture({ root: 'examples/use-cache', mode: 'dev' })
7+
defineTests(f)
8+
})
9+
10+
test.describe('build', () => {
11+
const f = useFixture({ root: 'examples/use-cache', mode: 'build' })
12+
defineTests(f)
13+
})
14+
15+
function defineTests(f: Fixture) {
16+
test('use cache function', async ({ page }) => {
17+
await page.goto(f.url())
18+
await waitForHydration(page)
19+
const locator = page.getByTestId('test-use-cache-fn')
20+
await expect(locator.locator('span')).toHaveText(
21+
'(actionCount: 0, cacheFnCount: 0)',
22+
)
23+
24+
// The action runs on every submit, but the cached function runs once per argument.
25+
await locator.getByRole('button').click()
26+
await expect(locator.locator('span')).toHaveText(
27+
'(actionCount: 1, cacheFnCount: 1)',
28+
)
29+
await locator.getByRole('button').click()
30+
await expect(locator.locator('span')).toHaveText(
31+
'(actionCount: 2, cacheFnCount: 1)',
32+
)
33+
await locator.getByRole('textbox').fill('test')
34+
await locator.getByRole('button').click()
35+
await expect(locator.locator('span')).toHaveText(
36+
'(actionCount: 3, cacheFnCount: 2)',
37+
)
38+
await locator.getByRole('textbox').fill('test')
39+
await locator.getByRole('button').click()
40+
await expect(locator.locator('span')).toHaveText(
41+
'(actionCount: 4, cacheFnCount: 2)',
42+
)
43+
44+
// revalidate cache
45+
await locator.getByRole('textbox').fill('revalidate')
46+
await locator.getByRole('button').click()
47+
await expect(locator.locator('span')).toHaveText(
48+
'(actionCount: 5, cacheFnCount: 3)',
49+
)
50+
await locator.getByRole('textbox').fill('test')
51+
await locator.getByRole('button').click()
52+
await expect(locator.locator('span')).toHaveText(
53+
'(actionCount: 6, cacheFnCount: 4)',
54+
)
55+
})
56+
57+
test('use cache component', async ({ page }) => {
58+
await page.goto(f.url())
59+
await waitForHydration(page)
60+
const static1 = await page
61+
.getByTestId('test-use-cache-component-static')
62+
.textContent()
63+
const dynamic1 = await page
64+
.getByTestId('test-use-cache-component-dynamic')
65+
.textContent()
66+
await page.waitForTimeout(100)
67+
await page.reload()
68+
const static2 = await page
69+
.getByTestId('test-use-cache-component-static')
70+
.textContent()
71+
const dynamic2 = await page
72+
.getByTestId('test-use-cache-component-dynamic')
73+
.textContent()
74+
75+
// The cached shell stays stable while temporary-reference children are refreshed.
76+
expect({ static2, dynamic2 }).toEqual({
77+
static2: expect.stringMatching(static1!),
78+
dynamic2: expect.not.stringMatching(dynamic1!),
79+
})
80+
})
81+
82+
test('use cache closure', async ({ page }) => {
83+
await page.goto(f.url())
84+
await waitForHydration(page)
85+
const locator = page.getByTestId('test-use-cache-closure')
86+
await expect(locator.locator('span')).toHaveText(
87+
'(actionCount: 0, innerFnCount: 0)',
88+
)
89+
90+
// Both the captured outer value and call-time inner argument form the cache key.
91+
// (x, y)
92+
await locator.getByPlaceholder('outer').fill('x')
93+
await locator.getByPlaceholder('inner').fill('y')
94+
await locator.getByRole('button').click()
95+
await expect(locator.locator('span')).toHaveText(
96+
'(actionCount: 1, innerFnCount: 1)',
97+
)
98+
99+
// (x, y)
100+
await locator.getByPlaceholder('outer').fill('x')
101+
await locator.getByPlaceholder('inner').fill('y')
102+
await locator.getByRole('button').click()
103+
await expect(locator.locator('span')).toHaveText(
104+
'(actionCount: 2, innerFnCount: 1)',
105+
)
106+
107+
// (xx, y)
108+
await locator.getByPlaceholder('outer').fill('xx')
109+
await locator.getByPlaceholder('inner').fill('y')
110+
await locator.getByRole('button').click()
111+
await expect(locator.locator('span')).toHaveText(
112+
'(actionCount: 3, innerFnCount: 2)',
113+
)
114+
115+
// (xx, y)
116+
await locator.getByPlaceholder('outer').fill('xx')
117+
await locator.getByPlaceholder('inner').fill('y')
118+
await locator.getByRole('button').click()
119+
await expect(locator.locator('span')).toHaveText(
120+
'(actionCount: 4, innerFnCount: 2)',
121+
)
122+
123+
// (xx, yy)
124+
await locator.getByPlaceholder('outer').fill('xx')
125+
await locator.getByPlaceholder('inner').fill('yy')
126+
await locator.getByRole('button').click()
127+
await expect(locator.locator('span')).toHaveText(
128+
'(actionCount: 5, innerFnCount: 3)',
129+
)
130+
})
131+
}

packages/plugin-rsc/examples/basic/src/routes/root.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ import { TestTailwind } from './tailwind'
5959
import { TestTemporaryReference } from './temporary-reference/client'
6060
import { TestTreeShakeServer } from './tree-shake/server'
6161
import { TestTreeShake2 } from './tree-shake2/server'
62-
import { TestUseCache } from './use-cache/server'
6362
import { TestUseId } from './use-id/server'
6463
import { TestVirtualModule } from './virtual-module/server'
6564

@@ -122,7 +121,6 @@ export function Root(props: { url: URL }) {
122121
<TestActionStateServer />
123122
<TestModuleInvalidationServer />
124123
<TestBrowserOnly />
125-
<TestUseCache />
126124
<TestReactCache url={props.url} />
127125
<TestCssQueries />
128126
<TestImportMetaGlob />

packages/plugin-rsc/examples/basic/vite.config.ts

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,7 @@ import { fileURLToPath } from 'node:url'
55
import tailwindcss from '@tailwindcss/vite'
66
import react from '@vitejs/plugin-react'
77
import rsc from '@vitejs/plugin-rsc'
8-
import { transformHoistInlineDirective } from '@vitejs/plugin-rsc/transforms'
9-
import {
10-
type Plugin,
11-
type Rollup,
12-
defineConfig,
13-
normalizePath,
14-
parseAstAsync,
15-
} from 'vite'
8+
import { type Plugin, type Rollup, defineConfig, normalizePath } from 'vite'
169

1710
export default defineConfig({
1811
clearScreen: false,
@@ -30,7 +23,6 @@ export default defineConfig({
3023
},
3124
},
3225
react(),
33-
vitePluginUseCache(),
3426
vitePluginVirtualModuleTest(),
3527
testRscVirtualClientPackagePlugin(),
3628
rsc({
@@ -335,33 +327,6 @@ function testBuildPlugin(): Plugin[] {
335327
]
336328
}
337329

338-
function vitePluginUseCache(): Plugin[] {
339-
return [
340-
{
341-
name: 'use-cache',
342-
async transform(code) {
343-
if (!code.includes('use cache')) return
344-
const ast = await parseAstAsync(code)
345-
// @ts-ignore for rolldown-vite ci estree/oxc mismatch
346-
const result = transformHoistInlineDirective(code, ast, {
347-
runtime: (value) => `__vite_rsc_cache(${value})`,
348-
directive: 'use cache',
349-
rejectNonAsyncFunction: true,
350-
noExport: true,
351-
})
352-
if (!result.output.hasChanged()) return
353-
result.output.prepend(
354-
`import __vite_rsc_cache from "/src/framework/use-cache-runtime";`,
355-
)
356-
return {
357-
code: result.output.toString(),
358-
map: result.output.generateMap({ hires: 'boundary' }),
359-
}
360-
},
361-
},
362-
]
363-
}
364-
365330
function vitePluginVirtualModuleTest(): Plugin[] {
366331
return [
367332
{

packages/plugin-rsc/examples/ppr/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ The PPR-relevant shape in [`src/root.tsx`](./src/root.tsx) places cached and dyn
3535
| `CachedAsyncContent` | Waits 100 ms on a cache miss. The RSC prerender tracks that fill, materializes its Flight result, and reuses it in later renders. |
3636
| `DynamicContent` | Calls `markDynamic()`. It returns a pending promise during prerender and `undefined` during a request render, so the request continues immediately into its request-time work. |
3737

38-
`createCachedComponent` gives this example the semantics of a small `"use cache"` runtime without requiring a compiler transform. Its argument and result serialization follows [`examples/basic/src/framework/use-cache-runtime.tsx`](../basic/src/framework/use-cache-runtime.tsx). Temporary references are important for `CachedLayout`: its cache key and bytes retain reference markers while the matching reference set supplies the concrete dynamic `children` on each invocation.
38+
`createCachedComponent` gives this example the semantics of a small `"use cache"` runtime without requiring a compiler transform. Its argument and result serialization follows [`examples/use-cache/src/framework/use-cache-runtime.tsx`](../use-cache/src/framework/use-cache-runtime.tsx). Temporary references are important for `CachedLayout`: its cache key and bytes retain reference markers while the matching reference set supplies the concrete dynamic `children` on each invocation.
3939

4040
Cached work must not call `markDynamic()` directly. The hanging dynamic promise would also keep that cache fill pending forever. A production framework can detect an active cache scope and report this as invalid usage.
4141

packages/plugin-rsc/examples/ppr/src/framework/cache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function createCachedComponent<Props extends object>(
3535
const componentId = String(nextComponentId++)
3636

3737
// Cache serialization follows the existing use-cache runtime example in
38-
// examples/basic/src/framework/use-cache-runtime.tsx.
38+
// examples/use-cache/src/framework/use-cache-runtime.tsx.
3939
// https://github.com/vercel/next.js/pull/70435
4040
// https://github.com/vercel/next.js/blob/09a2167b0a970757606b7f91ff2d470f77f13f8c/packages/next/src/server/use-cache/use-cache-wrapper.ts
4141

@@ -58,7 +58,7 @@ export function createCachedComponent<Props extends object>(
5858
environmentName: 'Cache',
5959
temporaryReferences,
6060
})
61-
// examples/basic's StreamCacher and Next.js keep this stream lazy. This
61+
// examples/use-cache's StreamCacher and Next.js keep this stream lazy. This
6262
// demo materializes it so the entry promise also tracks cache-fill
6363
// completion without a separate readiness signal.
6464
// TODO: Follow up with a stream-native cache and a separate,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
dist
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# `"use cache"` example
2+
3+
This example demonstrates a minimal cache feature inspired by Next.js's `"use cache"`. It composes `@vitejs/plugin-rsc`'s generic directive transform utilities with its low-level RSC runtime APIs.
4+
5+
Neither React nor `@vitejs/plugin-rsc` defines the `"use cache"` directive, and this example does not aim for full Next.js compatibility.
6+
7+
```sh
8+
pnpm dev
9+
```
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@vitejs/plugin-rsc-examples-use-cache",
3+
"private": true,
4+
"license": "MIT",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "vite build",
9+
"preview": "vite preview"
10+
},
11+
"dependencies": {
12+
"react": "^19.2.8",
13+
"react-dom": "^19.2.8"
14+
},
15+
"devDependencies": {
16+
"@types/react": "^19.2.17",
17+
"@types/react-dom": "^19.2.3",
18+
"@vitejs/plugin-react": "latest",
19+
"@vitejs/plugin-rsc": "latest",
20+
"rsc-html-stream": "^0.0.7",
21+
"vite": "^8.1.4"
22+
}
23+
}

0 commit comments

Comments
 (0)