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
1 change: 1 addition & 0 deletions packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ npm create vite@latest -- --template rsc

- [`./examples/basic`](./examples/basic) - Comprehensive showcase of standard RSC features and the primary E2E test fixture.
- [`./examples/use-cache`](./examples/use-cache) - Minimal cache feature inspired by Next.js's `"use cache"`, built with generic transform and RSC runtime APIs.
- [`./examples/use-cache-callable`](./examples/use-cache-callable) - Inline cache wrapper exported as a callable Server Function through a custom transform.
- [`./examples/custom-server-function`](./examples/custom-server-function) - Third-party Server Function directive integration using server reference claims.
- [`./examples/ssg`](./examples/ssg) - Static site generation with MDX and client components for interactivity.
- [`./examples/ppr`](./examples/ppr) - Partial prerendering with a reusable static HTML shell and request-time RSC content.
Expand Down
260 changes: 260 additions & 0 deletions packages/plugin-rsc/e2e/use-cache-callable.test.ts
Comment thread
hi-ogawa marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { expect, test, type Locator, type Page } from '@playwright/test'
import { type Fixture, useFixture } from './fixture'
import { expectNoPageError, testNoJs, waitForHydration } from './helper'

test.describe('dev', () => {
const f = useFixture({ root: 'examples/use-cache-callable', mode: 'dev' })
defineTests(f)
})

test.describe('build', () => {
const f = useFixture({ root: 'examples/use-cache-callable', mode: 'build' })
defineTests(f)
})

function defineTests(f: Fixture) {
test('inline directive', async ({ page }) => {
using _errors = expectNoPageError(page)
await page.goto(f.url())
await waitForHydration(page)
await page.getByRole('link', { name: 'Inline directive' }).click()
await expect(page).toHaveURL(f.url('/inline-directive'))

const example = page.getByTestId('inline-directive')
const submissionCount = example.getByTestId('submission-count')
const executionCount = example.getByTestId('execution-count')
const result = example.getByTestId('result')
const argument = example.getByRole('textbox', { name: 'Cache key' })
await page.getByRole('button', { name: 'Reset' }).click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('0')
await expect(result).toHaveText('not called')

// The callable is submitted every time, but the cached implementation runs once per argument.
// alpha (cache miss)
await submit(page, example)
await expect(submissionCount).toHaveText('1')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('captured + alpha')

// alpha (cache hit)
await submit(page, example)
await expect(submissionCount).toHaveText('2')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('captured + alpha')

// beta (cache miss)
await argument.fill('beta')
await submit(page, example)
await expect(submissionCount).toHaveText('3')
await expect(executionCount).toHaveText('2')
await expect(result).toHaveText('captured + beta')
})

testNoJs('inline directive progressive enhancement', async ({ page }) => {
await page.goto(f.url('/inline-directive'))

const example = page.getByTestId('inline-directive')
const submissionCount = example.getByTestId('submission-count')
const executionCount = example.getByTestId('execution-count')
const result = example.getByTestId('result')
const argument = example.getByRole('textbox', { name: 'Cache key' })
const call = example.getByRole('button', { name: 'Call cached function' })

await page.getByRole('button', { name: 'Reset' }).click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('0')
await expect(result).toHaveText('not called')

// Native form submissions call the same cached function without hydration.
// alpha (cache miss)
await argument.fill('alpha')
await call.click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('captured + alpha')

// alpha (cache hit)
await argument.fill('alpha')
await call.click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('captured + alpha')

// beta (cache miss)
await argument.fill('beta')
await call.click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('2')
await expect(result).toHaveText('captured + beta')
})

test('file directive from server', async ({ page }) => {
using _errors = expectNoPageError(page)
await page.goto(f.url('/file-directive-from-server'))
await waitForHydration(page)

const example = page.getByTestId('file-directive-from-server')
const submissionCount = example.getByTestId('submission-count')
const executionCount = example.getByTestId('execution-count')
const result = example.getByTestId('result')
const argument = example.getByRole('textbox', { name: 'Cache key' })
await page.getByRole('button', { name: 'Reset' }).click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('0')
await expect(result).toHaveText('not called')

// The wrapped export is passed from a Server Component to a Client Component.
// alpha (cache miss)
await submit(page, example)
await expect(submissionCount).toHaveText('1')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('server import + alpha')

// alpha (cache hit)
await submit(page, example)
await expect(submissionCount).toHaveText('2')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('server import + alpha')

// beta (cache miss)
await argument.fill('beta')
await submit(page, example)
await expect(submissionCount).toHaveText('3')
await expect(executionCount).toHaveText('2')
await expect(result).toHaveText('server import + beta')
})

testNoJs(
'file directive from server progressive enhancement',
async ({ page }) => {
await page.goto(f.url('/file-directive-from-server'))

const example = page.getByTestId('file-directive-from-server')
const submissionCount = example.getByTestId('submission-count')
const executionCount = example.getByTestId('execution-count')
const result = example.getByTestId('result')
const argument = example.getByRole('textbox', { name: 'Cache key' })
const call = example.getByRole('button', { name: 'Call cached function' })

await page.getByRole('button', { name: 'Reset' }).click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('0')
await expect(result).toHaveText('not called')

// The wrapped export remains callable through native form submissions.
// alpha (cache miss)
await argument.fill('alpha')
await call.click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('server import + alpha')

// alpha (cache hit)
await argument.fill('alpha')
await call.click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('server import + alpha')

// beta (cache miss)
await argument.fill('beta')
await call.click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('2')
await expect(result).toHaveText('server import + beta')
},
)

test('file directive from client', async ({ page }) => {
using _errors = expectNoPageError(page)
await page.goto(f.url('/file-directive-from-client'))
await waitForHydration(page)

const example = page.getByTestId('file-directive-from-client')
const submissionCount = example.getByTestId('submission-count')
const executionCount = example.getByTestId('execution-count')
const result = example.getByTestId('result')
const argument = example.getByRole('textbox', { name: 'Cache key' })
await page.getByRole('button', { name: 'Reset' }).click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('0')
await expect(result).toHaveText('not called')

// The generated client proxy calls the wrapped export.
// alpha (cache miss)
await submit(page, example)
await expect(submissionCount).toHaveText('1')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('client import + alpha')

// alpha (cache hit)
await submit(page, example)
await expect(submissionCount).toHaveText('2')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('client import + alpha')

// beta (cache miss)
await argument.fill('beta')
await submit(page, example)
await expect(submissionCount).toHaveText('3')
await expect(executionCount).toHaveText('2')
await expect(result).toHaveText('client import + beta')
})

testNoJs(
'file directive from client progressive enhancement',
async ({ page }) => {
await page.goto(f.url('/file-directive-from-client'))

const example = page.getByTestId('file-directive-from-client')
const submissionCount = example.getByTestId('submission-count')
const executionCount = example.getByTestId('execution-count')
const result = example.getByTestId('result')
const argument = example.getByRole('textbox', { name: 'Cache key' })
const call = example.getByRole('button', { name: 'Call cached function' })

await page.getByRole('button', { name: 'Reset' }).click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('0')
await expect(result).toHaveText('not called')

// The generated client proxy remains callable through native form submissions.
// alpha (cache miss)
await argument.fill('alpha')
await call.click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('client import + alpha')

// alpha (cache hit)
await argument.fill('alpha')
await call.click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('1')
await expect(result).toHaveText('client import + alpha')

// beta (cache miss)
await argument.fill('beta')
await call.click()
await expect(submissionCount).toHaveText('0')
await expect(executionCount).toHaveText('2')
await expect(result).toHaveText('client import + beta')
},
)
}

