diff --git a/.changeset/thin-turkeys-give.md b/.changeset/thin-turkeys-give.md new file mode 100644 index 000000000000..111dec8b6bc2 --- /dev/null +++ b/.changeset/thin-turkeys-give.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: reimplement treeshaking non-dynamic prerendered remote functions diff --git a/packages/kit/src/exports/vite/build/remote.js b/packages/kit/src/exports/vite/build/remote.js new file mode 100644 index 000000000000..689882593e92 --- /dev/null +++ b/packages/kit/src/exports/vite/build/remote.js @@ -0,0 +1,126 @@ +/** @import { ServerMetadata } from 'types' */ +/** @import { OutputBundle } from 'rollup' */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { Parser } from 'acorn'; +import MagicString from 'magic-string'; +import { posixify } from '../../../utils/filesystem.js'; +import { import_peer } from '../../../utils/import.js'; + +/** + * @param {string} out + * @param {Array<{ hash: string, file: string }>} remotes + * @param {ServerMetadata} metadata + * @param {string} cwd + * @param {OutputBundle} server_bundle + * @param {NonNullable['sourcemap']} sourcemap + */ +export async function treeshake_prerendered_remotes( + out, + remotes, + metadata, + cwd, + server_bundle, + sourcemap +) { + if (remotes.length === 0) return; + + const vite = /** @type {typeof import('vite')} */ (await import_peer('vite')); + + for (const remote of remotes) { + const exports_map = metadata.remotes.get(remote.hash); + if (!exports_map) continue; + + /** @type {string[]} */ + const dynamic = []; + /** @type {string[]} */ + const prerendered = []; + + for (const [name, value] of exports_map) { + (value.dynamic ? dynamic : prerendered).push(name); + } + + if (prerendered.length === 0) continue; // nothing to treeshake + + // remove file extension + const remote_filename = path.basename(remote.file).split('.').slice(0, -1).join('.'); + + const remote_chunk = Object.values(server_bundle).find((chunk) => { + return chunk.name === remote_filename; + }); + + if (!remote_chunk) continue; + + const chunk_path = posixify(path.relative(cwd, `${out}/server/${remote_chunk.fileName}`)); + + const code = fs.readFileSync(chunk_path, 'utf-8'); + const parsed = Parser.parse(code, { sourceType: 'module', ecmaVersion: 'latest' }); + const modified_code = new MagicString(code); + + for (const fn of prerendered) { + for (const node of parsed.body) { + const declaration = + node.type === 'ExportNamedDeclaration' + ? node.declaration + : node.type === 'VariableDeclaration' + ? node + : null; + + if (!declaration || declaration.type !== 'VariableDeclaration') continue; + + for (const declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier' && declarator.id.name === fn) { + modified_code.overwrite( + node.start, + node.end, + `const ${fn} = prerender('unchecked', () => { throw new Error('Unexpectedly called prerender function. Did you forget to set { dynamic: true } ?') });` + ); + } + } + } + } + + for (const node of parsed.body) { + if (node.type === 'ExportDefaultDeclaration') { + modified_code.remove(node.start, node.end); + } + } + + const stubbed = modified_code.toString(); + fs.writeFileSync(chunk_path, stubbed); + + const bundle = /** @type {import('vite').Rollup.RollupOutput} */ ( + await vite.build({ + configFile: false, + build: { + write: false, + ssr: true, + target: 'esnext', + sourcemap, + rollupOptions: { + // avoid resolving imports + external: (id) => !id.endsWith(chunk_path), + input: { + treeshaken: chunk_path + } + } + } + }) + ); + + const chunk = bundle.output.find( + (output) => output.type === 'chunk' && output.name === 'treeshaken' + ); + if (chunk && chunk.type === 'chunk') { + fs.writeFileSync(chunk_path, chunk.code); + + const chunk_sourcemap = bundle.output.find( + (output) => output.type === 'asset' && output.fileName === chunk.fileName + '.map' + ); + if (chunk_sourcemap && chunk_sourcemap.type === 'asset') { + fs.writeFileSync(chunk_path + '.map', chunk_sourcemap.source); + } + } + } +} diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 7d79bd897aa8..3bebedb7757a 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -41,6 +41,7 @@ import { import { import_peer } from '../../utils/import.js'; import { compact } from '../../utils/array.js'; import { should_ignore, has_children } from './static_analysis/utils.js'; +import { treeshake_prerendered_remotes } from './build/remote.js'; const cwd = posixify(process.cwd()); @@ -1053,7 +1054,7 @@ async function kit({ svelte_config }) { */ writeBundle: { sequential: true, - async handler(_options) { + async handler(_options, server_bundle) { if (secondary_build_started) return; // only run this once const verbose = vite_config.logLevel === 'info'; @@ -1308,6 +1309,15 @@ async function kit({ svelte_config }) { env: { ...env.private, ...env.public } }); + await treeshake_prerendered_remotes( + out, + remotes, + metadata, + cwd, + server_bundle, + vite_config.build.sourcemap + ); + // generate a new manifest that doesn't include prerendered pages fs.writeFileSync( `${out}/server/manifest.js`, diff --git a/packages/kit/test/apps/async/test/server.test.js b/packages/kit/test/apps/async/test/server.test.js index 69412eadbcbe..424524ebd262 100644 --- a/packages/kit/test/apps/async/test/server.test.js +++ b/packages/kit/test/apps/async/test/server.test.js @@ -1,9 +1,9 @@ -import process from 'node:process'; -import { expect } from '@playwright/test'; -import { test } from '../../../utils.js'; import fs from 'node:fs'; +import process from 'node:process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { expect } from '@playwright/test'; +import { test } from '../../../utils.js'; test.skip(({ javaScriptEnabled }) => javaScriptEnabled); @@ -11,7 +11,15 @@ const root = path.resolve(fileURLToPath(import.meta.url), '..', '..'); test.describe('remote functions', () => { test("doesn't write bundle to disk when treeshaking prerendered remote functions", () => { - test.skip(!!process.env.DEV, 'skip when in dev mode'); + test.skip(!!process.env.DEV, 'only applicable after build'); expect(fs.existsSync(path.join(root, 'dist'))).toBe(false); }); + + test('non-dynamic prerendered remote functions are treeshaken', () => { + test.skip(!!process.env.DEV, 'only applicable after build'); + const code = fs.readFileSync( + path.join(root, '.svelte-kit', 'output', 'server', 'chunks', 'prerender.remote.js') + ); + expect(code.includes('const with_read = prerender(')).toBe(false); + }); });