From c11c5ec0e381fc45f9f6ead1bce42f219eb8de61 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 16 Jul 2025 14:41:16 -0400 Subject: [PATCH 1/7] docs: tweak createSubscriber explanation (#16398) * docs: tweak createSubscriber explanation * regenerate --- packages/svelte/src/reactivity/create-subscriber.js | 10 +++++++--- packages/svelte/types/index.d.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/svelte/src/reactivity/create-subscriber.js b/packages/svelte/src/reactivity/create-subscriber.js index afcea9c5b40a..4dcac4e6f61b 100644 --- a/packages/svelte/src/reactivity/create-subscriber.js +++ b/packages/svelte/src/reactivity/create-subscriber.js @@ -6,10 +6,13 @@ import { DEV } from 'esm-env'; import { queue_micro_task } from '../internal/client/dom/task.js'; /** - * Returns a `subscribe` function that, if called in an effect (including expressions in the template), - * calls its `start` callback with an `update` function. Whenever `update` is called, the effect re-runs. + * Returns a `subscribe` function that integrates external event-based systems with Svelte's reactivity. + * It's particularly useful for integrating with web APIs like `MediaQuery`, `IntersectionObserver`, or `WebSocket`. * - * If `start` returns a function, it will be called when the effect is destroyed. + * If `subscribe` is called inside an effect (including indirectly, for example inside a getter), + * the `start` callback will be called with an `update` function. Whenever `update` is called, the effect re-runs. + * + * If `start` returns a cleanup function, it will be called when the effect is destroyed. * * If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects * are active, and the returned teardown function will only be called when all effects are destroyed. @@ -37,6 +40,7 @@ import { queue_micro_task } from '../internal/client/dom/task.js'; * } * * get current() { + * // This makes the getter reactive, if read in an effect * this.#subscribe(); * * // Return the current state of the query, whether or not we're in an effect diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index d356762a3fcc..a8b769d6d4c3 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2335,10 +2335,13 @@ declare module 'svelte/reactivity' { constructor(query: string, fallback?: boolean | undefined); } /** - * Returns a `subscribe` function that, if called in an effect (including expressions in the template), - * calls its `start` callback with an `update` function. Whenever `update` is called, the effect re-runs. + * Returns a `subscribe` function that integrates external event-based systems with Svelte's reactivity. + * It's particularly useful for integrating with web APIs like `MediaQuery`, `IntersectionObserver`, or `WebSocket`. * - * If `start` returns a function, it will be called when the effect is destroyed. + * If `subscribe` is called inside an effect (including indirectly, for example inside a getter), + * the `start` callback will be called with an `update` function. Whenever `update` is called, the effect re-runs. + * + * If `start` returns a cleanup function, it will be called when the effect is destroyed. * * If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects * are active, and the returned teardown function will only be called when all effects are destroyed. @@ -2366,6 +2369,7 @@ declare module 'svelte/reactivity' { * } * * get current() { + * // This makes the getter reactive, if read in an effect * this.#subscribe(); * * // Return the current state of the query, whether or not we're in an effect From 45cd000890202aff68e4c8aeeead1203ee369c7b Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:44:03 -0700 Subject: [PATCH 2/7] chore: fix peer dependency warning (#16401) --- package.json | 2 +- pnpm-lock.yaml | 26 +++++++++++++++----------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 3e609db87fb3..458bf340841b 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@sveltejs/eslint-config": "^8.1.0", "@svitejs/changesets-changelog-github-compact": "^1.1.0", "@types/node": "^20.11.5", - "@vitest/coverage-v8": "^2.0.5", + "@vitest/coverage-v8": "^2.1.9", "eslint": "^9.9.1", "eslint-plugin-lube": "^0.4.3", "jsdom": "25.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0982dc7915e..5f902186ef11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^20.11.5 version: 20.12.7 '@vitest/coverage-v8': - specifier: ^2.0.5 - version: 2.0.5(vitest@2.1.9(@types/node@20.12.7)(jsdom@25.0.1)(lightningcss@1.23.0)(sass@1.70.0)(terser@5.27.0)) + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@20.12.7)(jsdom@25.0.1)(lightningcss@1.23.0)(sass@1.70.0)(terser@5.27.0)) eslint: specifier: ^9.9.1 version: 9.9.1 @@ -869,10 +869,14 @@ packages: resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/coverage-v8@2.0.5': - resolution: {integrity: sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==} + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} peerDependencies: - vitest: 2.0.5 + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -1683,8 +1687,8 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - magicast@0.3.4: - resolution: {integrity: sha512-TyDF/Pn36bBji9rWKHlZe+PZb6Mx5V8IHCSxk7X4aljM4e/vyDvZZYwHewdVaqiA0nb3ghfHU/6AUpDxWoER2Q==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -3173,7 +3177,7 @@ snapshots: '@typescript-eslint/types': 8.32.1 eslint-visitor-keys: 4.2.0 - '@vitest/coverage-v8@2.0.5(vitest@2.1.9(@types/node@20.12.7)(jsdom@25.0.1)(lightningcss@1.23.0)(sass@1.70.0)(terser@5.27.0))': + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@20.12.7)(jsdom@25.0.1)(lightningcss@1.23.0)(sass@1.70.0)(terser@5.27.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -3183,7 +3187,7 @@ snapshots: istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.1.7 magic-string: 0.30.17 - magicast: 0.3.4 + magicast: 0.3.5 std-env: 3.8.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 @@ -4045,7 +4049,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - magicast@0.3.4: + magicast@0.3.5: dependencies: '@babel/parser': 7.25.4 '@babel/types': 7.25.4 @@ -4053,7 +4057,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.1 + semver: 7.7.2 merge2@1.4.1: {} From ead409120229a3c8677d357823b86242fa196dda Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 16 Jul 2025 18:54:45 -0400 Subject: [PATCH 3/7] docs: diligently describe destructured derived declarations (#16400) --- documentation/docs/02-runes/03-$derived.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/documentation/docs/02-runes/03-$derived.md b/documentation/docs/02-runes/03-$derived.md index 2464aa929550..5f253cf6d130 100644 --- a/documentation/docs/02-runes/03-$derived.md +++ b/documentation/docs/02-runes/03-$derived.md @@ -94,6 +94,23 @@ let selected = $derived(items[index]); ...you can change (or `bind:` to) properties of `selected` and it will affect the underlying `items` array. If `items` was _not_ deeply reactive, mutating `selected` would have no effect. +## Destructuring + +If you use destructuring with a `$derived` declaration, the resulting variables will all be reactive — this... + +```js +let { a, b, c } = $derived(stuff()); +``` + +...is roughly equivalent to this: + +```js +let _stuff = $derived(stuff()); +let a = $derived(_stuff.a); +let b = $derived(_stuff.b); +let c = $derived(_stuff.c); +``` + ## Update propagation Svelte uses something called _push-pull reactivity_ — when state is updated, everything that depends on the state (whether directly or indirectly) is immediately notified of the change (the 'push'), but derived values are not re-evaluated until they are actually read (the 'pull'). From 3edebd51035404c30c6c9f694c79037e2f93d433 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 16 Jul 2025 18:54:56 -0400 Subject: [PATCH 4/7] chore: update to new pnpm syntax (#16399) --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e2628f84f77..c2d3e45049c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,10 +105,10 @@ Test samples are kept in `/test/xxx/samples` folder. pnpm test validator ``` -1. To filter tests _within_ a test suite, use `pnpm test -- -t `, for example: +1. To filter tests _within_ a test suite, use `pnpm test -t `, for example: ```bash - pnpm test validator -- -t a11y-alt-text + pnpm test validator -t a11y-alt-text ``` (You can also do `FILTER= pnpm test ` which removes other tests rather than simply skipping them — this will result in faster and more compact test results, but it's non-idiomatic. Choose your fighter.) From 09c9a3c16533d2223e17700684718b4829cda9c6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 16 Jul 2025 18:55:24 -0400 Subject: [PATCH 5/7] fix: silence `$inspect` errors when the effect is about to be destroyed (#16391) * fix: silence `$inspect` errors when the effect is about to be destroyed * changeset --- .changeset/six-swans-rush.md | 5 ++ .../svelte/src/internal/client/dev/inspect.js | 51 ++++++++++++------- .../samples/inspect-exception/_config.js | 2 +- 3 files changed, 39 insertions(+), 19 deletions(-) create mode 100644 .changeset/six-swans-rush.md diff --git a/.changeset/six-swans-rush.md b/.changeset/six-swans-rush.md new file mode 100644 index 000000000000..cfb5b9740082 --- /dev/null +++ b/.changeset/six-swans-rush.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: silence `$inspect` errors when the effect is about to be destroyed diff --git a/packages/svelte/src/internal/client/dev/inspect.js b/packages/svelte/src/internal/client/dev/inspect.js index e15c66901c5f..c593f2622ca8 100644 --- a/packages/svelte/src/internal/client/dev/inspect.js +++ b/packages/svelte/src/internal/client/dev/inspect.js @@ -1,6 +1,6 @@ import { UNINITIALIZED } from '../../../constants.js'; import { snapshot } from '../../shared/clone.js'; -import { inspect_effect, validate_effect } from '../reactivity/effects.js'; +import { inspect_effect, render_effect, validate_effect } from '../reactivity/effects.js'; import { untrack } from '../runtime.js'; /** @@ -12,29 +12,44 @@ export function inspect(get_value, inspector = console.log) { validate_effect('$inspect'); let initial = true; + let error = /** @type {any} */ (UNINITIALIZED); + // Inspect effects runs synchronously so that we can capture useful + // stack traces. As a consequence, reading the value might result + // in an error (an `$inspect(object.property)` will run before the + // `{#if object}...{/if}` that contains it) inspect_effect(() => { - /** @type {any} */ - var value = UNINITIALIZED; - - // Capturing the value might result in an exception due to the inspect effect being - // sync and thus operating on stale data. In the case we encounter an exception we - // can bail-out of reporting the value. Instead we simply console.error the error - // so at least it's known that an error occured, but we don't stop execution try { - value = get_value(); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); + var value = get_value(); + } catch (e) { + error = e; + return; } - if (value !== UNINITIALIZED) { - var snap = snapshot(value, true); - untrack(() => { - inspector(initial ? 'init' : 'update', ...snap); - }); - } + var snap = snapshot(value, true); + untrack(() => { + inspector(initial ? 'init' : 'update', ...snap); + }); initial = false; }); + + // If an error occurs, we store it (along with its stack trace). + // If the render effect subsequently runs, we log the error, + // but if it doesn't run it's because the `$inspect` was + // destroyed, meaning we don't need to bother + render_effect(() => { + try { + // call `get_value` so that this runs alongside the inspect effect + get_value(); + } catch { + // ignore + } + + if (error !== UNINITIALIZED) { + // eslint-disable-next-line no-console + console.error(error); + error = UNINITIALIZED; + } + }); } diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-exception/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-exception/_config.js index e155ff236a48..83e0eb944370 100644 --- a/packages/svelte/tests/runtime-runes/samples/inspect-exception/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/inspect-exception/_config.js @@ -11,7 +11,7 @@ export default test({ b1?.click(); flushSync(); - assert.ok(errors.length > 0); + assert.equal(errors.length, 0); assert.deepEqual(logs, ['init', 'a', 'init', 'b']); } }); From a67b5862f15ce763c0d3a038f88abbb4b08d2d80 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 16 Jul 2025 19:24:57 -0400 Subject: [PATCH 6/7] fix: more informative error when effects run in an infinite loop (#16405) * update effect_update_depth_exceeded docs * log update locations * remove dev_effect_stack stuff, it's not very helpful * tidy up * test * fix test * changeset * fix --- .changeset/slimy-doors-fetch.md | 5 ++ .../98-reference/.generated/client-errors.md | 40 ++++++++++- .../svelte/messages/client-errors/errors.md | 40 ++++++++++- .../svelte/src/internal/client/dev/tracing.js | 64 +++++++++-------- packages/svelte/src/internal/client/errors.js | 4 +- .../src/internal/client/reactivity/batch.js | 71 ++++++++----------- .../src/internal/client/reactivity/sources.js | 18 ++++- .../src/internal/client/reactivity/types.d.ts | 4 +- .../svelte/src/internal/client/runtime.js | 12 +--- .../samples/effect-loop-infinite/_config.js | 21 ++++++ .../samples/effect-loop-infinite/main.svelte | 12 ++++ .../samples/error-boundary-20/_config.js | 4 +- 12 files changed, 202 insertions(+), 93 deletions(-) create mode 100644 .changeset/slimy-doors-fetch.md create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/main.svelte diff --git a/.changeset/slimy-doors-fetch.md b/.changeset/slimy-doors-fetch.md new file mode 100644 index 000000000000..8dec24a98dab --- /dev/null +++ b/.changeset/slimy-doors-fetch.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: more informative error when effects run in an infinite loop diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index c7d6ec8ac9cd..3b17ef9f9b4e 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -89,9 +89,47 @@ Effect cannot be created inside a `$derived` value that was not itself created i ### effect_update_depth_exceeded ``` -Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops +Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state ``` +If an effect updates some state that it also depends on, it will re-run, potentially in a loop: + +```js +let count = $state(0); + +$effect(() => { + // this both reads and writes `count`, + // so will run in an infinite loop + count += 1; +}); +``` + +(Svelte intervenes before this can crash your browser tab.) + +The same applies to array mutations, since these both read and write to the array: + +```js +let array = $state([]); + +$effect(() => { + array.push('hello'); +}); +``` + +Note that it's fine for an effect to re-run itself as long as it 'settles': + +```js +let array = ['a', 'b', 'c']; +// ---cut--- +$effect(() => { + // this is okay, because sorting an already-sorted array + // won't result in a mutation + array.sort(); +}); +``` + +Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. + ### flush_sync_in_effect ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index 2ce25dbd53e6..d6af8598812b 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -60,7 +60,45 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long ## effect_update_depth_exceeded -> Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops +> Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state + +If an effect updates some state that it also depends on, it will re-run, potentially in a loop: + +```js +let count = $state(0); + +$effect(() => { + // this both reads and writes `count`, + // so will run in an infinite loop + count += 1; +}); +``` + +(Svelte intervenes before this can crash your browser tab.) + +The same applies to array mutations, since these both read and write to the array: + +```js +let array = $state([]); + +$effect(() => { + array.push('hello'); +}); +``` + +Note that it's fine for an effect to re-run itself as long as it 'settles': + +```js +let array = ['a', 'b', 'c']; +// ---cut--- +$effect(() => { + // this is okay, because sorting an already-sorted array + // won't result in a mutation + array.sort(); +}); +``` + +Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. ## flush_sync_in_effect diff --git a/packages/svelte/src/internal/client/dev/tracing.js b/packages/svelte/src/internal/client/dev/tracing.js index 128942ceb293..b7a6a385486e 100644 --- a/packages/svelte/src/internal/client/dev/tracing.js +++ b/packages/svelte/src/internal/client/dev/tracing.js @@ -56,8 +56,10 @@ function log_entry(signal, entry) { } if (dirty && signal.updated) { - // eslint-disable-next-line no-console - console.log(signal.updated); + for (const updated of signal.updated.values()) { + // eslint-disable-next-line no-console + console.log(updated.error); + } } if (entry) { @@ -120,44 +122,46 @@ export function trace(label, fn) { /** * @param {string} label + * @returns {Error & { stack: string } | null} */ export function get_stack(label) { let error = Error(); const stack = error.stack; - if (stack) { - const lines = stack.split('\n'); - const new_lines = ['\n']; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - if (line === 'Error') { - continue; - } - if (line.includes('validate_each_keys')) { - return null; - } - if (line.includes('svelte/src/internal')) { - continue; - } - new_lines.push(line); - } + if (!stack) return null; - if (new_lines.length === 1) { + const lines = stack.split('\n'); + const new_lines = ['\n']; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line === 'Error') { + continue; + } + if (line.includes('validate_each_keys')) { return null; } + if (line.includes('svelte/src/internal')) { + continue; + } + new_lines.push(line); + } - define_property(error, 'stack', { - value: new_lines.join('\n') - }); - - define_property(error, 'name', { - // 'Error' suffix is required for stack traces to be rendered properly - value: `${label}Error` - }); + if (new_lines.length === 1) { + return null; } - return error; + + define_property(error, 'stack', { + value: new_lines.join('\n') + }); + + define_property(error, 'name', { + // 'Error' suffix is required for stack traces to be rendered properly + value: `${label}Error` + }); + + return /** @type {Error & { stack: string }} */ (error); } /** diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index a491dc683d9e..edd66a7400d7 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -214,12 +214,12 @@ export function effect_pending_outside_reaction() { } /** - * Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops + * Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state * @returns {never} */ export function effect_update_depth_exceeded() { if (DEV) { - const error = new Error(`effect_update_depth_exceeded\nMaximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops\nhttps://svelte.dev/e/effect_update_depth_exceeded`); + const error = new Error(`effect_update_depth_exceeded\nMaximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state\nhttps://svelte.dev/e/effect_update_depth_exceeded`); error.name = 'Svelte error'; diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 1d08b5c3d82b..cdce971b189a 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -46,9 +46,6 @@ export let current_batch = null; */ export let batch_deriveds = null; -/** @type {Effect[]} Stack of effects, dev only */ -export let dev_effect_stack = []; - /** @type {Set<() => void>} */ export let effect_pending_updates = new Set(); @@ -345,6 +342,28 @@ export class Batch { while (queued_root_effects.length > 0) { if (flush_count++ > 1000) { + if (DEV) { + var updates = new Map(); + + for (const source of this.#current.keys()) { + for (const [stack, update] of source.updated ?? []) { + var entry = updates.get(stack); + + if (!entry) { + entry = { error: update.error, count: 0 }; + updates.set(stack, entry); + } + + entry.count += update.count; + } + } + + for (const update of updates.values()) { + // eslint-disable-next-line no-console + console.error(update.error); + } + } + infinite_loop_guard(); } @@ -356,9 +375,6 @@ export class Batch { set_is_updating_effect(was_updating_effect); last_scheduled_effect = null; - if (DEV) { - dev_effect_stack = []; - } } } @@ -471,10 +487,6 @@ export function flushSync(fn) { // we need to reset it here as well in case the first time there's 0 queued root effects last_scheduled_effect = null; - if (DEV) { - dev_effect_stack = []; - } - return /** @type {T} */ (result); } @@ -482,45 +494,18 @@ export function flushSync(fn) { } } -function log_effect_stack() { - // eslint-disable-next-line no-console - console.error( - 'Last ten effects were: ', - dev_effect_stack.slice(-10).map((d) => d.fn) - ); - dev_effect_stack = []; -} - function infinite_loop_guard() { try { e.effect_update_depth_exceeded(); } catch (error) { if (DEV) { - // stack is garbage, ignore. Instead add a console.error message. - define_property(error, 'stack', { - value: '' - }); - } - // Try and handle the error so it can be caught at a boundary, that's - // if there's an effect available from when it was last scheduled - if (last_scheduled_effect !== null) { - if (DEV) { - try { - invoke_error_boundary(error, last_scheduled_effect); - } catch (e) { - // Only log the effect stack if the error is re-thrown - log_effect_stack(); - throw e; - } - } else { - invoke_error_boundary(error, last_scheduled_effect); - } - } else { - if (DEV) { - log_effect_stack(); - } - throw error; + // stack contains no useful information, replace it + define_property(error, 'stack', { value: '' }); } + + // Best effort: invoke the boundary nearest the most recent + // effect and hope that it's relevant to the infinite loop + invoke_error_boundary(error, last_scheduled_effect); } } diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index bd55b9d935f4..9b534d2d7190 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -182,8 +182,22 @@ export function internal_set(source, value) { const batch = Batch.ensure(); batch.capture(source, old_value); - if (DEV && tracing_mode_flag) { - source.updated = get_stack('UpdatedAt'); + if (DEV) { + if (tracing_mode_flag || active_effect !== null) { + const error = get_stack('UpdatedAt'); + + if (error !== null) { + source.updated ??= new Map(); + let entry = source.updated.get(error.stack); + + if (!entry) { + entry = { error, count: 0 }; + source.updated.set(error.stack, entry); + } + + entry.count++; + } + } if (active_effect !== null) { source.set_during_effect = true; diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 90f922df686b..72187e84a720 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -29,8 +29,8 @@ export interface Value extends Signal { label?: string; /** An error with a stack trace showing when the source was created */ created?: Error | null; - /** An error with a stack trace showing when the source was last updated */ - updated?: Error | null; + /** An map of errors with stack traces showing when the source was updated, keyed by the stack trace */ + updated?: Map | null; /** * Whether or not the source was set while running an effect — if so, we need to * increment the write version so that it shows up as dirty when the effect re-runs diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 6c4d92bbadf5..306b9b9dd9a1 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -42,13 +42,7 @@ import { set_dev_stack } from './context.js'; import * as w from './warnings.js'; -import { - Batch, - batch_deriveds, - dev_effect_stack, - flushSync, - schedule_effect -} from './reactivity/batch.js'; +import { Batch, batch_deriveds, flushSync, schedule_effect } from './reactivity/batch.js'; import { handle_error } from './error-handling.js'; import { UNINITIALIZED } from '../../constants.js'; @@ -491,10 +485,6 @@ export function update_effect(effect) { } } } - - if (DEV) { - dev_effect_stack.push(effect); - } } finally { is_updating_effect = was_updating_effect; active_effect = previous_effect; diff --git a/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js new file mode 100644 index 000000000000..400495050cfd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/_config.js @@ -0,0 +1,21 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client', 'hydrate'], + + compileOptions: { + dev: true + }, + + test({ assert, errors }) { + const [button] = document.querySelectorAll('button'); + + try { + flushSync(() => button.click()); + } catch (e) { + assert.equal(errors.length, 1); // for whatever reason we can't get the name which should be UpdatedAtError + assert.ok(/** @type {Error} */ (e).message.startsWith('effect_update_depth_exceeded')); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/main.svelte b/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/main.svelte new file mode 100644 index 000000000000..ddb91a90ad22 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-loop-infinite/main.svelte @@ -0,0 +1,12 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/error-boundary-20/_config.js b/packages/svelte/tests/runtime-runes/samples/error-boundary-20/_config.js index ccff614ade0d..e3a3b0c7d752 100644 --- a/packages/svelte/tests/runtime-runes/samples/error-boundary-20/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/error-boundary-20/_config.js @@ -2,12 +2,14 @@ import { flushSync } from 'svelte'; import { test } from '../../test'; export default test({ - test({ assert, target }) { + test({ assert, target, errors }) { let btn = target.querySelector('button'); btn?.click(); flushSync(); + assert.equal(errors.length, 1); + assert.htmlEqual(target.innerHTML, `
An error occurred!
`); } }); From fcfbc9cca0720d2d57cf6a594f099f568dedfe88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 16 Jul 2025 20:01:58 -0400 Subject: [PATCH 7/7] Version Packages (#16406) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/six-swans-rush.md | 5 ----- .changeset/slimy-doors-fetch.md | 5 ----- packages/svelte/CHANGELOG.md | 8 ++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) delete mode 100644 .changeset/six-swans-rush.md delete mode 100644 .changeset/slimy-doors-fetch.md diff --git a/.changeset/six-swans-rush.md b/.changeset/six-swans-rush.md deleted file mode 100644 index cfb5b9740082..000000000000 --- a/.changeset/six-swans-rush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: silence `$inspect` errors when the effect is about to be destroyed diff --git a/.changeset/slimy-doors-fetch.md b/.changeset/slimy-doors-fetch.md deleted file mode 100644 index 8dec24a98dab..000000000000 --- a/.changeset/slimy-doors-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: more informative error when effects run in an infinite loop diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 6d4f31480d6e..56f91c395f79 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,13 @@ # svelte +## 5.36.5 + +### Patch Changes + +- fix: silence `$inspect` errors when the effect is about to be destroyed ([#16391](https://github.com/sveltejs/svelte/pull/16391)) + +- fix: more informative error when effects run in an infinite loop ([#16405](https://github.com/sveltejs/svelte/pull/16405)) + ## 5.36.4 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 3e9022a0910f..2a1b3cf9e516 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.36.4", + "version": "5.36.5", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index f8d23b44b1e5..4d6811c409df 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.36.4'; +export const VERSION = '5.36.5'; export const PUBLIC_VERSION = '5'; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy