diff --git a/eslint.config.js b/eslint.config.js
index 6b868a9944c0..958a228f69ad 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -77,6 +77,7 @@ export default [
'packages/kit/test/apps/**/*',
'packages/kit/test/build-errors/**/*',
'packages/kit/test/prerendering/**/*',
+ 'packages/kit/test/version-chunk-rotation/**/*',
'packages/test-redirect-importer/index.js',
'packages/adapter-netlify/test/preview.js'
]
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 2b2bf749dbbc..c3c792f3a079 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -99,6 +99,10 @@
"browser": "./src/runtime/app/env/public/client.js",
"default": "./src/runtime/app/env/public/server.js"
},
+ "#app/env/version": {
+ "browser": "./src/runtime/app/env/version/client.js",
+ "default": "./src/runtime/app/env/version/server.js"
+ },
"#internal": {
"browser": "./src/exports/internal/client.js",
"default": "./src/exports/internal/server/index.js"
diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js
index 5f0ccf0519c2..5415ed9c6f8e 100644
--- a/packages/kit/src/core/sync/write_server.js
+++ b/packages/kit/src/core/sync/write_server.js
@@ -1,10 +1,10 @@
import path from 'node:path';
import { styleText } from 'node:util';
-import { hash } from '../../utils/hash.js';
import { resolve_entry } from '../../utils/filesystem.js';
import { posixify } from '../../utils/os.js';
import { s } from '../../utils/misc.js';
import { load_error_page, load_template } from '../config/index.js';
+import { payload_hash } from '../utils.js';
import { write_if_changed } from './utils.js';
import { escape_html } from '../../utils/escape.js';
@@ -56,7 +56,7 @@ export const options = {
)},
error
},
- version_hash: ${s(hash(config.kit.version.name))}
+ version_hash: ${s(payload_hash(config.kit))}
};
export async function get_hooks() {
diff --git a/packages/kit/src/core/utils.js b/packages/kit/src/core/utils.js
index 39f99ea39452..b320cb33ddd5 100644
--- a/packages/kit/src/core/utils.js
+++ b/packages/kit/src/core/utils.js
@@ -4,8 +4,44 @@ import { fileURLToPath } from 'node:url';
import { styleText } from 'node:util';
import { to_fs } from '../utils/vite.js';
import { noop } from '../utils/functions.js';
+import { hash } from '../utils/hash.js';
import { posixify } from '../utils/os.js';
+/**
+ * Returns a deterministic identifier for the `globalThis.__sveltekit_${payload_hash}`
+ * payload global.
+ *
+ * It must not be derived from `kit.version.name`: the name is inlined into
+ * content-hashed client chunks in `__SVELTEKIT_PAYLOAD__` via the
+ * `$env/dynamic/public` virtual module, so a version-derived identifier rotates the
+ * hashed filename of every chunk referencing it on each deploy (#12260).
+ *
+ * It must still distinguish SvelteKit apps from different projects embedded in the
+ * same document (#9576), so it is derived from the project's `package.json` name
+ * plus `paths.base` and `appDir` — all deterministic across repeated config loads
+ * within a build and across machines.
+ *
+ * @param {import('types').ValidatedKitConfig} kit
+ * @returns {string}
+ */
+export function payload_hash(kit) {
+ if (kit.embedded) return hash(kit.version.name);
+
+ const root = path.dirname(kit.outDir);
+
+ let name = '';
+
+ try {
+ name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')).name ?? '';
+ } catch {
+ // TODO log error or similiar
+ }
+
+ if (!name) name = path.basename(root);
+
+ return hash(`${name}\n${kit.paths.base}\n${kit.appDir}`);
+}
+
/**
* Resolved path of the `runtime` directory posix-ified
*
diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js
index 0e000666ae71..6423e92b176c 100644
--- a/packages/kit/src/exports/vite/index.js
+++ b/packages/kit/src/exports/vite/index.js
@@ -24,7 +24,7 @@ import {
import * as sync from '../../core/sync/sync.js';
import { create_assets } from '../../core/sync/create_manifest_data/index.js';
import { load_and_validate_params } from '../../utils/params.js';
-import { runtime_directory, logger } from '../../core/utils.js';
+import { runtime_directory, logger, payload_hash } from '../../core/utils.js';
import { generate_manifest } from '../../core/generate_manifest/index.js';
import { build_server_nodes } from './build/build_server.js';
import { find_deps, resolve_symlinks } from './build/utils.js';
@@ -217,6 +217,12 @@ function resolve_root(vite_config) {
return posixify(vite_config.root ? path.resolve(vite_config.root) : process.cwd());
}
+/**
+ * config loads twice so we cache the value of the first load so client and server bundles read the same value
+ * @type {string | undefined}
+ */
+let resolved_version_name;
+
/**
* @return {Plugin}
*/
@@ -341,7 +347,20 @@ function kit({ svelte_config }) {
out_dir = posixify(kit.outDir);
out = `${out_dir}/output`;
- version_hash = hash(kit.version.name);
+ if (is_build) {
+ if (resolved_version_name === undefined) {
+ resolved_version_name = kit.version.name;
+ } else if (kit.version.name !== resolved_version_name) {
+ console.warn(
+ `svelte.config.js computed a different kit.version.name on each load ` +
+ `("${resolved_version_name}" vs "${kit.version.name}"). Using the first value ` +
+ `for the whole build — make version.name deterministic to silence this warning.`
+ );
+ }
+ kit.version.name = resolved_version_name;
+ }
+
+ version_hash = payload_hash(kit);
kit_global = is_build
? `globalThis.__sveltekit_${version_hash}`
diff --git a/packages/kit/src/runtime/app/env/internal.js b/packages/kit/src/runtime/app/env/internal.js
index 1d438b38e870..4346d11cfd2c 100644
--- a/packages/kit/src/runtime/app/env/internal.js
+++ b/packages/kit/src/runtime/app/env/internal.js
@@ -1,4 +1,4 @@
-export const version = __SVELTEKIT_APP_VERSION__;
+export { version } from '#app/env/version';
export let building = false;
export let prerendering = false;
diff --git a/packages/kit/src/runtime/app/env/version/client.js b/packages/kit/src/runtime/app/env/version/client.js
new file mode 100644
index 000000000000..331d50116840
--- /dev/null
+++ b/packages/kit/src/runtime/app/env/version/client.js
@@ -0,0 +1 @@
+export const version = /** @type {string} */ (__SVELTEKIT_PAYLOAD__?.version);
diff --git a/packages/kit/src/runtime/app/env/version/server.js b/packages/kit/src/runtime/app/env/version/server.js
new file mode 100644
index 000000000000..6c0d694c02c0
--- /dev/null
+++ b/packages/kit/src/runtime/app/env/version/server.js
@@ -0,0 +1 @@
+export const version = __SVELTEKIT_APP_VERSION__;
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 583d4584324f..e70b3d3c3862 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -412,7 +412,10 @@ export async function render_response({
const blocks = [];
- const properties = [`base: ${base_expression}`];
+ const properties = [
+ `base: ${base_expression}`,
+ `version: ${devalue.uneval(__SVELTEKIT_APP_VERSION__)}`
+ ];
if (paths.assets) {
properties.push(`assets: ${s(paths.assets)}`);
diff --git a/packages/kit/src/types/global-private.d.ts b/packages/kit/src/types/global-private.d.ts
index 7c7bed787169..8e6d8b3c6e22 100644
--- a/packages/kit/src/types/global-private.d.ts
+++ b/packages/kit/src/types/global-private.d.ts
@@ -37,6 +37,8 @@ declare global {
const __SVELTEKIT_PAYLOAD__: {
/** The basepath, usually relative to the current page */
base: string;
+ /** determinsitically computed from package name + config.appDir + config.base */
+ version?: string;
/** Path to externally-hosted assets */
assets?: string;
/** Public environment variables */
diff --git a/packages/kit/test/version-chunk-rotation/app/package.json b/packages/kit/test/version-chunk-rotation/app/package.json
new file mode 100644
index 000000000000..a61bae71f858
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "test-version-chunk-rotation-app",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "svelte-kit sync && vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync",
+ "check": "svelte-kit sync && tsc && svelte-check"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "workspace:^",
+ "@sveltejs/kit": "workspace:^",
+ "@sveltejs/vite-plugin-svelte": "catalog:",
+ "svelte": "catalog:",
+ "svelte-check": "catalog:",
+ "typescript": "catalog:",
+ "vite": "catalog:"
+ }
+}
diff --git a/packages/kit/test/version-chunk-rotation/app/src/app.d.ts b/packages/kit/test/version-chunk-rotation/app/src/app.d.ts
new file mode 100644
index 000000000000..6285566846a0
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/src/app.d.ts
@@ -0,0 +1,5 @@
+declare global {
+ namespace App {}
+}
+
+export {};
diff --git a/packages/kit/test/version-chunk-rotation/app/src/app.html b/packages/kit/test/version-chunk-rotation/app/src/app.html
new file mode 100644
index 000000000000..d533c5e31716
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/src/app.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/packages/kit/test/version-chunk-rotation/app/src/lib/shared.js b/packages/kit/test/version-chunk-rotation/app/src/lib/shared.js
new file mode 100644
index 000000000000..15f94603b63a
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/src/lib/shared.js
@@ -0,0 +1,6 @@
+export const sharedMessage = 'shared between routes';
+
+/** @param {number} n */
+export function double(n) {
+ return n * 2;
+}
diff --git a/packages/kit/test/version-chunk-rotation/app/src/routes/+page.svelte b/packages/kit/test/version-chunk-rotation/app/src/routes/+page.svelte
new file mode 100644
index 000000000000..731197e95f21
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/src/routes/+page.svelte
@@ -0,0 +1,7 @@
+
+
+home
+{sharedMessage}: {double(21)}
+about
diff --git a/packages/kit/test/version-chunk-rotation/app/src/routes/about/+page.svelte b/packages/kit/test/version-chunk-rotation/app/src/routes/about/+page.svelte
new file mode 100644
index 000000000000..c4ca72613c2f
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/src/routes/about/+page.svelte
@@ -0,0 +1,7 @@
+
+
+about
+{sharedMessage}: {double(50)}
+home
diff --git a/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.js b/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.js
new file mode 100644
index 000000000000..189f71e2e1b3
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.js
@@ -0,0 +1 @@
+export const prerender = true;
diff --git a/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.svelte b/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.svelte
new file mode 100644
index 000000000000..5839f7d6d262
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/src/routes/prerendered/+page.svelte
@@ -0,0 +1,6 @@
+
+
+prerendered
+{sharedMessage}
diff --git a/packages/kit/test/version-chunk-rotation/app/tsconfig.json b/packages/kit/test/version-chunk-rotation/app/tsconfig.json
new file mode 100644
index 000000000000..b1096bf168cd
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "noEmit": true
+ },
+ "extends": "./.svelte-kit/tsconfig.json"
+}
diff --git a/packages/kit/test/version-chunk-rotation/app/vite.config.js b/packages/kit/test/version-chunk-rotation/app/vite.config.js
new file mode 100644
index 000000000000..0322e21e7f0d
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/app/vite.config.js
@@ -0,0 +1,32 @@
+import * as path from 'node:path';
+import process from 'node:process';
+import { sveltekit } from '@sveltejs/kit/vite';
+import adapter from '../../../../adapter-auto/index.js';
+
+/** @type {import('vite').UserConfig} */
+const config = {
+ build: {
+ minify: false
+ },
+
+ clearScreen: false,
+
+ logLevel: 'silent',
+
+ plugins: [
+ sveltekit({
+ adapter: adapter(),
+ version: {
+ name: process.env.SK_VERSION || Date.now().toString()
+ }
+ })
+ ],
+
+ server: {
+ fs: {
+ allow: [path.resolve('../../../src')]
+ }
+ }
+};
+
+export default config;
diff --git a/packages/kit/test/version-chunk-rotation/classify.js b/packages/kit/test/version-chunk-rotation/classify.js
new file mode 100644
index 000000000000..7a6b8386da09
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/classify.js
@@ -0,0 +1,88 @@
+import { createHash } from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+
+/**
+ * @typedef {object} Chunk
+ * @property {string} bytes_hash
+ * @property {boolean} contains_version
+ */
+
+const VERSION_DERIVED_PAYLOAD_KEY = /__sveltekit_(?!dev\b)[A-Za-z0-9]+/;
+
+/** @param {string} code */
+const bytes_hash = (code) => createHash('sha256').update(code).digest('hex').slice(0, 12);
+
+/**
+ * @param {string} dir
+ * @param {string} [base]
+ * @param {string[]} [found]
+ * @returns {string[]}
+ */
+function js_files(dir, base = dir, found = []) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const abs = path.join(dir, entry.name);
+ if (entry.isDirectory()) js_files(abs, base, found);
+ else if (entry.name.endsWith('.js')) found.push(path.relative(base, abs));
+ }
+ return found.sort();
+}
+
+/**
+ * @param {string} dir
+ * @param {string} version
+ * @returns {Map}
+ */
+export function snapshot(dir, version) {
+ /** @type {Map} */
+ const chunks = new Map();
+
+ for (const filename of js_files(dir)) {
+ const code = fs.readFileSync(path.join(dir, filename), 'utf-8');
+
+ chunks.set(filename, {
+ bytes_hash: bytes_hash(code),
+ contains_version: code.includes(version) || VERSION_DERIVED_PAYLOAD_KEY.test(code)
+ });
+ }
+
+ return chunks;
+}
+
+/**
+ * @param {Map} before
+ * @param {Map} after
+ */
+export function compare(before, after) {
+ /** @type {Array<{ name: string, contains_version: boolean }>} */
+ const rotated = [];
+ /** @type {Array<{ name: string, contains_version: boolean }>} */
+ const mutable = [];
+
+ for (const [name, chunk] of before) {
+ const replacement = after.get(name);
+ if (!replacement) {
+ rotated.push({ name, contains_version: chunk.contains_version });
+ } else if (replacement.bytes_hash !== chunk.bytes_hash) {
+ mutable.push({
+ name,
+ contains_version: chunk.contains_version || replacement.contains_version
+ });
+ }
+ }
+
+ return { total: before.size, rotated, mutable };
+}
+
+/** @param {ReturnType} result */
+export function format_report(result) {
+ /** @param {{ name: string, contains_version: boolean }} finding */
+ const line = ({ name, contains_version }) =>
+ `${name}${contains_version ? ' ← contains the version' : ''}`;
+
+ return [
+ `${result.total} chunks total · ${result.rotated.length} rotated · ${result.mutable.length} mutable (same name, new bytes)`,
+ ...result.mutable.map((finding) => ` MUTABLE ${line(finding)}`),
+ ...result.rotated.map((finding) => ` ROTATED ${line(finding)}`)
+ ].join('\n');
+}
diff --git a/packages/kit/test/version-chunk-rotation/classify.spec.js b/packages/kit/test/version-chunk-rotation/classify.spec.js
new file mode 100644
index 000000000000..272a454e26fa
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/classify.spec.js
@@ -0,0 +1,72 @@
+import { assert, test } from 'vitest';
+import { compare } from './classify.js';
+
+/**
+ * @param {object} [options]
+ * @param {string} [options.bytes]
+ * @param {boolean} [options.contains_version]
+ * @returns {import('./classify.js').Chunk}
+ */
+function chunk({ bytes = 'same-bytes', contains_version = false } = {}) {
+ return { bytes_hash: bytes, contains_version };
+}
+
+/** @param {Record} chunks */
+const build = (chunks) => new Map(Object.entries(chunks));
+
+test('identical builds produce no findings', () => {
+ const output = build({
+ 'chunks/AAAAAAAA.js': chunk(),
+ 'entry/app.BBBBBBBB.js': chunk({ contains_version: true })
+ });
+
+ const result = compare(output, output);
+
+ assert.deepEqual(result.rotated, []);
+ assert.deepEqual(result.mutable, []);
+});
+
+test('a chunk whose bytes change under a stable filename is mutable', () => {
+ const before = build({ 'chunks/AAAAAAAA.js': chunk({ bytes: 'old', contains_version: true }) });
+ const after = build({ 'chunks/AAAAAAAA.js': chunk({ bytes: 'new', contains_version: true }) });
+
+ const result = compare(before, after);
+
+ assert.deepEqual(result.rotated, []);
+ assert.deepEqual(result.mutable, [{ name: 'chunks/AAAAAAAA.js', contains_version: true }]);
+});
+
+test('a rotated chunk is reported with its version flag', () => {
+ const before = build({
+ 'chunks/AAAAAAAA.js': chunk({ contains_version: true }),
+ 'nodes/0.BBBBBBBB.js': chunk({ bytes: 'imports-AAAAAAAA' })
+ });
+ const after = build({
+ 'chunks/CCCCCCCC.js': chunk({ contains_version: true }),
+ 'nodes/0.DDDDDDDD.js': chunk({ bytes: 'imports-CCCCCCCC' })
+ });
+
+ const result = compare(before, after);
+
+ assert.deepEqual(result.rotated, [
+ { name: 'chunks/AAAAAAAA.js', contains_version: true },
+ { name: 'nodes/0.BBBBBBBB.js', contains_version: false }
+ ]);
+ assert.deepEqual(result.mutable, []);
+});
+
+test('an unchanged chunk is not reported even when siblings rotate', () => {
+ const before = build({
+ 'chunks/AAAAAAAA.js': chunk(),
+ 'chunks/BBBBBBBB.js': chunk({ contains_version: true })
+ });
+ const after = build({
+ 'chunks/AAAAAAAA.js': chunk(),
+ 'chunks/CCCCCCCC.js': chunk({ contains_version: true })
+ });
+
+ const result = compare(before, after);
+
+ assert.deepEqual(result.rotated, [{ name: 'chunks/BBBBBBBB.js', contains_version: true }]);
+ assert.deepEqual(result.mutable, []);
+});
diff --git a/packages/kit/test/version-chunk-rotation/consistency.spec.js b/packages/kit/test/version-chunk-rotation/consistency.spec.js
new file mode 100644
index 000000000000..75b0aa4ff850
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/consistency.spec.js
@@ -0,0 +1,56 @@
+import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+import { assert, test } from 'vitest';
+
+const timeout = 180_000;
+
+const app_dir = fileURLToPath(new URL('./app', import.meta.url));
+
+/** @param {string} [version] omit to build with the fixture's per-config-load default */
+function build(version) {
+ const env = { ...process.env };
+ if (version === undefined) delete env.SK_VERSION;
+ else env.SK_VERSION = version;
+
+ fs.rmSync(path.join(app_dir, '.svelte-kit/output'), { recursive: true, force: true });
+ execSync('pnpm build', { cwd: app_dir, stdio: 'pipe', timeout, env });
+
+ return {
+ prerendered_page: fs.readFileSync(
+ path.join(app_dir, '.svelte-kit/output/prerendered/pages/prerendered.html'),
+ 'utf-8'
+ ),
+ version_json: /** @type {{ version: string }} */ (
+ JSON.parse(
+ fs.readFileSync(path.join(app_dir, '.svelte-kit/output/client/_app/version.json'), 'utf-8')
+ )
+ )
+ };
+}
+
+test('a version containing cannot terminate the payload script', { timeout }, () => {
+ const dangerous_version = '';
+
+ const { prerendered_page, version_json } = build(dangerous_version);
+
+ assert.notInclude(prerendered_page, dangerous_version);
+ assert.include(prerendered_page, '\\u003C/script>', 'the version is escaped, not dropped');
+ assert.equal(version_json.version, dangerous_version);
+});
+
+test(
+ 'a version computed per config load resolves to one value for the whole build (#14166)',
+ { timeout },
+ () => {
+ const { prerendered_page, version_json } = build();
+
+ assert.include(
+ prerendered_page,
+ `version: "${version_json.version}"`,
+ 'the payload version and _app/version.json must agree'
+ );
+ }
+);
diff --git a/packages/kit/test/version-chunk-rotation/package.json b/packages/kit/test/version-chunk-rotation/package.json
new file mode 100644
index 000000000000..16fecb69ead9
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "test-version-chunk-rotation",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "test": "vitest run",
+ "test:cross-platform:build": "vitest run"
+ },
+ "devDependencies": {
+ "vitest": "catalog:"
+ }
+}
diff --git a/packages/kit/test/version-chunk-rotation/payload-hash.spec.js b/packages/kit/test/version-chunk-rotation/payload-hash.spec.js
new file mode 100644
index 000000000000..8655f258592d
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/payload-hash.spec.js
@@ -0,0 +1,91 @@
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { assert, test } from 'vitest';
+import { payload_hash } from '../../src/core/utils.js';
+import { hash } from '../../src/utils/hash.js';
+
+/** @param {{ name?: string, package_json?: boolean }} [options] */
+function project({ name, package_json = true } = {}) {
+ const root = mkdtempSync(join(tmpdir(), 'kit-payload-hash-'));
+ if (package_json) writeFileSync(join(root, 'package.json'), JSON.stringify({ name }));
+ return root;
+}
+
+/**
+ * @param {object} options
+ * @param {string} options.root
+ * @param {string} [options.base]
+ * @param {string} [options.app_dir]
+ * @param {string} [options.version]
+ * @param {boolean} [options.embedded]
+ * @returns {import('types').ValidatedKitConfig}
+ */
+function kit_config({ root, base = '', app_dir = '_app', version = 'v1', embedded = false }) {
+ return /** @type {import('types').ValidatedKitConfig} */ (
+ /** @type {unknown} */ ({
+ outDir: join(root, '.svelte-kit'),
+ appDir: app_dir,
+ paths: { base },
+ version: { name: version },
+ embedded
+ })
+ );
+}
+
+test('is independent of version.name', () => {
+ const root = project({ name: 'my-app' });
+
+ assert.equal(
+ payload_hash(kit_config({ root, version: 'v-alpha' })),
+ payload_hash(kit_config({ root, version: 'v-bravo' }))
+ );
+});
+
+test('differs between projects with different package names', () => {
+ assert.notEqual(
+ payload_hash(kit_config({ root: project({ name: 'app-one' }) })),
+ payload_hash(kit_config({ root: project({ name: 'app-two' }) }))
+ );
+});
+
+test('differs when paths.base differs', () => {
+ const root = project({ name: 'my-app' });
+
+ assert.notEqual(
+ payload_hash(kit_config({ root, base: '' })),
+ payload_hash(kit_config({ root, base: '/docs' }))
+ );
+});
+
+test('differs when appDir differs', () => {
+ const root = project({ name: 'my-app' });
+
+ assert.notEqual(
+ payload_hash(kit_config({ root, app_dir: '_app' })),
+ payload_hash(kit_config({ root, app_dir: '_custom' }))
+ );
+});
+
+test('embedded apps keep the version-derived key for cross-deploy isolation', () => {
+ const root = project({ name: 'my-app' });
+
+ assert.equal(
+ payload_hash(kit_config({ root, embedded: true, version: 'v-alpha' })),
+ hash('v-alpha')
+ );
+});
+
+test('projects without a package.json still get distinct keys', () => {
+ assert.notEqual(
+ payload_hash(kit_config({ root: project({ package_json: false }) })),
+ payload_hash(kit_config({ root: project({ package_json: false }) }))
+ );
+});
+
+test('projects with a nameless package.json still get distinct keys', () => {
+ assert.notEqual(
+ payload_hash(kit_config({ root: project() })),
+ payload_hash(kit_config({ root: project() }))
+ );
+});
diff --git a/packages/kit/test/version-chunk-rotation/rotation.spec.js b/packages/kit/test/version-chunk-rotation/rotation.spec.js
new file mode 100644
index 000000000000..f677e9801518
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/rotation.spec.js
@@ -0,0 +1,71 @@
+import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+import { assert, beforeAll, test } from 'vitest';
+import { compare, format_report, snapshot } from './classify.js';
+
+const timeout = 180_000;
+
+const app_dir = fileURLToPath(new URL('./app', import.meta.url));
+const immutable_dir = path.join(app_dir, '.svelte-kit/output/client/_app/immutable');
+
+/** @param {string} version */
+function build(version) {
+ fs.rmSync(path.join(app_dir, '.svelte-kit/output'), { recursive: true, force: true });
+ execSync('pnpm build', {
+ cwd: app_dir,
+ stdio: 'pipe',
+ timeout,
+ env: { ...process.env, SK_VERSION: version }
+ });
+
+ return {
+ version,
+ chunks: snapshot(immutable_dir, version),
+ prerendered_page: fs.readFileSync(
+ path.join(app_dir, '.svelte-kit/output/prerendered/pages/prerendered.html'),
+ 'utf-8'
+ )
+ };
+}
+
+/**
+ * @param {ReturnType} result
+ * @param {string} when
+ */
+function assert_bundle_unchanged(result, when) {
+ assert.equal(result.mutable.length, 0, `chunks mutated ${when}:\n${format_report(result)}`);
+ assert.equal(result.rotated.length, 0, `chunks rotated ${when}:\n${format_report(result)}`);
+}
+
+/** @type {ReturnType} */
+let control_a;
+/** @type {ReturnType} */
+let control_b;
+/** @type {ReturnType} */
+let bumped;
+
+beforeAll(() => {
+ control_a = build('version-alpha');
+ control_b = build('version-alpha');
+ bumped = build('version-bravo');
+}, timeout);
+
+test('the same version builds an identical client bundle', () => {
+ assert_bundle_unchanged(compare(control_a.chunks, control_b.chunks), 'at a fixed version');
+});
+
+test('bumping kit.version.name rotates no client chunks', () => {
+ assert_bundle_unchanged(
+ compare(control_a.chunks, bumped.chunks),
+ 'after a version bump with no app-code change'
+ );
+});
+
+test('the server-rendered payload carries the version of its own build', () => {
+ for (const { version, prerendered_page } of [control_a, bumped]) {
+ assert.include(prerendered_page, `version: "${version}"`);
+ }
+});
diff --git a/packages/kit/test/version-chunk-rotation/vitest.config.js b/packages/kit/test/version-chunk-rotation/vitest.config.js
new file mode 100644
index 000000000000..91bbdfedda78
--- /dev/null
+++ b/packages/kit/test/version-chunk-rotation/vitest.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ // rotation.spec.js and consistency.spec.js both build ./app in place —
+ // concurrent builds in the same directory would corrupt each other's output
+ fileParallelism: false
+ }
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f1caf22bd2bc..606048de79da 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1392,6 +1392,36 @@ importers:
specifier: 'catalog:'
version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.19.19)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0))
+ packages/kit/test/version-chunk-rotation:
+ devDependencies:
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.19.19)(@vitest/browser-playwright@4.1.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0))
+
+ packages/kit/test/version-chunk-rotation/app:
+ devDependencies:
+ '@sveltejs/adapter-auto':
+ specifier: workspace:^
+ version: link:../../../../adapter-auto
+ '@sveltejs/kit':
+ specifier: workspace:^
+ version: link:../../..
+ '@sveltejs/vite-plugin-svelte':
+ specifier: 'catalog:'
+ version: 7.0.0(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0))
+ svelte:
+ specifier: 'catalog:'
+ version: 5.56.3(@typescript-eslint/types@8.61.1)
+ svelte-check:
+ specifier: 'catalog:'
+ version: 4.7.0(picomatch@4.0.4)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(typescript@6.0.3)
+ typescript:
+ specifier: 'catalog:'
+ version: 6.0.3
+ vite:
+ specifier: 'catalog:'
+ version: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0)
+
packages/package:
dependencies:
chokidar:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d8bde5880d59..00c894c21efe 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -27,6 +27,8 @@ packages:
- packages/kit/test/prerendering/*
- packages/kit/test/build-errors/**
- packages/kit/test/build-errors/apps/*
+ - packages/kit/test/version-chunk-rotation
+ - packages/kit/test/version-chunk-rotation/app
- '!.test-tmp/**'
- playgrounds/*
allowBuilds: