diff --git a/.changeset/more-frequent-updated-checks.md b/.changeset/more-frequent-updated-checks.md
new file mode 100644
index 000000000000..b14549acc8db
--- /dev/null
+++ b/.changeset/more-frequent-updated-checks.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': breaking
+---
+
+breaking: detect new deployments on data, remote, and form action responses, tab focus, and visibility change, and default `version.pollInterval` to 1 hour
diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js
index 2123a7c5d140..314ee174deb0 100644
--- a/packages/kit/src/core/config/index.spec.js
+++ b/packages/kit/src/core/config/index.spec.js
@@ -142,7 +142,7 @@ const get_defaults = (prefix = '') => ({
},
version: {
name: Date.now().toString(),
- pollInterval: 0
+ pollInterval: 3_600_000
}
}
});
diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js
index 9a40a0734f6b..c615a544afed 100644
--- a/packages/kit/src/core/config/options.js
+++ b/packages/kit/src/core/config/options.js
@@ -292,7 +292,7 @@ export const validate_kit_options = object({
version: object({
name: string(Date.now().toString()),
- pollInterval: number(0)
+ pollInterval: number(3_600_000)
})
});
diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js
index 521d50ac029d..d2336eda8107 100644
--- a/packages/kit/src/core/sync/write_server.js
+++ b/packages/kit/src/core/sync/write_server.js
@@ -53,6 +53,7 @@ export const options = {
)},
error
},
+ version: ${s(config.kit.version.name)},
version_hash: ${s(hash(config.kit.version.name))}
};
diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts
index c1f28309fa6f..d32cbfa08a3c 100644
--- a/packages/kit/src/exports/public.d.ts
+++ b/packages/kit/src/exports/public.d.ts
@@ -875,9 +875,9 @@ export interface KitConfig {
};
/**
* Client-side navigation can be buggy if you deploy a new version of your app while people are using it. If the code for the new page is already loaded, it may have stale content; if it isn't, the app's route manifest may point to a JavaScript file that no longer exists.
- * SvelteKit helps you solve this problem through version management.
+ * SvelteKit helps you solve this problem through version management. The current version is included in data, remote, and form action responses via the `x-sveltekit-version` header, so SvelteKit can detect new deployments without polling — for example when a navigation triggers a server `load` function, or when a remote function is called. SvelteKit also checks for new versions when the tab regains focus or becomes visible.
* If SvelteKit encounters an error while loading the page and detects that a new version has been deployed (using the `name` specified here, which defaults to a timestamp of the build) it will fall back to traditional full-page navigation.
- * Not all navigations will result in an error though, for example if the JavaScript for the next page is already loaded. If you still want to force a full-page navigation in these cases, use techniques such as setting the `pollInterval` and then using `beforeNavigate`:
+ * Not all navigations will result in an error though, for example if the JavaScript for the next page is already loaded. If you still want to force a full-page navigation in these cases, use `beforeNavigate`:
* ```html
* /// file: +layout.svelte
*
* ```
*
- * If you set `pollInterval` to a non-zero value, SvelteKit will poll for new versions in the background and set the value of [`updated.current`](https://svelte.dev/docs/kit/$app-state#updated) `true` when it detects one.
+ * In addition to these checks, SvelteKit polls for new versions on an interval and sets [`updated.current`](https://svelte.dev/docs/kit/$app-state#updated) to `true` when it detects one. Set `pollInterval` to `0` to disable polling (the header- and event-based checks will still run).
*/
version?: {
/**
@@ -919,8 +919,8 @@ export interface KitConfig {
*/
name?: string;
/**
- * The interval in milliseconds to poll for version changes. If this is `0`, no polling occurs.
- * @default 0
+ * The interval in milliseconds to poll for version changes. If this is `0`, no polling occurs. SvelteKit also checks for new versions on server responses (via the `x-sveltekit-version` header) and when the tab regains focus or becomes visible, so polling is only needed for long-lived sessions on a single page.
+ * @default 3600000
*/
pollInterval?: number;
};
diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js
index 726833c3cdb0..17bf12ce6b35 100644
--- a/packages/kit/src/exports/vite/index.js
+++ b/packages/kit/src/exports/vite/index.js
@@ -501,6 +501,7 @@ function kit({ svelte_config }) {
const define = {
__SVELTEKIT_APP_DIR__: s(posixify(kit.appDir)),
__SVELTEKIT_APP_VERSION__: s(kit.version.name),
+ __SVELTEKIT_APP_VERSION_CHECKS_ENABLED__: s(kit.output.bundleStrategy !== 'inline'),
__SVELTEKIT_EMBEDDED__: s(kit.embedded),
__SVELTEKIT_FORK_PRELOADS__: s(kit.experimental.forkPreloads),
__SVELTEKIT_PATHS_ASSETS__: s(kit.paths.assets),
diff --git a/packages/kit/src/runtime/app/forms.js b/packages/kit/src/runtime/app/forms.js
index adb55c8247a3..00480ed52627 100644
--- a/packages/kit/src/runtime/app/forms.js
+++ b/packages/kit/src/runtime/app/forms.js
@@ -4,6 +4,7 @@ import { noop } from '../../utils/functions.js';
import { refreshAll } from './navigation.js';
import { app as client_app, applyAction, handle_error } from '../client/client.js';
import { app as server_app } from '../server/app.js';
+import { notify_version } from '../client/state.svelte.js';
export { applyAction };
@@ -201,6 +202,9 @@ export function enhance(form_element, submit = noop) {
signal: controller.signal
});
+ // detect new deployments from the response header
+ notify_version(response.headers.get('x-sveltekit-version'));
+
if (response.status === 204) {
result = { type: 'success', status: 204 };
} else {
diff --git a/packages/kit/src/runtime/app/state/index.js b/packages/kit/src/runtime/app/state/index.js
index 70ed0eaab425..e1114315c7d7 100644
--- a/packages/kit/src/runtime/app/state/index.js
+++ b/packages/kit/src/runtime/app/state/index.js
@@ -58,7 +58,7 @@ export const page = BROWSER ? client_page : server_page;
export const navigating = BROWSER ? client_navigating : server_navigating;
/**
- * A read-only reactive value that's initially `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update `current` to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling.
+ * A read-only reactive value that's initially `false`. SvelteKit checks for new versions on data, remote, and form action responses (via the `x-sveltekit-version` header), when the tab regains focus or becomes visible, and on a poll interval (see [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version)). `updated.current` is set to `true` when a new version is detected. `updated.check()` will force an immediate check, regardless of polling.
* @type {{ get current(): boolean; check(): Promise; }}
*/
export const updated = BROWSER ? client_updated : server_updated;
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index a9a3950b45ff..6a3f7daddfed 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -43,7 +43,7 @@ import {
validate_load_response
} from '../shared.js';
import { get_message, get_status } from '../../utils/error.js';
-import { page, update, navigating, updated } from './state.svelte.js';
+import { page, update, navigating, updated, notify_version } from './state.svelte.js';
import { payload } from './payload.js';
import { add_data_suffix, add_resolution_suffix } from '../pathname.js';
import { noop_span } from '../telemetry/noop.js';
@@ -2763,13 +2763,19 @@ function _start_router() {
history.scrollRestoration = 'auto';
}
});
-
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
persist_state();
+ } else {
+ // the tab just became visible — a good time to check for a new deployment
+ void updated.check();
}
});
+ addEventListener('focus', () => {
+ void updated.check();
+ });
+
// @ts-expect-error this isn't supported everywhere yet
if (!navigator.connection?.saveData) {
setup_preload();
@@ -3196,6 +3202,9 @@ async function load_data(url, invalid) {
const fetcher = DEV ? dev_fetch : window.fetch;
const res = await fetcher(data_url.href, {});
+ // detect new deployments from the response header
+ notify_version(res.headers.get('x-sveltekit-version'));
+
if (!res.ok) {
// error message is a JSON-stringified string which devalue can't handle at the top level
// turn it into a HttpError to not call handleError on the client again (was already handled on the server)
diff --git a/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js b/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js
index 7da351d3b522..fc345ce4dae2 100644
--- a/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js
+++ b/packages/kit/src/runtime/client/remote-functions/query-live/iterator.js
@@ -1,5 +1,6 @@
import { app_dir, base } from '$app/paths/internal/client';
import { app } from '../../client.js';
+import { notify_version } from '../../state.svelte.js';
import { handle_side_channel_response } from '../shared.svelte.js';
import * as devalue from 'devalue';
import { HttpError } from '@sveltejs/kit/internal';
@@ -26,6 +27,9 @@ export async function* create_live_iterator(
signal: controller.signal
});
+ // detect new deployments from the response header
+ notify_version(response.headers.get('x-sveltekit-version'));
+
if (!response.ok) {
const result = await response.json().catch(() => ({
type: 'error',
diff --git a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js
index bad25b47736c..1c6741797041 100644
--- a/packages/kit/src/runtime/client/remote-functions/shared.svelte.js
+++ b/packages/kit/src/runtime/client/remote-functions/shared.svelte.js
@@ -6,7 +6,7 @@ import { app, _goto, live_query_map, query_map, query_responses } from '../clien
import { HttpError, Redirect } from '@sveltejs/kit/internal';
import { untrack } from 'svelte';
import { create_remote_key, split_remote_key } from '../../shared.js';
-import { navigating, page } from '../state.svelte.js';
+import { navigating, page, notify_version } from '../state.svelte.js';
/** Indicates a query function, as opposed to a query instance */
export const QUERY_FUNCTION_ID = Symbol('sveltekit.query_function_id');
@@ -113,6 +113,9 @@ export function get_remote_request_headers() {
export async function remote_request(url, init) {
const response = await fetch(url, init);
+ // detect new deployments from the response header
+ notify_version(response.headers.get('x-sveltekit-version'));
+
if (!response.ok) {
const result = await response.json().catch(() => ({
type: 'error',
diff --git a/packages/kit/src/runtime/client/remote-functions/shared.transport.spec.js b/packages/kit/src/runtime/client/remote-functions/shared.transport.spec.js
index a6efa0549331..eb4456f3c711 100644
--- a/packages/kit/src/runtime/client/remote-functions/shared.transport.spec.js
+++ b/packages/kit/src/runtime/client/remote-functions/shared.transport.spec.js
@@ -16,12 +16,29 @@ vi.mock(new URL('../client.js', import.meta.url).pathname, () => ({
// Svelte state only available in a full SvelteKit runtime.
vi.mock(new URL('../state.svelte.js', import.meta.url).pathname, () => ({
navigating: { current: null },
- page: { url: new URL('http://localhost/') }
+ page: { url: new URL('http://localhost/') },
+ notify_version: () => {}
}));
const { remote_request } = await import('./shared.svelte.js');
const { HttpError } = await import('@sveltejs/kit/internal');
+/**
+ * Build a mock fetch Response. `remote_request` reads `response.headers` before
+ * anything else, so every mock needs a `headers` object.
+ * @param {Partial & { json?: () => Promise }} props
+ */
+function mock_response(props) {
+ return Promise.resolve({
+ headers: new Headers(),
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: () => Promise.resolve({}),
+ ...props
+ });
+}
+
describe('remote_request transport error handling', () => {
beforeEach(() => {
vi.unstubAllGlobals();
@@ -29,7 +46,7 @@ describe('remote_request transport error handling', () => {
test('non-OK response with JSON error body preserves status and error body', async () => {
vi.stubGlobal('fetch', () =>
- Promise.resolve({
+ mock_response({
ok: false,
status: 401,
statusText: 'Unauthorized',
@@ -45,7 +62,7 @@ describe('remote_request transport error handling', () => {
test('non-OK response with non-JSON body falls back to response.status and statusText', async () => {
vi.stubGlobal('fetch', () =>
- Promise.resolve({
+ mock_response({
ok: false,
status: 503,
statusText: 'Service Unavailable',
@@ -63,10 +80,7 @@ describe('remote_request transport error handling', () => {
const body = JSON.stringify({ type: 'result', data: null });
vi.stubGlobal('fetch', () =>
- Promise.resolve({
- ok: true,
- status: 200,
- statusText: 'OK',
+ mock_response({
json: () => Promise.resolve(JSON.parse(body))
})
);
diff --git a/packages/kit/src/runtime/client/state.svelte.js b/packages/kit/src/runtime/client/state.svelte.js
index f29bc9d3728c..49910754fe23 100644
--- a/packages/kit/src/runtime/client/state.svelte.js
+++ b/packages/kit/src/runtime/client/state.svelte.js
@@ -25,41 +25,74 @@ export const updated = new (class Updated {
check = async () => false;
})();
+/**
+ * Internal: mark `updated.current` as `true` if the given version differs.
+ * Called from the server response header path. No-op unless version checks
+ * are enabled (assigned below). Not exported on the public `updated` object.
+ * @type {(new_version: string | null) => void}
+ */
+export let notify_version = () => {};
+
if (!DEV && BROWSER) {
const interval = __SVELTEKIT_APP_VERSION_POLL_INTERVAL__;
- /** @type {number} */
+ /** @type {number | undefined} */
let timeout;
+ /** @type {Promise | undefined} */
+ let checking;
+
+ if (__SVELTEKIT_APP_VERSION_CHECKS_ENABLED__) {
+ /**
+ * Mark `updated.current` as `true` if the given version differs from the one
+ * the app was hydrated with. Called from the server response header path.
+ * Does NOT reset the poll timer — unlike `check()`, this is a passive observation
+ * from a single server instance's response, not an explicit version check. The
+ * poll timer continues on its original schedule as a backstop. This is important
+ * for platforms that implement skew protection, where `x-sveltekit-version`
+ * may be out of date — in this case we still need to poll for `version.json`.
+ * @param {string | null} new_version
+ */
+ notify_version = (new_version) => {
+ if (new_version && new_version !== version) {
+ updated.current = true;
+ }
+ };
+ }
+
/** @type {() => Promise} */
- async function check() {
+ function check() {
+ if (checking) return checking;
+
window.clearTimeout(timeout);
- if (interval) timeout = window.setTimeout(check, interval);
+ return (checking = (async () => {
+ try {
+ const res = await fetch(`${assets}/${__SVELTEKIT_APP_VERSION_FILE__}`, {
+ headers: {
+ 'cache-control': 'no-cache'
+ }
+ });
- try {
- const res = await fetch(`${assets}/${__SVELTEKIT_APP_VERSION_FILE__}`, {
- headers: {
- 'cache-control': 'no-cache'
+ if (!res.ok) {
+ return false;
}
- });
- if (!res.ok) {
- return false;
- }
+ const data = await res.json();
+ const new_update = data.version !== version;
- const data = await res.json();
- const new_update = data.version !== version;
+ if (new_update) {
+ updated.current = true;
+ }
- if (new_update) {
- updated.current = true;
- window.clearTimeout(timeout);
+ return new_update;
+ } catch {
+ return false;
+ } finally {
+ checking = undefined;
+ if (interval && !updated.current) timeout = window.setTimeout(check, interval);
}
-
- return new_update;
- } catch {
- return false;
- }
+ })());
}
if (interval) timeout = window.setTimeout(check, interval);
diff --git a/packages/kit/src/runtime/client/state.svelte.spec.js b/packages/kit/src/runtime/client/state.svelte.spec.js
new file mode 100644
index 000000000000..ad74dec588d0
--- /dev/null
+++ b/packages/kit/src/runtime/client/state.svelte.spec.js
@@ -0,0 +1,181 @@
+import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
+import { updated, notify_version } from './state.svelte.js';
+
+// Mock `esm-env` so the version-check logic is initialised. In the test env,
+// `DEV` is true which would skip the `if (!DEV && ...)` block.
+vi.mock('esm-env', () => ({
+ BROWSER: true,
+ DEV: false
+}));
+
+vi.hoisted(() => {
+ vi.stubGlobal('__SVELTEKIT_APP_VERSION_CHECKS_ENABLED__', true);
+ vi.stubGlobal('__SVELTEKIT_APP_VERSION_FILE__', '_app/version.json');
+ vi.stubGlobal('__SVELTEKIT_APP_VERSION_POLL_INTERVAL__', 0);
+});
+
+describe('updated', () => {
+ beforeEach(() => {
+ // reset state between tests
+ updated.current = false;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.stubGlobal('__SVELTEKIT_APP_VERSION_CHECKS_ENABLED__', true);
+ vi.stubGlobal('__SVELTEKIT_APP_VERSION_POLL_INTERVAL__', 0);
+ });
+
+ test('notify_version is a no-op when the version matches', () => {
+ // `version` is mocked as '' in the test env
+ notify_version('');
+ expect(updated.current).toBe(false);
+ });
+
+ test('notify_version flips current to true when the version differs', () => {
+ notify_version('');
+ expect(updated.current).toBe(true);
+ });
+
+ test('notify_version ignores null', () => {
+ notify_version(null);
+ expect(updated.current).toBe(false);
+ });
+
+ test('notify_version ignores empty string', () => {
+ notify_version('');
+ expect(updated.current).toBe(false);
+ });
+
+ test('check() remains available when response header checks are disabled', async () => {
+ vi.stubGlobal('__SVELTEKIT_APP_VERSION_CHECKS_ENABLED__', false);
+ vi.resetModules();
+ vi.stubGlobal('fetch', () =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ version: '' })
+ })
+ );
+
+ const { updated } = await import('./state.svelte.js');
+ expect(await updated.check()).toBe(true);
+ expect(updated.current).toBe(true);
+ });
+
+ test('check() fetches version.json and flips current on mismatch', async () => {
+ vi.stubGlobal('fetch', () =>
+ Promise.resolve({
+ ok: true,
+ headers: new Headers(),
+ json: () => Promise.resolve({ version: '' })
+ })
+ );
+
+ const result = await updated.check();
+ expect(result).toBe(true);
+ expect(updated.current).toBe(true);
+ });
+
+ test('check() does not flip current when version matches', async () => {
+ vi.stubGlobal('fetch', () =>
+ Promise.resolve({
+ ok: true,
+ headers: new Headers(),
+ json: () => Promise.resolve({ version: '' })
+ })
+ );
+
+ const result = await updated.check();
+ expect(result).toBe(false);
+ expect(updated.current).toBe(false);
+ });
+
+ test('check() returns false on non-ok response', async () => {
+ vi.stubGlobal('fetch', () =>
+ Promise.resolve({
+ ok: false,
+ status: 404,
+ headers: new Headers(),
+ json: () => Promise.resolve({})
+ })
+ );
+
+ const result = await updated.check();
+ expect(result).toBe(false);
+ expect(updated.current).toBe(false);
+ });
+
+ test('check() continues polling after a check exceeds the interval', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('__SVELTEKIT_APP_VERSION_POLL_INTERVAL__', 10);
+ vi.resetModules();
+
+ /** @type {Array<(value: Response) => void>} */
+ const resolve_queue = [];
+ vi.stubGlobal(
+ 'fetch',
+ () =>
+ new Promise((resolve) => {
+ resolve_queue.push(resolve);
+ })
+ );
+
+ const { updated } = await import('./state.svelte.js');
+ const first = updated.check();
+ expect(resolve_queue).toHaveLength(1);
+
+ await vi.advanceTimersByTimeAsync(20);
+ expect(resolve_queue).toHaveLength(1);
+
+ resolve_queue[0](
+ new Response(JSON.stringify({ version: '' }), {
+ headers: { 'content-type': 'application/json' }
+ })
+ );
+ await first;
+
+ await vi.advanceTimersByTimeAsync(10);
+ expect(resolve_queue).toHaveLength(2);
+
+ resolve_queue[1](
+ new Response(JSON.stringify({ version: '' }), {
+ headers: { 'content-type': 'application/json' }
+ })
+ );
+ });
+
+ test('check() does not run concurrent checks', async () => {
+ /** @type {Array<{ resolve: (value: any) => void }>} */
+ const resolve_queue = [];
+
+ vi.stubGlobal(
+ 'fetch',
+ () =>
+ new Promise((resolve) => {
+ resolve_queue.push({
+ resolve: () =>
+ resolve({
+ ok: true,
+ headers: new Headers(),
+ json: () => Promise.resolve({ version: '' })
+ })
+ });
+ })
+ );
+
+ // start two checks concurrently
+ const p1 = updated.check();
+ const p2 = updated.check();
+
+ // only one fetch should be in-flight
+ expect(resolve_queue.length).toBe(1);
+
+ resolve_queue[0].resolve(undefined);
+
+ await Promise.all([p1, p2]);
+
+ // the second check should have returned early with the current value
+ // (which is false since the version matched)
+ expect(updated.current).toBe(false);
+ });
+});
diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js
index 93bdde16a6f1..e65fbe5c0230 100644
--- a/packages/kit/src/runtime/server/data/index.js
+++ b/packages/kit/src/runtime/server/data/index.js
@@ -7,6 +7,7 @@ import { load_server_data } from '../page/load_data.js';
import { handle_error_and_jsonify } from '../errors.js';
import { normalize_path } from '../../../utils/url.js';
import { text_encoder } from '../../utils.js';
+import { with_version_header } from '../utils.js';
/**
* @param {import('@sveltejs/kit').RequestEvent} event
@@ -31,9 +32,7 @@ export async function render_data(
) {
if (!route.page) {
// requesting /__data.json should fail for a +server.js
- return new Response(undefined, {
- status: 404
- });
+ return with_version_header(new Response(undefined, { status: 404 }));
}
try {
@@ -123,26 +122,28 @@ export async function render_data(
return json_response(data);
}
- return new Response(
- new ReadableStream({
- async start(controller) {
- controller.enqueue(text_encoder.encode(data));
- for await (const chunk of chunks) {
- controller.enqueue(text_encoder.encode(chunk));
+ return with_version_header(
+ new Response(
+ new ReadableStream({
+ async start(controller) {
+ controller.enqueue(text_encoder.encode(data));
+ for await (const chunk of chunks) {
+ controller.enqueue(text_encoder.encode(chunk));
+ }
+ controller.close();
+ },
+
+ type: 'bytes'
+ }),
+ {
+ headers: {
+ // we use a proprietary content type to prevent buffering.
+ // the `text` prefix makes it inspectable
+ 'content-type': 'text/sveltekit-data',
+ 'cache-control': 'private, no-store'
}
- controller.close();
- },
-
- type: 'bytes'
- }),
- {
- headers: {
- // we use a proprietary content type to prevent buffering.
- // the `text` prefix makes it inspectable
- 'content-type': 'text/sveltekit-data',
- 'cache-control': 'private, no-store'
}
- }
+ )
);
} catch (e) {
const error = normalize_error(e);
@@ -161,13 +162,15 @@ export async function render_data(
* @param {number} [status]
*/
function json_response(json, status = 200) {
- return text(typeof json === 'string' ? json : JSON.stringify(json), {
- status,
- headers: {
- 'content-type': 'application/json',
- 'cache-control': 'private, no-store'
- }
- });
+ return with_version_header(
+ text(typeof json === 'string' ? json : JSON.stringify(json), {
+ status,
+ headers: {
+ 'content-type': 'application/json',
+ 'cache-control': 'private, no-store'
+ }
+ })
+ );
}
/**
diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js
index 2418649b5bda..315fff550560 100644
--- a/packages/kit/src/runtime/server/page/actions.js
+++ b/packages/kit/src/runtime/server/page/actions.js
@@ -7,7 +7,7 @@ import { HttpError, Redirect, ActionFailure, SvelteKitError } from '@sveltejs/ki
import { with_request_store, merge_tracing } from '@sveltejs/kit/internal/server';
import { normalize_error } from '../../../utils/error.js';
import { is_form_content_type, negotiate } from '../../../utils/http.js';
-import { create_replacer } from '../utils.js';
+import { create_replacer, with_version_header } from '../utils.js';
import { handle_error_and_jsonify } from '../errors.js';
import { record_span } from '../../telemetry/record_span.js';
@@ -95,7 +95,7 @@ export async function handle_action_json_request(event, event_state, options, se
});
} else {
// no data returned — use 204 No Content (without a body, per the spec)
- return new Response(null, { status: 204 });
+ return with_version_header(new Response(null, { status: 204 }));
}
} catch (e) {
const err = normalize_error(e);
@@ -148,7 +148,7 @@ export function action_json_redirect(redirect) {
* @param {ResponseInit} [init]
*/
function action_json(data, init) {
- return json(data, init);
+ return with_version_header(json(data, init));
}
/**
diff --git a/packages/kit/src/runtime/server/remote-functions.js b/packages/kit/src/runtime/server/remote-functions.js
index 195c057c4263..08e0f988aaa5 100644
--- a/packages/kit/src/runtime/server/remote-functions.js
+++ b/packages/kit/src/runtime/server/remote-functions.js
@@ -13,6 +13,7 @@ import { check_incorrect_fail_use } from './page/actions.js';
import { DEV } from 'esm-env';
import { record_span } from '../telemetry/record_span.js';
import { deserialize_binary_form } from '../form-utils.js';
+import { with_version_header } from './utils.js';
/**
* How long (in milliseconds) to wait after the last message was sent before
@@ -28,11 +29,12 @@ export async function handle_remote_call(event, state, options, manifest, id) {
attributes: {
'sveltekit.remote.call.id': id
},
- fn: (current) => {
+ fn: async (current) => {
const traced_event = merge_tracing(event, current);
- return with_request_store({ event: traced_event, state }, () =>
+ const response = await with_request_store({ event: traced_event, state }, () =>
handle_remote_call_internal(traced_event, state, options, manifest, id)
);
+ return with_version_header(response);
}
});
}
diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js
index 675b40305ec7..75351e22bd59 100644
--- a/packages/kit/src/runtime/server/utils.js
+++ b/packages/kit/src/runtime/server/utils.js
@@ -50,6 +50,16 @@ export function redirect_response(status, location) {
return response;
}
+/**
+ * @param {Response} response
+ */
+export function with_version_header(response) {
+ if (__SVELTEKIT_APP_VERSION_CHECKS_ENABLED__) {
+ response.headers.set('x-sveltekit-version', __SVELTEKIT_APP_VERSION__);
+ }
+ return response;
+}
+
/**
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {Error & { path: string }} error
diff --git a/packages/kit/src/types/global-private.d.ts b/packages/kit/src/types/global-private.d.ts
index 1bb3e664d601..e5484bd905d8 100644
--- a/packages/kit/src/types/global-private.d.ts
+++ b/packages/kit/src/types/global-private.d.ts
@@ -6,6 +6,8 @@ declare global {
const __SVELTEKIT_APP_VERSION__: string;
const __SVELTEKIT_APP_VERSION_FILE__: string;
const __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: number;
+ /** True if version checks are enabled (i.e. `bundleStrategy !== 'inline'`) */
+ const __SVELTEKIT_APP_VERSION_CHECKS_ENABLED__: boolean;
/**
* True if the user ran `vite dev`. This is different from `esm-env` because
* it is influenced by `NODE_ENV` which can still be true during `vite preview`
diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts
index 8a2bb1bf211e..1eeb6f6dee94 100644
--- a/packages/kit/src/types/internal.d.ts
+++ b/packages/kit/src/types/internal.d.ts
@@ -496,6 +496,7 @@ export interface SSROptions {
}): string;
error(values: { message: string; status: number }): string;
};
+ version: string;
version_hash: string;
}
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index 201b0c20d93f..bf2607ee2173 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -968,6 +968,16 @@ test.describe('Miscellaneous', () => {
const response = await request.get('/prerendering/中文');
expect(response.status()).toBe(200);
});
+
+ test('does not send x-sveltekit-version header on document responses', async ({ page }) => {
+ const response = await page.goto('/');
+ expect(response?.headers()['x-sveltekit-version']).toBeUndefined();
+ });
+
+ test('sends x-sveltekit-version header on data responses', async ({ request }) => {
+ const response = await request.get('/__data.json');
+ expect(response.headers()['x-sveltekit-version']).toBeTruthy();
+ });
});
test.describe('reroute', () => {
diff --git a/packages/kit/test/apps/options-3/test/test.js b/packages/kit/test/apps/options-3/test/test.js
index ff0d914f1877..91ba50c75f97 100644
--- a/packages/kit/test/apps/options-3/test/test.js
+++ b/packages/kit/test/apps/options-3/test/test.js
@@ -32,4 +32,9 @@ test.describe("bundleStrategy: 'inline'", () => {
test('still emits version.json', () => {
expect(fs.existsSync(`${client}/_app/version.json`)).toBe(true);
});
+
+ test('does not send x-sveltekit-version header when checks are disabled', async ({ request }) => {
+ const response = await request.get('/serialization-stream/__data.json');
+ expect(response.headers()['x-sveltekit-version']).toBeUndefined();
+ });
});
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index f3c28f49ed09..1f13ead141dc 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -847,9 +847,9 @@ declare module '@sveltejs/kit' {
};
/**
* Client-side navigation can be buggy if you deploy a new version of your app while people are using it. If the code for the new page is already loaded, it may have stale content; if it isn't, the app's route manifest may point to a JavaScript file that no longer exists.
- * SvelteKit helps you solve this problem through version management.
+ * SvelteKit helps you solve this problem through version management. The current version is included in data, remote, and form action responses via the `x-sveltekit-version` header, so SvelteKit can detect new deployments without polling — for example when a navigation triggers a server `load` function, or when a remote function is called. SvelteKit also checks for new versions when the tab regains focus or becomes visible.
* If SvelteKit encounters an error while loading the page and detects that a new version has been deployed (using the `name` specified here, which defaults to a timestamp of the build) it will fall back to traditional full-page navigation.
- * Not all navigations will result in an error though, for example if the JavaScript for the next page is already loaded. If you still want to force a full-page navigation in these cases, use techniques such as setting the `pollInterval` and then using `beforeNavigate`:
+ * Not all navigations will result in an error though, for example if the JavaScript for the next page is already loaded. If you still want to force a full-page navigation in these cases, use `beforeNavigate`:
* ```html
* /// file: +layout.svelte
*
* ```
*
- * If you set `pollInterval` to a non-zero value, SvelteKit will poll for new versions in the background and set the value of [`updated.current`](https://svelte.dev/docs/kit/$app-state#updated) `true` when it detects one.
+ * In addition to these checks, SvelteKit polls for new versions on an interval and sets [`updated.current`](https://svelte.dev/docs/kit/$app-state#updated) to `true` when it detects one. Set `pollInterval` to `0` to disable polling (the header- and event-based checks will still run).
*/
version?: {
/**
@@ -891,8 +891,8 @@ declare module '@sveltejs/kit' {
*/
name?: string;
/**
- * The interval in milliseconds to poll for version changes. If this is `0`, no polling occurs.
- * @default 0
+ * The interval in milliseconds to poll for version changes. If this is `0`, no polling occurs. SvelteKit also checks for new versions on server responses (via the `x-sveltekit-version` header) and when the tab regains focus or becomes visible, so polling is only needed for long-lived sessions on a single page.
+ * @default 3600000
*/
pollInterval?: number;
};
@@ -3619,7 +3619,7 @@ declare module '$app/state' {
complete: null;
};
/**
- * A read-only reactive value that's initially `false`. If [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version) is a non-zero value, SvelteKit will poll for new versions of the app and update `current` to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling.
+ * A read-only reactive value that's initially `false`. SvelteKit checks for new versions on data, remote, and form action responses (via the `x-sveltekit-version` header), when the tab regains focus or becomes visible, and on a poll interval (see [`version.pollInterval`](https://svelte.dev/docs/kit/configuration#version)). `updated.current` is set to `true` when a new version is detected. `updated.check()` will force an immediate check, regardless of polling.
* */
export const updated: {
get current(): boolean;