Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/more-frequent-updated-checks.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const get_defaults = (prefix = '') => ({
},
version: {
name: Date.now().toString(),
pollInterval: 0
pollInterval: 3_600_000
}
}
});
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
});

Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const options = {
)},
error
},
version: ${s(config.kit.version.name)},
version_hash: ${s(hash(config.kit.version.name))}
};
Expand Down
10 changes: 5 additions & 5 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <script>
Expand All @@ -892,7 +892,7 @@ export interface KitConfig {
* </script>
* ```
*
* 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?: {
/**
Expand All @@ -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;
};
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/runtime/app/forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/app/state/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>; }}
*/
export const updated = BROWSER ? client_updated : server_updated;
13 changes: 11 additions & 2 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,37 @@ 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<Response> & { json?: () => Promise<any> }} 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();
});

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',
Expand All @@ -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',
Expand All @@ -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))
})
);
Expand Down
75 changes: 54 additions & 21 deletions packages/kit/src/runtime/client/state.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> | 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<boolean>} */
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;
Comment thread
Rich-Harris marked this conversation as resolved.
} 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);
Expand Down
Loading
Loading