diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 01cadf3b0..7e87e9e74 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -448,6 +448,49 @@ function defineTest(f: Fixture) { const deps = manifest.clientReferenceDeps[hashString('src/routes/client.tsx')] expect(srcs).toEqual(expect.arrayContaining(deps.js)) + expect(manifest.clientEntryDeps.js.length).toBeGreaterThan(0) + expect(deps.js).toEqual( + expect.arrayContaining(manifest.clientEntryDeps.js), + ) + expect( + deps.js.some( + (href: string) => !manifest.clientEntryDeps.js.includes(href), + ), + ).toBe(true) + + const clientOnlyDependencyCounts = new Map() + for (const referenceDeps of Object.values( + manifest.clientReferenceDeps, + ) as { js: string[] }[]) { + for (const href of referenceDeps.js) { + if (!manifest.clientEntryDeps.js.includes(href)) { + clientOnlyDependencyCounts.set( + href, + (clientOnlyDependencyCounts.get(href) ?? 0) + 1, + ) + } + } + } + expect( + [...clientOnlyDependencyCounts.values()].some((count) => count > 1), + ).toBe(true) + + const clientBuildManifest: import('vite').Manifest = JSON.parse( + readFileSync(f.root + '/dist/client/.vite/manifest.json', 'utf-8'), + ) + const allReferenceJs = new Set( + Object.values(manifest.clientReferenceDeps).flatMap( + (referenceDeps) => (referenceDeps as { js: string[] }).js, + ), + ) + for (const dynamicModuleId of [ + 'src/routes/browser-only/browser-dep.tsx', + 'src/routes/lazy-css/client-to-client-child.tsx', + ]) { + const dynamicChunk = clientBuildManifest[dynamicModuleId] + expect(dynamicChunk).toBeDefined() + expect(allReferenceJs).not.toContain(`/${dynamicChunk!.file}`) + } }) }) diff --git a/packages/plugin-rsc/e2e/preload-priority.test.ts b/packages/plugin-rsc/e2e/preload-priority.test.ts new file mode 100644 index 000000000..7d4a9db84 --- /dev/null +++ b/packages/plugin-rsc/e2e/preload-priority.test.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs' +import { expect, test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' + +test.describe('custom client entry preload priority', () => { + const root = 'examples/e2e/temp/preload-priority-custom-entry' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter-extra', + dest: root, + files: { + 'vite.config.ts': { + edit: (source) => + source.replace('rsc()', 'rsc({ customClientEntry: true })'), + }, + 'src/framework/entry.ssr.tsx': { + edit: (source) => + source.replace( + ` const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index')`, + ` const bootstrapScriptContent = undefined`, + ), + }, + }, + }) + }) + + const f = useFixture({ root, mode: 'build' }) + + test('leaves framework-managed entry dependencies unclassified', () => { + const manifest = JSON.parse( + fs + .readFileSync( + f.root + '/dist/ssr/__vite_rsc_assets_manifest.js', + 'utf-8', + ) + .slice('export default '.length), + ) + + expect(manifest.clientEntryDeps).toBeUndefined() + expect(Object.keys(manifest.clientReferenceDeps).length).toBeGreaterThan(0) + expect( + Object.values(manifest.clientReferenceDeps).some( + (deps) => (deps as { js: string[] }).js.length > 0, + ), + ).toBe(true) + }) +}) diff --git a/packages/plugin-rsc/e2e/render-built-url.test.ts b/packages/plugin-rsc/e2e/render-built-url.test.ts index 9f51ecc1d..9cd1f156a 100644 --- a/packages/plugin-rsc/e2e/render-built-url.test.ts +++ b/packages/plugin-rsc/e2e/render-built-url.test.ts @@ -111,6 +111,32 @@ test.describe(() => { expect(manifestFileContent).toContain( `__dynamicBase + "assets/entry.rsc-`, ) + + const manifest = Function( + '__dynamicBase', + manifestFileContent.replace('export default', 'return'), + )('/custom-server/') + expect(manifest.clientEntryDeps.js.length).toBeGreaterThan(0) + expect( + manifest.clientEntryDeps.js.every((href: string) => + href.startsWith('/custom-server/'), + ), + ).toBe(true) + + const referenceDeps = Object.values(manifest.clientReferenceDeps) as { + js: string[] + }[] + expect(referenceDeps.length).toBeGreaterThan(0) + for (const deps of referenceDeps) { + expect(deps.js).toEqual( + expect.arrayContaining(manifest.clientEntryDeps.js), + ) + } + expect( + referenceDeps.some((deps) => + deps.js.some((href) => !manifest.clientEntryDeps.js.includes(href)), + ), + ).toBe(true) }) }) }) diff --git a/packages/plugin-rsc/src/core/ssr-resources.test.ts b/packages/plugin-rsc/src/core/ssr-resources.test.ts new file mode 100644 index 000000000..0b080e0b4 --- /dev/null +++ b/packages/plugin-rsc/src/core/ssr-resources.test.ts @@ -0,0 +1,152 @@ +import * as ReactDOM from 'react-dom' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { preloadClientReferenceDeps } from './ssr-resources' + +vi.mock('react-dom', () => ({ + preloadModule: vi.fn(), + preinit: vi.fn(), +})) + +describe(preloadClientReferenceDeps, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('preloads client reference JavaScript with low priority', () => { + preloadClientReferenceDeps( + { + js: ['/assets/client.js', '/assets/shared.js'], + css: [], + }, + [], + true, + ) + + expect(ReactDOM.preloadModule).toHaveBeenCalledTimes(2) + expect(ReactDOM.preloadModule).toHaveBeenNthCalledWith( + 1, + '/assets/client.js', + { + as: 'script', + crossOrigin: '', + fetchPriority: 'low', + }, + ) + expect(ReactDOM.preloadModule).toHaveBeenNthCalledWith( + 2, + '/assets/shared.js', + { + as: 'script', + crossOrigin: '', + fetchPriority: 'low', + }, + ) + }) + + test('preserves default priority for client entry dependencies', () => { + preloadClientReferenceDeps( + { + js: [ + '/assets/client.js', + '/assets/entry.js', + '/assets/entry-shared.js', + ], + css: [], + }, + ['/assets/entry.js', '/assets/entry-shared.js'], + true, + ) + + expect(ReactDOM.preloadModule).toHaveBeenNthCalledWith( + 1, + '/assets/client.js', + { + as: 'script', + crossOrigin: '', + fetchPriority: 'low', + }, + ) + expect(ReactDOM.preloadModule).toHaveBeenNthCalledWith( + 2, + '/assets/entry.js', + { + as: 'script', + crossOrigin: '', + }, + ) + expect(ReactDOM.preloadModule).toHaveBeenNthCalledWith( + 3, + '/assets/entry-shared.js', + { + as: 'script', + crossOrigin: '', + }, + ) + }) + + test('preserves default priority when a reference only uses entry dependencies', () => { + preloadClientReferenceDeps( + { + js: ['/assets/entry.js', '/assets/entry-shared.js'], + css: [], + }, + ['/assets/entry.js', '/assets/entry-shared.js'], + true, + ) + + expect(ReactDOM.preloadModule).toHaveBeenCalledTimes(2) + expect(ReactDOM.preloadModule).toHaveBeenNthCalledWith( + 1, + '/assets/entry.js', + { + as: 'script', + crossOrigin: '', + }, + ) + expect(ReactDOM.preloadModule).toHaveBeenNthCalledWith( + 2, + '/assets/entry-shared.js', + { + as: 'script', + crossOrigin: '', + }, + ) + }) + + test('keeps client reference CSS render blocking', () => { + preloadClientReferenceDeps( + { + js: [], + css: ['/assets/client.css'], + }, + [], + true, + ) + + expect(ReactDOM.preinit).toHaveBeenCalledWith('/assets/client.css', { + as: 'style', + precedence: 'vite-rsc/client-reference', + }) + }) + + test('supports disabling CSS link precedence without changing JS priority', () => { + preloadClientReferenceDeps( + { + js: ['/assets/client.js'], + css: ['/assets/client.css'], + }, + [], + false, + ) + + expect(ReactDOM.preloadModule).toHaveBeenCalledWith('/assets/client.js', { + as: 'script', + crossOrigin: '', + fetchPriority: 'low', + }) + expect(ReactDOM.preinit).toHaveBeenCalledWith('/assets/client.css', { + as: 'style', + precedence: undefined, + }) + }) +}) diff --git a/packages/plugin-rsc/src/core/ssr-resources.ts b/packages/plugin-rsc/src/core/ssr-resources.ts new file mode 100644 index 000000000..f1f26d6e0 --- /dev/null +++ b/packages/plugin-rsc/src/core/ssr-resources.ts @@ -0,0 +1,38 @@ +import * as ReactDOM from 'react-dom' +import type { ResolvedAssetDeps } from '../plugin' + +type PreloadModuleOptionsWithFetchPriority = NonNullable< + Parameters[1] +> & { + fetchPriority?: 'high' | 'low' | 'auto' +} + +export function preloadClientReferenceDeps( + deps: ResolvedAssetDeps, + clientEntryJs: readonly string[], + cssLinkPrecedence: boolean, +): void { + // Remove this cast once React's public types include fetchPriority. + const preloadModule = ReactDOM.preloadModule as ( + href: string, + options: PreloadModuleOptionsWithFetchPriority, + ) => void + + const clientEntryJsSet = new Set(clientEntryJs) + for (const href of deps.js) { + const options: PreloadModuleOptionsWithFetchPriority = { + as: 'script', + crossOrigin: '', + } + if (!clientEntryJsSet.has(href)) { + options.fetchPriority = 'low' + } + preloadModule(href, options) + } + for (const href of deps.css) { + ReactDOM.preinit(href, { + as: 'style', + precedence: cssLinkPrecedence ? 'vite-rsc/client-reference' : undefined, + }) + } +} diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 01ec7b083..3dd2d4b95 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -1158,6 +1158,7 @@ export function createRpcClient(params) { const assetDeps = collectAssetDeps(bundle) let bootstrapScriptContent: string | RuntimeAsset = '' + let clientEntryDeps: AssetDeps | undefined const clientReferenceDeps: Record = {} for (const meta of Object.values(manager.clientReferenceMetaMap)) { @@ -1182,9 +1183,9 @@ export function createRpcClient(params) { `[vite-rsc] Client build must have an entry chunk named "index". Use 'customClientEntry' option to disable this requirement.`, ) } - const entryDeps = assetsURLOfDeps(entry.deps, manager) + clientEntryDeps = assetsURLOfDeps(entry.deps, manager) for (const [key, deps] of Object.entries(clientReferenceDeps)) { - clientReferenceDeps[key] = mergeAssetDeps(deps, entryDeps) + clientReferenceDeps[key] = mergeAssetDeps(deps, clientEntryDeps) } const entryUrl = assetsURL(entry.chunk.fileName, manager) if (typeof entryUrl === 'string') { @@ -1198,6 +1199,7 @@ export function createRpcClient(params) { manager.buildAssetsManifest = { bootstrapScriptContent, + clientEntryDeps, clientReferenceDeps, serverResources, cssLinkPrecedence: rscPluginOptions.cssLinkPrecedence, @@ -2190,6 +2192,7 @@ function assetsURLOfDeps(deps: AssetDeps, manager: RscPluginManager) { export type AssetsManifest = { bootstrapScriptContent: string | RuntimeAsset + clientEntryDeps?: AssetDeps clientReferenceDeps: Record serverResources?: Record> cssLinkPrecedence?: boolean @@ -2202,6 +2205,7 @@ export type AssetDeps = { export type ResolvedAssetsManifest = { bootstrapScriptContent: string + clientEntryDeps?: ResolvedAssetDeps clientReferenceDeps: Record serverResources?: Record> cssLinkPrecedence?: boolean diff --git a/packages/plugin-rsc/src/ssr.tsx b/packages/plugin-rsc/src/ssr.tsx index 2fb4fd40b..6814dd815 100644 --- a/packages/plugin-rsc/src/ssr.tsx +++ b/packages/plugin-rsc/src/ssr.tsx @@ -1,7 +1,7 @@ -import * as ReactDOM from 'react-dom' import assetsManifest from 'virtual:vite-rsc/assets-manifest' import * as clientReferences from 'virtual:vite-rsc/client-references' import { setRequireModule } from './core/ssr' +import { preloadClientReferenceDeps } from './core/ssr-resources' import type { ResolvedAssetDeps } from './plugin' import { toCssVirtual, toReferenceValidationVirtual } from './plugins/shared' @@ -82,21 +82,9 @@ function wrapResourceProxy(mod: any, id: string, deps: ResolvedAssetDeps) { } function preloadDeps(deps: ResolvedAssetDeps) { - for (const href of deps.js) { - ReactDOM.preloadModule(href, { - as: 'script', - // vite doesn't allow configuring crossorigin at the moment, so we can hard code it as well. - // https://github.com/vitejs/vite/issues/6648 - crossOrigin: '', - }) - } - for (const href of deps.css) { - ReactDOM.preinit(href, { - as: 'style', - precedence: - assetsManifest.cssLinkPrecedence !== false - ? 'vite-rsc/client-reference' - : undefined, - }) - } + preloadClientReferenceDeps( + deps, + assetsManifest.clientEntryDeps?.js ?? [], + assetsManifest.cssLinkPrecedence !== false, + ) }