-
-
Notifications
You must be signed in to change notification settings - Fork 260
feat(rsc): support hoisting with runtime wrapper in transformHoistInlineDirective + add callable use cache example
#1330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
e69bc30
feat(rsc): hoist inline directive runtimes
hi-ogawa 9913cee
Merge branch 'main' into callable-inline-hoist
hi-ogawa 6833638
fix: lockfile
hi-ogawa adb9b37
Merge branch 'main' into callable-inline-hoist
hi-ogawa f3ff93e
test(rsc): use fixtures for hoisted runtimes
hi-ogawa f27b72c
test(rsc): simplify hoisted runtime fixture
hi-ogawa 39d3893
refactor(rsc): place runtime hoists before imports
hi-ogawa a0413a3
test(rsc): cover callable cache file directives
hi-ogawa-agent bbbae0d
refactor(rsc): align callable cache example framework
hi-ogawa 2a5ddef
refactor(rsc): route callable cache examples
hi-ogawa 018f110
test(rsc): cover callable progressive enhancement
hi-ogawa 4104915
Merge branch 'main' into callable-inline-hoist
hi-ogawa a60e1b7
chore(rsc): align callable cache example framework
hi-ogawa 019c955
refactor(rsc): simplify example FormData cache keys
hi-ogawa 9b26887
test(rsc): clarify callable cache scenarios
hi-ogawa 677b4c5
test(rsc): explain callable submit synchronization
hi-ogawa a2ea49c
test(rsc): cover callable cache progressive forms
hi-ogawa 6643232
test(rsc): align callable cache scenarios
hi-ogawa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(), | ||
| ]) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| node_modules | ||
| dist |
103 changes: 103 additions & 0 deletions
103
packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
23
packages/plugin-rsc/examples/use-cache-callable/package.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.