Skip to content
5 changes: 5 additions & 0 deletions .changeset/thin-turkeys-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: reimplement treeshaking non-dynamic prerendered remote functions
126 changes: 126 additions & 0 deletions packages/kit/src/exports/vite/build/remote.js
Original file line number Diff line number Diff line change
@@ -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<import('vitest/config').ViteUserConfig['build']>['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);
}
}
}
}
12 changes: 11 additions & 1 deletion packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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`,
Expand Down
16 changes: 12 additions & 4 deletions packages/kit/test/apps/async/test/server.test.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
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);

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);
});
});
Loading