From e6ae5cceadda10f5f7b63f13fdd6bad9747a887f Mon Sep 17 00:00:00 2001 From: James Date: Sat, 10 Oct 2026 00:14:36 +0100 Subject: [PATCH 1/2] fix(rsc): drop Server Function source from client sourcemaps --- packages/plugin-rsc/e2e/source-map.test.ts | 38 ++++++++++++++++++++++ packages/plugin-rsc/src/plugin.ts | 10 +++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/e2e/source-map.test.ts b/packages/plugin-rsc/e2e/source-map.test.ts index ba3578710..020b6d40d 100644 --- a/packages/plugin-rsc/e2e/source-map.test.ts +++ b/packages/plugin-rsc/e2e/source-map.test.ts @@ -1,4 +1,6 @@ +import fs from 'node:fs' import { SourceMap, type SourceMapPayload } from 'node:module' +import path from 'node:path' import { expect, test, type Page } from '@playwright/test' import * as vite from 'vite' import { useFixture } from './fixture' @@ -222,6 +224,42 @@ test.describe('source map', () => { }) }) +test.describe('source map build', () => { + const f = useFixture({ + root: 'examples/source-map', + mode: 'build', + buildCommand: 'pnpm build --sourcemap', + }) + + // Next.js covers the same case in "should not expose action content in sourcemaps": + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts + test('keeps Server Function source out of client source maps', () => { + const clientDir = path.join(f.root, 'dist/client') + const sources = new Map() + for (const file of fs.readdirSync(clientDir, { recursive: true })) { + if (!String(file).endsWith('.js.map')) continue + const map = JSON.parse( + fs.readFileSync(path.join(clientDir, String(file)), 'utf-8'), + ) as SourceMapPayload + map.sources.forEach((source, i) => { + sources.set(source, map.sourcesContent?.[i] ?? null) + }) + } + + // The client component importing the Server Function keeps its source. + const client = [...sources].find(([source]) => + source.endsWith('/server-reference-from-client/client.tsx'), + ) + expect(client?.[1]).toContain('clientImportedAction') + + // Its proxy must not carry the Server Function module's source. + const leaked = [...sources] + .filter(([, content]) => content?.includes('use server')) + .map(([source]) => source) + expect(leaked).toEqual([]) + }) +}) + async function createFunctionSourceMapResolver(page: Page, baseURL: string) { const session = await page.context().newCDPSession(page) const scripts = new Map() diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 848993609..de4fb58db 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -2156,7 +2156,15 @@ function vitePluginUseServer( ) return { code: output.toString(), - map: output.generateMap({ hires: 'boundary' }), + // The proxy must not map back to the server module in browser + // builds, or its source ships in the client sourcemap's + // `sourcesContent`. Next.js drops these mappings the same way. + // https://github.com/vercel/next.js/pull/76157 + map: + this.environment.mode === 'build' && + this.environment.name === browserEnvironmentName + ? { mappings: '' } + : output.generateMap({ hires: 'boundary' }), } } }, From 4a5d422457e136358d1b4083a2c7377aa8034447 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 10 Oct 2026 00:19:38 +0100 Subject: [PATCH 2/2] test(rsc): check source maps of the scripts the page loads --- packages/plugin-rsc/e2e/source-map.test.ts | 63 +++++++++++++--------- packages/plugin-rsc/src/plugin.ts | 3 +- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/plugin-rsc/e2e/source-map.test.ts b/packages/plugin-rsc/e2e/source-map.test.ts index 020b6d40d..7ec32a532 100644 --- a/packages/plugin-rsc/e2e/source-map.test.ts +++ b/packages/plugin-rsc/e2e/source-map.test.ts @@ -1,6 +1,4 @@ -import fs from 'node:fs' import { SourceMap, type SourceMapPayload } from 'node:module' -import path from 'node:path' import { expect, test, type Page } from '@playwright/test' import * as vite from 'vite' import { useFixture } from './fixture' @@ -231,35 +229,52 @@ test.describe('source map build', () => { buildCommand: 'pnpm build --sourcemap', }) - // Next.js covers the same case in "should not expose action content in sourcemaps": - // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts - test('keeps Server Function source out of client source maps', () => { - const clientDir = path.join(f.root, 'dist/client') - const sources = new Map() - for (const file of fs.readdirSync(clientDir, { recursive: true })) { - if (!String(file).endsWith('.js.map')) continue - const map = JSON.parse( - fs.readFileSync(path.join(clientDir, String(file)), 'utf-8'), - ) as SourceMapPayload - map.sources.forEach((source, i) => { - sources.set(source, map.sourcesContent?.[i] ?? null) - }) - } + test('omits Server Reference sources on /server-reference-from-client', async ({ + page, + }) => { + await page.goto(f.url('/server-reference-from-client')) + await waitForHydration(page) - // The client component importing the Server Function keeps its source. - const client = [...sources].find(([source]) => + const sources = await loadClientSources(page) + const clientSource = [...sources].find(([source]) => source.endsWith('/server-reference-from-client/client.tsx'), - ) - expect(client?.[1]).toContain('clientImportedAction') - - // Its proxy must not carry the Server Function module's source. - const leaked = [...sources] + )?.[1] + expect(clientSource).toContain('clientImportedAction') + const serverSources = [...sources] .filter(([, content]) => content?.includes('use server')) .map(([source]) => source) - expect(leaked).toEqual([]) + expect(serverSources).toEqual([]) }) }) +// Collect `sourcesContent` from the source maps of every script the page loaded. +async function loadClientSources(page: Page) { + const scriptURLs = await page.evaluate(() => + performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .filter((url) => new URL(url).pathname.endsWith('.js')), + ) + const sources = new Map() + for (const scriptURL of scriptURLs) { + const script = await page.request.get(scriptURL) + const sourceMappingURL = /\/\/# sourceMappingURL=(\S+)\s*$/.exec( + await script.text(), + )?.[1] + if (!sourceMappingURL) continue + const response = await page.request.get( + new URL(sourceMappingURL, scriptURL).href, + ) + expect(response.ok()).toBe(true) + const payload = (await response.json()) as Partial + payload.sources!.forEach((source, i) => { + sources.set(source, payload.sourcesContent?.[i] ?? null) + }) + } + expect(sources.size).toBeGreaterThan(0) + return sources +} + async function createFunctionSourceMapResolver(page: Page, baseURL: string) { const session = await page.context().newCDPSession(page) const scripts = new Map() diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index de4fb58db..a03a7a05d 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -2158,8 +2158,7 @@ function vitePluginUseServer( code: output.toString(), // The proxy must not map back to the server module in browser // builds, or its source ships in the client sourcemap's - // `sourcesContent`. Next.js drops these mappings the same way. - // https://github.com/vercel/next.js/pull/76157 + // `sourcesContent`. map: this.environment.mode === 'build' && this.environment.name === browserEnvironmentName