async function submit(page: Page, form: Locator) {
// `submissionCount` updates immediately on the client, while a cache hit leaves
// the server-rendered execution count and result unchanged. Those assertions do
// not prove that the server action and subsequent render have completed, so wait
// for the action response before proceeding.
await Promise.all([
page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
response.url().includes('_.rsc'),
),
form.getByRole('button', { name: 'Call cached function' }).click(),
])
}
2 changes: 2 additions & 0 deletions packages/plugin-rsc/examples/use-cache-callable/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc'
import {
hasDirective,
transformDirectiveProxyExport,
transformHoistInlineDirective,
transformWrapExport,
} from '@vitejs/plugin-rsc/transforms'
import { parseAstAsync, type Plugin } from 'vite'

const directive = 'use cache'
const pluginName = 'example:use-cache-callable'

export function callableCachePlugin(): Plugin {
let manager: RscPluginManager

return {
name: pluginName,
configResolved(config) {
manager = getPluginApi(config)!.manager
},
async transform(code, id) {
if (!code.includes(directive)) {
manager.serverReferences.deleteClaim(pluginName, id)
return
}

const reference = manager.serverReferences.resolve(id, 'rsc')
const ast = (await parseAstAsync(code)) as unknown as Parameters<
typeof transformHoistInlineDirective
>[1]
const environmentName = this.environment.name

if (environmentName === 'rsc') {
const runtime = (value: string, name: string) =>
`$$ReactServer.registerServerReference(` +
`$$cacheWrapper(${value}),` +
`${JSON.stringify(reference.referenceKey)},` +
`${JSON.stringify(name)})`
const result = hasDirective(ast.body, directive)
? transformWrapExport(code, ast, {
runtime,
rejectNonAsyncFunction: true,
})
: transformHoistInlineDirective(code, ast, {
directive,
rejectNonAsyncFunction: true,
hoistRuntime: true,
runtime,
})
if (!result.output.hasChanged()) {
manager.serverReferences.deleteClaim(pluginName, id)
return
}

manager.serverReferences.replaceClaim(pluginName, id, {
...reference,
exportNames: 'names' in result ? result.names : result.exportNames,
})
result.output.prepend(
`import $$cacheWrapper from "/src/framework/use-cache-runtime";\n` +
`import * as $$ReactServer from "@vitejs/plugin-rsc/react/rsc/server";\n`,
)
return {
code: result.output.toString(),
map: result.output.generateMap({ hires: 'boundary' }),
}
}

const result = transformDirectiveProxyExport(ast, {
code,
directive,
rejectNonAsyncFunction: true,
runtime: (name) =>
`$$ReactClient.createServerReference(` +
`${JSON.stringify(reference.referenceKey + '#' + name)},` +
`$$ReactClient.callServer,` +
`undefined,` +
(this.environment.mode === 'dev'
? `$$ReactClient.findSourceMapURL,`
: `undefined,`) +
`${JSON.stringify(name)})`,
})
if (!result?.output.hasChanged()) {
manager.serverReferences.deleteClaim(pluginName, id)
return
}

manager.serverReferences.replaceClaim(pluginName, id, {
...reference,
exportNames: result.exportNames,
})
const runtimeEnvironment =
environmentName === 'client' ? 'browser' : 'ssr'
result.output.prepend(
`import * as $$ReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`,
)
return {
code: result.output.toString(),
map: result.output.generateMap({ hires: 'boundary' }),
}
},
}
}
23 changes: 23 additions & 0 deletions packages/plugin-rsc/examples/use-cache-callable/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@vitejs/plugin-rsc-examples-use-cache-callable",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "latest",
"@vitejs/plugin-rsc": "latest",
"rsc-html-stream": "^0.0.7",
"vite": "^8.1.5"
}
}
Loading
Loading