From 20f2720aa3455f38fa2630a33d52f7532da27fce Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Thu, 19 Dec 2024 15:53:29 +0100 Subject: [PATCH] feat: add `bundleStrategy` option (#13173) * feat: add `codeSplitJs` option * fix: only use `manualChunks` when `!codeSplitJS` * Create beige-carpets-wave.md * chore: regenerate types * chore: add option to `options` app for tests * chore: move tests from `options` to `options-2` and add test for bundle * chore: rename chunk, add css test * chore: rename option to `codeSplit` * chore: apply suggestions from code review * this looks unintentional, reverting * tweak docs * DRY out * get rid of Promise.all when codeSplit: false * types * rename to bundleStrategy * more detail * pretty sure this is unused * tweak * small tweaks * Update .changeset/beige-carpets-wave.md --------- Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/beige-carpets-wave.md | 5 ++ packages/kit/src/core/config/index.spec.js | 2 +- packages/kit/src/core/config/options.js | 3 +- packages/kit/src/exports/public.d.ts | 9 +++ .../src/exports/vite/build/build_server.js | 5 +- packages/kit/src/exports/vite/index.js | 72 ++++++++++++++----- packages/kit/src/runtime/client/bundle.js | 15 ++++ packages/kit/src/runtime/client/client.js | 10 +-- packages/kit/src/runtime/client/types.d.ts | 20 +++++- .../kit/src/runtime/server/page/render.js | 28 ++++---- packages/kit/src/types/internal.d.ts | 2 +- .../apps/options-2/src/routes/+page.svelte | 6 ++ .../routes/deeply/nested/page/+page.svelte | 6 ++ .../options-2/src/routes/hello/+page.svelte | 6 ++ .../kit/test/apps/options-2/svelte.config.js | 3 + packages/kit/test/apps/options-2/test/test.js | 21 +++++- packages/kit/types/index.d.ts | 11 ++- 17 files changed, 177 insertions(+), 47 deletions(-) create mode 100644 .changeset/beige-carpets-wave.md create mode 100644 packages/kit/src/runtime/client/bundle.js diff --git a/.changeset/beige-carpets-wave.md b/.changeset/beige-carpets-wave.md new file mode 100644 index 000000000000..605fcea37217 --- /dev/null +++ b/.changeset/beige-carpets-wave.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `bundleStrategy: 'split' | 'single'` option diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 35ca362837d5..ed07bc12770d 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -92,7 +92,7 @@ const get_defaults = (prefix = '') => ({ }, inlineStyleThreshold: 0, moduleExtensions: ['.js', '.ts'], - output: { preloadStrategy: 'modulepreload' }, + output: { preloadStrategy: 'modulepreload', bundleStrategy: 'split' }, outDir: join(prefix, '.svelte-kit'), serviceWorker: { register: true diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 70d631d3ada6..5f12c5f99d6d 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -142,7 +142,8 @@ const options = object( outDir: string('.svelte-kit'), output: object({ - preloadStrategy: list(['modulepreload', 'preload-js', 'preload-mjs'], 'modulepreload') + preloadStrategy: list(['modulepreload', 'preload-js', 'preload-mjs']), + bundleStrategy: list(['split', 'single']) }), paths: object({ diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 4dd2d48cb5a6..41c440fcdcb1 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -498,6 +498,15 @@ export interface KitConfig { * @since 1.8.4 */ preloadStrategy?: 'modulepreload' | 'preload-js' | 'preload-mjs'; + /** + * If `'split'`, splits the app up into multiple .js/.css files so that they are loaded lazily as the user navigates around the app. This is the default, and is recommended for most scenarios. + * If `'single'`, creates just one .js bundle and one .css file containing code for the entire app. + * + * When using `'split'`, you can also adjust the bundling behaviour by setting [`output.experimentalMinChunkSize`](https://rollupjs.org/configuration-options/#output-experimentalminchunksize) and [`output.manualChunks`](https://rollupjs.org/configuration-options/#output-manualchunks)inside your Vite config's [`build.rollupOptions`](https://vite.dev/config/build-options.html#build-rollupoptions). + * @default 'split' + * @since 2.13.0 + */ + bundleStrategy?: 'split' | 'single'; }; paths?: { /** diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index f13c29872e38..f951d896ec97 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -11,8 +11,9 @@ import { normalizePath } from 'vite'; * @param {import('vite').Manifest} server_manifest * @param {import('vite').Manifest | null} client_manifest * @param {import('vite').Rollup.OutputAsset[] | null} css + * @param {import('types').RecursiveRequired} output_config */ -export function build_server_nodes(out, kit, manifest_data, server_manifest, client_manifest, css) { +export function build_server_nodes(out, kit, manifest_data, server_manifest, client_manifest, css, output_config) { mkdirp(`${out}/server/nodes`); mkdirp(`${out}/server/stylesheets`); @@ -69,7 +70,7 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli exports.push(`export const server_id = ${s(node.server)};`); } - if (client_manifest && (node.universal || node.component)) { + if (client_manifest && (node.universal || node.component) && output_config.bundleStrategy === 'split') { const entry = find_deps( client_manifest, `${normalizePath(kit.outDir)}/generated/client-optimized/nodes/${i}.js`, diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 2c76a71f0026..ad519c900ca3 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -361,6 +361,10 @@ async function kit({ svelte_config }) { name: 'vite-plugin-sveltekit-virtual-modules', resolveId(id, importer) { + if (id === '__sveltekit/manifest') { + return `${kit.outDir}/generated/client-optimized/app.js`; + } + // If importing from a service-worker, only allow $service-worker & $env/static/public, but none of the other virtual modules. // This check won't catch transitive imports, but it will warn when the import comes from a service-worker directly. // Transitive imports will be caught during the build. @@ -605,10 +609,11 @@ async function kit({ svelte_config }) { const name = posixify(path.join('entries/matchers', key)); input[name] = path.resolve(file); }); + } else if (svelte_config.kit.output.bundleStrategy !== 'split') { + input['bundle'] = `${runtime_directory}/client/bundle.js`; } else { input['entry/start'] = `${runtime_directory}/client/entry.js`; input['entry/app'] = `${kit.outDir}/generated/client-optimized/app.js`; - manifest_data.nodes.forEach((node, i) => { if (node.component || node.universal) { input[`nodes/${i}`] = `${kit.outDir}/generated/client-optimized/nodes/${i}.js`; @@ -643,7 +648,9 @@ async function kit({ svelte_config }) { chunkFileNames: ssr ? 'chunks/[name].js' : `${prefix}/chunks/[name].[hash].${ext}`, assetFileNames: `${prefix}/assets/[name].[hash][extname]`, hoistTransitiveImports: false, - sourcemapIgnoreList + sourcemapIgnoreList, + manualChunks: + svelte_config.kit.output.bundleStrategy === 'single' ? () => 'bundle' : undefined }, preserveEntrySignatures: 'strict' }, @@ -775,7 +782,15 @@ async function kit({ svelte_config }) { // first, build server nodes without the client manifest so we can analyse it log.info('Analysing routes'); - build_server_nodes(out, kit, manifest_data, server_manifest, null, null); + build_server_nodes( + out, + kit, + manifest_data, + server_manifest, + null, + null, + svelte_config.output + ); const metadata = await analyse({ manifest_path, @@ -825,19 +840,34 @@ async function kit({ svelte_config }) { const deps_of = /** @param {string} f */ (f) => find_deps(client_manifest, posixify(path.relative('.', f)), false); - const start = deps_of(`${runtime_directory}/client/entry.js`); - const app = deps_of(`${kit.outDir}/generated/client-optimized/app.js`); - - build_data.client = { - start: start.file, - app: app.file, - imports: [...start.imports, ...app.imports], - stylesheets: [...start.stylesheets, ...app.stylesheets], - fonts: [...start.fonts, ...app.fonts], - uses_env_dynamic_public: output.some( - (chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public] - ) - }; + + if (svelte_config.kit.output.bundleStrategy === 'split') { + const start = deps_of(`${runtime_directory}/client/entry.js`); + const app = deps_of(`${kit.outDir}/generated/client-optimized/app.js`); + + build_data.client = { + start: start.file, + app: app.file, + imports: [...start.imports, ...app.imports], + stylesheets: [...start.stylesheets, ...app.stylesheets], + fonts: [...start.fonts, ...app.fonts], + uses_env_dynamic_public: output.some( + (chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public] + ) + }; + } else { + const start = deps_of(`${runtime_directory}/client/bundle.js`); + + build_data.client = { + start: start.file, + imports: start.imports, + stylesheets: start.stylesheets, + fonts: start.fonts, + uses_env_dynamic_public: output.some( + (chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public] + ) + }; + } const css = output.filter( /** @type {(value: any) => value is import('vite').Rollup.OutputAsset} */ @@ -855,7 +885,15 @@ async function kit({ svelte_config }) { ); // regenerate nodes with the client manifest... - build_server_nodes(out, kit, manifest_data, server_manifest, client_manifest, css); + build_server_nodes( + out, + kit, + manifest_data, + server_manifest, + client_manifest, + css, + svelte_config.kit.output + ); // ...and prerender const { prerendered, prerender_map } = await prerender({ diff --git a/packages/kit/src/runtime/client/bundle.js b/packages/kit/src/runtime/client/bundle.js new file mode 100644 index 000000000000..9ef13c0f2bd2 --- /dev/null +++ b/packages/kit/src/runtime/client/bundle.js @@ -0,0 +1,15 @@ +/* if `bundleStrategy === 'single'`, this file is used as the entry point */ + +import * as kit from './entry.js'; + +// @ts-expect-error +import * as app from '__sveltekit/manifest'; + +/** + * + * @param {HTMLElement} element + * @param {import('./types.js').HydrateOptions} options + */ +export function start(element, options) { + kit.start(app, element, options); +} diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index d03baae091b0..e28f1ce0547d 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2354,15 +2354,7 @@ function _start_router() { /** * @param {HTMLElement} target - * @param {{ - * status: number; - * error: App.Error | null; - * node_ids: number[]; - * params: Record; - * route: { id: string | null }; - * data: Array; - * form: Record | null; - * }} opts + * @param {import('./types.js').HydrateOptions} opts */ async function _hydrate( target, diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 78b740e78393..117828218c75 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -1,5 +1,13 @@ import { SvelteComponent } from 'svelte'; -import { ClientHooks, CSRPageNode, CSRPageNodeLoader, CSRRoute, TrailingSlash, Uses } from 'types'; +import { + ClientHooks, + CSRPageNode, + CSRPageNodeLoader, + CSRRoute, + ServerDataNode, + TrailingSlash, + Uses +} from 'types'; import { Page, ParamMatcher } from '@sveltejs/kit'; export interface SvelteKitApp { @@ -88,3 +96,13 @@ export interface NavigationState { route: CSRRoute | null; url: URL; } + +export interface HydrateOptions { + status: number; + error: App.Error | null; + node_ids: number[]; + params: Record; + route: { id: string | null }; + data: Array; + form: Record | null; +} diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index a3e9cddd8866..004a5ca59a2c 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -354,7 +354,7 @@ export async function render_response({ ${properties.join(',\n\t\t\t\t\t\t')} };`); - const args = ['app', 'element']; + const args = ['element']; blocks.push('const element = document.currentScript.parentElement;'); @@ -392,24 +392,26 @@ export async function render_response({ args.push(`{\n${indent}\t${hydrate.join(`,\n${indent}\t`)}\n${indent}}`); } + // `client.app` is a proxy for `bundleStrategy !== 'single'` + const boot = client.app + ? `Promise.all([ + import(${s(prefixed(client.start))}), + import(${s(prefixed(client.app))}) + ]).then(([kit, app]) => { + kit.start(app, ${args.join(', ')}); + });` + : `import(${s(prefixed(client.start))}).then((app) => { + app.start(${args.join(', ')}) + });`; + if (load_env_eagerly) { blocks.push(`import(${s(`${base}/${options.app_dir}/env.js`)}).then(({ env }) => { ${global}.env = env; - Promise.all([ - import(${s(prefixed(client.start))}), - import(${s(prefixed(client.app))}) - ]).then(([kit, app]) => { - kit.start(${args.join(', ')}); - }); + ${boot.replace(/\n/g, '\n\t')} });`); } else { - blocks.push(`Promise.all([ - import(${s(prefixed(client.start))}), - import(${s(prefixed(client.app))}) - ]).then(([kit, app]) => { - kit.start(${args.join(', ')}); - });`); + blocks.push(boot); } if (options.service_worker) { diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index d838add28a54..b20b155b050f 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -69,7 +69,7 @@ export interface BuildData { service_worker: string | null; client: { start: string; - app: string; + app?: string; imports: string[]; stylesheets: string[]; fonts: string[]; diff --git a/packages/kit/test/apps/options-2/src/routes/+page.svelte b/packages/kit/test/apps/options-2/src/routes/+page.svelte index badacc8173d8..c026409d91ee 100644 --- a/packages/kit/test/apps/options-2/src/routes/+page.svelte +++ b/packages/kit/test/apps/options-2/src/routes/+page.svelte @@ -8,3 +8,9 @@

assets: {assets}

Go to /hello + + diff --git a/packages/kit/test/apps/options-2/src/routes/deeply/nested/page/+page.svelte b/packages/kit/test/apps/options-2/src/routes/deeply/nested/page/+page.svelte index d6232afc0cc9..ee77d1c9d5b5 100644 --- a/packages/kit/test/apps/options-2/src/routes/deeply/nested/page/+page.svelte +++ b/packages/kit/test/apps/options-2/src/routes/deeply/nested/page/+page.svelte @@ -6,3 +6,9 @@

base: {base}

assets: {assets}

+ + diff --git a/packages/kit/test/apps/options-2/src/routes/hello/+page.svelte b/packages/kit/test/apps/options-2/src/routes/hello/+page.svelte index a994afef288b..8f60c2df52bc 100644 --- a/packages/kit/test/apps/options-2/src/routes/hello/+page.svelte +++ b/packages/kit/test/apps/options-2/src/routes/hello/+page.svelte @@ -1 +1,7 @@

Prerendered

+ + diff --git a/packages/kit/test/apps/options-2/svelte.config.js b/packages/kit/test/apps/options-2/svelte.config.js index ca9cb32020f8..dd4414575a39 100644 --- a/packages/kit/test/apps/options-2/svelte.config.js +++ b/packages/kit/test/apps/options-2/svelte.config.js @@ -10,6 +10,9 @@ const config = { }, env: { dir: '../../env' + }, + output: { + bundleStrategy: 'single' } } }; diff --git a/packages/kit/test/apps/options-2/test/test.js b/packages/kit/test/apps/options-2/test/test.js index 1633e55d28b4..5cfc8c8fed69 100644 --- a/packages/kit/test/apps/options-2/test/test.js +++ b/packages/kit/test/apps/options-2/test/test.js @@ -78,7 +78,7 @@ test.describe('Service worker', () => { }); expect(self.base).toBe('/basepath'); - expect(self.build[0]).toMatch(/\/basepath\/_app\/immutable\/entry\/start\.[\w-]+\.js/); + expect(self.build[0]).toMatch(/\/basepath\/_app\/immutable\/bundle\.[\w-]+\.js/); expect(self.image_src).toMatch(/\/basepath\/_app\/immutable\/assets\/image\.[\w-]+\.jpg/); }); @@ -87,3 +87,22 @@ test.describe('Service worker', () => { expect(await page.content()).not.toMatch(/navigator\.serviceWorker/); }); }); + +test.describe("bundleStrategy: 'single'", () => { + test.skip(({ javaScriptEnabled }) => !javaScriptEnabled || !!process.env.DEV); + test('loads a single js file and a single css file', async ({ page }) => { + /** @type {string[]} */ + const requests = []; + page.on('request', (r) => requests.push(new URL(r.url()).pathname)); + + await page.goto('/basepath'); + + await Promise.all([ + page.waitForTimeout(100), // wait for preloading to start + page.waitForLoadState('networkidle') // wait for preloading to finish + ]); + + expect(requests.filter((req) => req.endsWith('.js')).length).toBe(1); + expect(requests.filter((req) => req.endsWith('.css')).length).toBe(1); + }); +}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index eb3b4329a7a0..d14306f4167e 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -480,6 +480,15 @@ declare module '@sveltejs/kit' { * @since 1.8.4 */ preloadStrategy?: 'modulepreload' | 'preload-js' | 'preload-mjs'; + /** + * If `'split'`, splits the app up into multiple .js/.css files so that they are loaded lazily as the user navigates around the app. This is the default, and is recommended for most scenarios. + * If `'single'`, creates just one .js bundle and one .css file containing code for the entire app. + * + * When using `'split'`, you can also adjust the bundling behaviour by setting [`output.experimentalMinChunkSize`](https://rollupjs.org/configuration-options/#output-experimentalminchunksize) and [`output.manualChunks`](https://rollupjs.org/configuration-options/#output-manualchunks)inside your Vite config's [`build.rollupOptions`](https://vite.dev/config/build-options.html#build-rollupoptions). + * @default 'split' + * @since 2.13.0 + */ + bundleStrategy?: 'split' | 'single'; }; paths?: { /** @@ -1641,7 +1650,7 @@ declare module '@sveltejs/kit' { service_worker: string | null; client: { start: string; - app: string; + app?: string; imports: string[]; stylesheets: string[]; fonts: string[];