diff --git a/.changeset/modern-towns-call.md b/.changeset/modern-towns-call.md new file mode 100644 index 0000000000..77ca8e9185 --- /dev/null +++ b/.changeset/modern-towns-call.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: discard batches made obsolete by commit diff --git a/.changeset/quiet-jars-search.md b/.changeset/quiet-jars-search.md deleted file mode 100644 index 4d229da74a..0000000000 --- a/.changeset/quiet-jars-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: properly lazily evaluate RHS when checking for `assignment_value_stale` diff --git a/.changeset/upset-parts-throw.md b/.changeset/upset-parts-throw.md deleted file mode 100644 index 5835fd48e0..0000000000 --- a/.changeset/upset-parts-throw.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: rebase batches after process, not during diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml new file mode 100644 index 0000000000..706a62e3d6 --- /dev/null +++ b/.github/workflows/autofix.yml @@ -0,0 +1,69 @@ +name: Autofix Lint + +on: + issue_comment: + types: [created] + workflow_dispatch: + +permissions: {} + +jobs: + autofix-lint: + permissions: + contents: write # to push the generated types commit + pull-requests: read # to resolve the PR head ref + # prevents this action from running on forks + if: | + github.repository == 'sveltejs/svelte' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.event.issue.pull_request != null && + github.event.comment.body == '/autofix' && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + ) + ) + runs-on: ubuntu-latest + steps: + - name: Get PR ref + if: github.event_name != 'workflow_dispatch' + id: pr + uses: actions/github-script@v8 + with: + script: | + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + if (pull.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'Cannot autofix: this PR is from a forked repository. The autofix workflow can only push to branches within this repository.' + }); + core.setFailed('PR is from a fork'); + } + core.setOutput('ref', pull.head.ref); + - uses: actions/checkout@v6 + if: github.event_name == 'workflow_dispatch' || steps.pr.outcome == 'success' + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || steps.pr.outputs.ref }} + - uses: pnpm/action-setup@v4.3.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build + run: pnpm -F svelte build + - name: Run prettier + run: pnpm format + - name: Commit changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git diff --staged --quiet || git commit -m "chore: autofix" + git push origin HEAD diff --git a/.prettierignore b/.prettierignore index ee5ef6d8c6..92d9bc797b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,6 +31,8 @@ packages/svelte/tests/parser-modern/samples/*/_actual.json packages/svelte/tests/parser-modern/samples/*/output.json packages/svelte/types packages/svelte/compiler/index.js +playgrounds/sandbox/dist/* +playgrounds/sandbox/output/* playgrounds/sandbox/src/* **/node_modules diff --git a/documentation/docs/07-misc/01-best-practices.md b/documentation/docs/07-misc/01-best-practices.md index 66f7da2613..e2cb72828f 100644 --- a/documentation/docs/07-misc/01-best-practices.md +++ b/documentation/docs/07-misc/01-best-practices.md @@ -143,7 +143,7 @@ The CSS in a component's ` ``` -If this impossible (for example, the child component comes from a library) you can use `:global` to override styles: +If this is impossible (for example, the child component comes from a library) you can use `:global` to override styles: ```svelte
diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index 136b3f4957..739bd58b35 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -42,6 +42,12 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` ``` +### invariant_violation + +``` +An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "%message%" +``` + ### lifecycle_outside_component ``` diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 35a0b2df3f..3ca0db8dec 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,33 @@ # svelte +## 5.53.12 + +### Patch Changes + +- fix: update `select.__value` on `change` ([#17745](https://github.com/sveltejs/svelte/pull/17745)) + +- chore: add `invariant` helper for debugging ([#17929](https://github.com/sveltejs/svelte/pull/17929)) + +- fix: ensure deriveds values are correct across batches ([#17917](https://github.com/sveltejs/svelte/pull/17917)) + +- fix: handle async RHS in `assignment_value_stale` ([#17925](https://github.com/sveltejs/svelte/pull/17925)) + +- fix: avoid traversing clean roots ([#17928](https://github.com/sveltejs/svelte/pull/17928)) + +## 5.53.11 + +### Patch Changes + +- fix: remove `untrack` circular dependency ([#17910](https://github.com/sveltejs/svelte/pull/17910)) + +- fix: recover from errors that leave a corrupted effect tree ([#17888](https://github.com/sveltejs/svelte/pull/17888)) + +- fix: properly lazily evaluate RHS when checking for `assignment_value_stale` ([#17906](https://github.com/sveltejs/svelte/pull/17906)) + +- fix: resolve boundary in correct batch when hydrating ([#17914](https://github.com/sveltejs/svelte/pull/17914)) + +- chore: rebase batches after process, not during ([#17900](https://github.com/sveltejs/svelte/pull/17900)) + ## 5.53.10 ### Patch Changes diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index bf053283e4..3ff474768e 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -34,6 +34,10 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P > A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}` +## invariant_violation + +> An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "%message%" + ## lifecycle_outside_component > `%name%(...)` can only be used during component initialisation diff --git a/packages/svelte/package.json b/packages/svelte/package.json index bd55811602..49c53a26eb 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.53.10", + "version": "5.53.12", "type": "module", "types": "./types/index.d.ts", "engines": { @@ -138,14 +138,15 @@ "templating" ], "scripts": { - "build": "node scripts/process-messages && rollup -c && pnpm generate:types && node scripts/check-treeshakeability.js", + "build": "rollup -c && pnpm generate", "dev": "node scripts/process-messages -w & rollup -cw", "check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc", "check:tsgo": "tsgo --project tsconfig.runtime.json --skipLibCheck && tsgo --skipLibCheck", "check:watch": "tsc --watch", + "generate": "node scripts/process-messages && node ./scripts/generate-types.js", "generate:version": "node ./scripts/generate-version.js", "generate:types": "node ./scripts/generate-types.js && tsc -p tsconfig.generated.json", - "prepublishOnly": "pnpm build", + "prepublishOnly": "pnpm build && node scripts/check-treeshakeability.js", "knip": "pnpm dlx knip" }, "devDependencies": { @@ -175,7 +176,7 @@ "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.3", + "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index 5282f1ed64..e66e3408e2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -5,7 +5,8 @@ import * as b from '#compiler/builders'; import { build_assignment_value, get_attribute_expression, - is_event_attribute + is_event_attribute, + is_expression_async } from '../../../../utils/ast.js'; import { dev, locate_node } from '../../../../state.js'; import { build_getter, should_proxy } from '../utils.js'; @@ -36,14 +37,6 @@ function is_non_coercive_operator(operator) { return ['=', '||=', '&&=', '??='].includes(operator); } -/** @type {Record} */ -const callees = { - '=': '$.assign', - '&&=': '$.assign_and', - '||=': '$.assign_or', - '??=': '$.assign_nullish' -}; - /** * @param {AssignmentOperator} operator * @param {Pattern} left @@ -179,7 +172,7 @@ function build_assignment(operator, left, right, context) { // in cases like `(object.items ??= []).push(value)`, we may need to warn // if the value gets proxified, since the proxy _isn't_ the thing that // will be pushed to. we do this by transforming it to something like - // `$.assign_nullish(object, 'items', () => [])` + // `$.assign(object, 'items', '??=', () => [])` let should_transform = dev && path.at(-1) !== 'ExpressionStatement' && @@ -225,22 +218,23 @@ function build_assignment(operator, left, right, context) { } if (left.type === 'MemberExpression' && should_transform) { - const callee = callees[operator]; - return /** @type {Expression} */ ( - context.visit( - b.call( - callee, - /** @type {Expression} */ (left.object), - /** @type {Expression} */ ( - left.computed - ? left.property - : b.literal(/** @type {Identifier} */ (left.property).name) - ), - b.arrow([], right), - b.literal(locate_node(left)) - ) - ) + const needs_lazy_getter = operator !== '='; + const needs_async = needs_lazy_getter && is_expression_async(right); + /** @type {Expression} */ + let e = b.call( + needs_async ? '$.assign_async' : '$.assign', + /** @type {Expression} */ (left.object), + /** @type {Expression} */ ( + left.computed ? left.property : b.literal(/** @type {Identifier} */ (left.property).name) + ), + b.literal(operator), + needs_lazy_getter ? b.arrow([], right, needs_async) : right, + b.literal(locate_node(left)) ); + if (needs_async) { + e = b.await(e); + } + return /** @type {Expression} */ (context.visit(e)); } return null; diff --git a/packages/svelte/src/internal/client/dev/assign.js b/packages/svelte/src/internal/client/dev/assign.js index 1cda7044b5..e328543b32 100644 --- a/packages/svelte/src/internal/client/dev/assign.js +++ b/packages/svelte/src/internal/client/dev/assign.js @@ -21,12 +21,21 @@ function compare(a, b, property, location) { /** * @param {any} object * @param {string} property - * @param {() => any} rhs_getter + * @param {string} operator + * @param {any} rhs * @param {string} location */ -export function assign(object, property, rhs_getter, location) { +export function assign(object, property, operator, rhs, location) { return compare( - (object[property] = rhs_getter()), + operator === '=' + ? (object[property] = rhs) + : operator === '&&=' + ? (object[property] &&= rhs()) + : operator === '||=' + ? (object[property] ||= rhs()) + : operator === '??=' + ? (object[property] ??= rhs()) + : null, untrack(() => object[property]), property, location @@ -36,42 +45,21 @@ export function assign(object, property, rhs_getter, location) { /** * @param {any} object * @param {string} property - * @param {() => any} rhs_getter + * @param {string} operator + * @param {any} rhs * @param {string} location */ -export function assign_and(object, property, rhs_getter, location) { +export async function assign_async(object, property, operator, rhs, location) { return compare( - (object[property] &&= rhs_getter()), - untrack(() => object[property]), - property, - location - ); -} - -/** - * @param {any} object - * @param {string} property - * @param {() => any} rhs_getter - * @param {string} location - */ -export function assign_or(object, property, rhs_getter, location) { - return compare( - (object[property] ||= rhs_getter()), - untrack(() => object[property]), - property, - location - ); -} - -/** - * @param {any} object - * @param {string} property - * @param {() => any} rhs_getter - * @param {string} location - */ -export function assign_nullish(object, property, rhs_getter, location) { - return compare( - (object[property] ??= rhs_getter()), + operator === '=' + ? (object[property] = await rhs) + : operator === '&&=' + ? (object[property] &&= await rhs()) + : operator === '||=' + ? (object[property] ||= await rhs()) + : operator === '??=' + ? (object[property] ??= await rhs()) + : null, untrack(() => object[property]), property, location diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index b38a3131ca..b440bb3ba4 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -218,8 +218,6 @@ export class Boundary { this.is_pending = true; this.#pending_effect = branch(() => pending(this.#anchor)); - var batch = /** @type {Batch} */ (current_batch); - queue_micro_task(() => { var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); var anchor = create_text(); @@ -238,14 +236,12 @@ export class Boundary { this.#pending_effect = null; }); - this.#resolve(batch); + this.#resolve(/** @type {Batch} */ (current_batch)); } }); } #render() { - var batch = /** @type {Batch} */ (current_batch); - try { this.is_pending = this.has_pending_snippet(); this.#pending_count = 0; @@ -262,7 +258,7 @@ export class Boundary { const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); this.#pending_effect = branch(() => pending(this.#anchor)); } else { - this.#resolve(batch); + this.#resolve(/** @type {Batch} */ (current_batch)); } } catch (error) { this.error(error); @@ -275,21 +271,9 @@ export class Boundary { #resolve(batch) { this.is_pending = false; - // any effects that were previously deferred should be rescheduled — - // after the next traversal (which will happen immediately, due to the - // same update that brought us here) the effects will be flushed - for (const e of this.#dirty_effects) { - set_signal_status(e, DIRTY); - batch.schedule(e); - } - - for (const e of this.#maybe_dirty_effects) { - set_signal_status(e, MAYBE_DIRTY); - batch.schedule(e); - } - - this.#dirty_effects.clear(); - this.#maybe_dirty_effects.clear(); + // any effects that were previously deferred should be transferred + // to the batch, which will flush in the next microtask + batch.transfer_effects(this.#dirty_effects, this.#maybe_dirty_effects); } /** diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js index 21be75ba61..eb26062653 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js @@ -106,6 +106,9 @@ export function bind_select_value(select, get, set = get) { set(value); + // @ts-ignore + select.__value = value; + if (current_batch !== null) { batches.add(current_batch); } diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index af8df2e32c..988998d067 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -1,7 +1,7 @@ export { createAttachmentKey as attachment } from '../../attachments/index.js'; export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js'; export { push, pop, add_svelte_meta } from './context.js'; -export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js'; +export { assign, assign_async } from './dev/assign.js'; export { cleanup_styles } from './dev/css.js'; export { add_locations } from './dev/elements.js'; export { hmr } from './dev/hmr.js'; diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 85025dfe99..dd8b9cbe93 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -40,6 +40,8 @@ import { defer_effect } from './utils.js'; import { UNINITIALIZED } from '../../../constants.js'; import { set_signal_status } from './status.js'; import { legacy_is_updating_store } from './store.js'; +import { invariant } from '../../shared/dev.js'; +import { log_effect_tree } from '../dev/debug.js'; /** @type {Set} */ const batches = new Set(); @@ -242,9 +244,25 @@ export class Batch { #process() { if (flush_count++ > 1000) { + batches.delete(this); infinite_loop_guard(); } + // we only reschedule previously-deferred effects if we expect + // to be able to run them after processing the batch + if (!this.#is_deferred()) { + for (const e of this.#dirty_effects) { + this.#maybe_dirty_effects.delete(e); + set_signal_status(e, DIRTY); + this.schedule(e); + } + + for (const e of this.#maybe_dirty_effects) { + set_signal_status(e, MAYBE_DIRTY); + this.schedule(e); + } + } + const roots = this.#roots; this.#roots = []; @@ -263,7 +281,12 @@ export class Batch { var updates = (legacy_updates = []); for (const root of roots) { - this.#traverse(root, effects, render_effects); + try { + this.#traverse(root, effects, render_effects); + } catch (e) { + reset_all(root); + throw e; + } } // any writes should take effect in a subsequent batch @@ -401,18 +424,16 @@ export class Batch { * Associate a change to a given source with the current * batch, noting its previous and current values * @param {Source} source - * @param {any} value + * @param {any} old_value */ - capture(source, value) { + capture(source, old_value) { if (this.successor) { - this.successor.capture(source, value); + this.successor.capture(source, old_value); return; } - if (!this.is_fork) source.batch = Batch.upsert(source); - - if (value !== UNINITIALIZED && !this.previous.has(source)) { - this.previous.set(source, value); + if (old_value !== UNINITIALIZED && !this.previous.has(source)) { + this.previous.set(source, old_value); } // Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get` @@ -453,21 +474,6 @@ export class Batch { is_processing = true; current_batch = this; - // we only reschedule previously-deferred effects if we expect - // to be able to run them after processing the batch - if (!this.#is_deferred()) { - for (const e of this.#dirty_effects) { - this.#maybe_dirty_effects.delete(e); - set_signal_status(e, DIRTY); - this.schedule(e); - } - - for (const e of this.#maybe_dirty_effects) { - set_signal_status(e, MAYBE_DIRTY); - this.schedule(e); - } - } - this.#process(); } finally { flush_count = 0; @@ -497,6 +503,8 @@ export class Batch { for (const fn of this.#discard_callbacks) fn(this); this.#discard_callbacks.clear(); + + batches.delete(this); } #commit() { @@ -542,6 +550,23 @@ export class Batch { }); } + /** + * @param {Set} dirty_effects + * @param {Set} maybe_dirty_effects + */ + transfer_effects(dirty_effects, maybe_dirty_effects) { + for (const e of dirty_effects) { + this.#dirty_effects.add(e); + } + + for (const e of maybe_dirty_effects) { + this.#maybe_dirty_effects.add(e); + } + + dirty_effects.clear(); + maybe_dirty_effects.clear(); + } + /** @param {(batch: Batch) => void} fn */ oncommit(fn) { if (this.successor) { @@ -750,7 +775,7 @@ export class Batch { // ...and undo changes belonging to other batches for (const batch of batches) { - if (batch === this) continue; + if (batch === this || batch.is_fork) continue; for (const [source, previous] of batch.previous) { if (!batch_values.has(source)) { @@ -1102,6 +1127,20 @@ function reset_branch(effect, tracked) { } } +/** + * Mark an entire effect tree clean following an error + * @param {Effect} effect + */ +function reset_all(effect) { + set_signal_status(effect, CLEAN); + + var e = effect.first; + while (e !== null) { + reset_all(e); + e = e.next; + } +} + /** * Creates a 'fork', in which state changes are evaluated but not applied to the DOM. * This is useful for speculatively loading data (for example) when you suspect that @@ -1144,13 +1183,6 @@ export function fork(fn) { source.v = value; } - // make writable deriveds dirty, so they recalculate correctly - for (source of batch.current.keys()) { - if ((source.f & DERIVED) !== 0) { - set_signal_status(source, DIRTY); - } - } - return { commit: async () => { if (committed) { @@ -1209,7 +1241,6 @@ export function fork(fn) { } if (!committed && batches.has(batch)) { - batches.delete(batch); batch.discard(); } } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 8ec46ee5f3..c736ddb6c4 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -394,6 +394,7 @@ export function execute_derived(derived) { * @returns {void} */ export function update_derived(derived) { + var old_value = derived.v; var value = execute_derived(derived); if (!derived.equals(value)) { @@ -406,6 +407,7 @@ export function update_derived(derived) { if (!current_batch?.is_fork || derived.deps === null) { derived.v = value; derived.batch = current_batch; + current_batch?.capture(derived, old_value); // TODO came in from main merge; check if correct still // deriveds without dependencies should never be recomputed if (derived.deps === null) { diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 18b0e37bb8..fed098e9b9 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -230,7 +230,11 @@ export function internal_set(source, value, updated_during_traversal = null) { execute_derived(derived); } - update_derived_status(derived); + // During time traveling we don't want to reset the status so that + // traversal of the graph in the other batches still happens + if (batch_values === null) { + update_derived_status(derived); + } } source.wv = increment_write_version(); diff --git a/packages/svelte/src/internal/shared/dev.js b/packages/svelte/src/internal/shared/dev.js index aadb3c7e6d..9c36664dc4 100644 --- a/packages/svelte/src/internal/shared/dev.js +++ b/packages/svelte/src/internal/shared/dev.js @@ -1,4 +1,6 @@ +import { DEV } from 'esm-env'; import { define_property } from './utils.js'; +import * as e from './errors.js'; /** * @param {string} label @@ -63,3 +65,15 @@ export function get_stack() { return new_lines; } + +/** + * @param {boolean} condition + * @param {string} message + */ +export function invariant(condition, message) { + if (!DEV) { + throw new Error('invariant(...) was not guarded by if (DEV)'); + } + + if (!condition) e.invariant_violation(message); +} diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index b13a65b598..a4ad8cecff 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -51,6 +51,23 @@ export function invalid_snippet_arguments() { } } +/** + * An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "%message%" + * @param {string} message + * @returns {never} + */ +export function invariant_violation(message) { + if (DEV) { + const error = new Error(`invariant_violation\nAn invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "${message}"\nhttps://svelte.dev/e/invariant_violation`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/invariant_violation`); + } +} + /** * `%name%(...)` can only be used during component initialisation * @param {string} name diff --git a/packages/svelte/src/store/utils.js b/packages/svelte/src/store/utils.js index db2a62c68c..2d36d64d2d 100644 --- a/packages/svelte/src/store/utils.js +++ b/packages/svelte/src/store/utils.js @@ -1,5 +1,5 @@ /** @import { Readable } from './public' */ -import { untrack } from '../index-client.js'; +import { untrack } from '../internal/client/runtime.js'; import { noop } from '../internal/shared/utils.js'; /** diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 13f68c6467..5045430cee 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.53.10'; +export const VERSION = '5.53.12'; export const PUBLIC_VERSION = '5'; diff --git a/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js new file mode 100644 index 0000000000..9ad21cffb0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + async test({ assert, target }) { + const button = /** @type {HTMLElement} */ (target.querySelector('button')); + await tick(); + assert.htmlEqual(target.innerHTML, `

count1: 0, count2: 0

`); + + button.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

count1: 1, count2: 1

`); + + // additional tick necessary in legacy mode because it's using Promise.resolve() which finishes before the await in the component, + // causing the cache to not be set yet, which would result in count2 becoming 2 + await tick(); + + button.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

count1: 2, count2: 1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte new file mode 100644 index 0000000000..be4d0ad2f4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte @@ -0,0 +1,18 @@ + + + +

count1: {count1}, count2: {count2}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js new file mode 100644 index 0000000000..64e1a4b2b5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js @@ -0,0 +1,89 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, shift, pop] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

1 = 1

+ ` + ); + + increment.click(); + await tick(); + increment.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

1 = 1

+ ` + ); + + shift.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

1 = 1

+ ` + ); + + shift.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

3 = 3

+ ` + ); + + increment.click(); + await tick(); + increment.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

3 = 3

+ ` + ); + + pop.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

5 = 5

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte new file mode 100644 index 0000000000..faa8d139a6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte @@ -0,0 +1,36 @@ + + + + + + + +

{n} = {await push(n)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-derived/_config.js new file mode 100644 index 0000000000..043f1610fb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-derived/_config.js @@ -0,0 +1,23 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

true - true

` + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

false - false

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-derived/main.svelte new file mode 100644 index 0000000000..d1d979126d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-eager-derived/main.svelte @@ -0,0 +1,22 @@ + + + + + +

{$state.eager(count) !== count} - {$state.eager(derivedCount) !== derivedCount}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js new file mode 100644 index 0000000000..98440b6922 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn] = target.querySelectorAll('button'); + + btn.click(); + await tick(); + assert.deepEqual(logs, [10]); + + btn.click(); + await tick(); + assert.deepEqual(logs, [10, 10]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte new file mode 100644 index 0000000000..16c1668480 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte new file mode 100644 index 0000000000..b39ca3dfd4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js new file mode 100644 index 0000000000..083608e9dc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + // This test causes two batches to be scheduled such that the same root is traversed multiple times, + // some of the time while it was already marked clean by a previous batch processing. It tests + // that the app stays reactive after, i.e. that the root is not improperly marked as unclean. + await tick(); + const [button] = target.querySelectorAll('button'); + + button.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

hello

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte new file mode 100644 index 0000000000..d6c09803f9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte @@ -0,0 +1,19 @@ + + + + + + + + {#snippet pending()} + + {/snippet} + + +{#if condition} +

hello

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js new file mode 100644 index 0000000000..24e2036d39 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js @@ -0,0 +1,54 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [add, shift, reset] = target.querySelectorAll('button'); + + // resolve initial pending state + shift.click(); + await tick(); + + const [p] = target.querySelectorAll('p'); + + const select = /** @type {HTMLSelectElement} */ (target.querySelector('select')); + assert.equal(select.value, 'a'); + + // add option 'c', making items ['a', 'b', 'c'] + add.click(); + await tick(); + + // select 'b' while focused + select.focus(); + select.value = 'b'; + select.dispatchEvent(new InputEvent('change', { bubbles: true })); + await tick(); + + assert.equal(select.value, 'b'); + assert.equal(p.textContent, 'a'); + + // add option 'd', making items ['a', 'b', 'c', 'd'] + // this triggers MutationObserver which uses select.__value + add.click(); + await tick(); + + // select should still show 'b', not snap to a stale value + assert.equal(select.value, 'b'); + assert.equal(p.textContent, 'a'); + + shift.click(); + await tick(); + assert.equal(select.value, 'b'); + assert.equal(p.textContent, 'b'); + + reset.click(); + await tick(); + assert.equal(select.value, 'b'); + assert.equal(p.textContent, 'b'); + + shift.click(); + await tick(); + assert.equal(select.value, 'a'); + assert.equal(p.textContent, 'a'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte new file mode 100644 index 0000000000..968e920f82 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte @@ -0,0 +1,31 @@ + + + + + + + + + +

{await push(selected)}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js b/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js index e9ccbba2b6..c6f65c33be 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js @@ -18,6 +18,14 @@ export default test({ increment.click(); await tick(); + assert.htmlEqual( + target.innerHTML, + ` + +

0

+ ` + ); + increment.click(); await tick(); @@ -28,5 +36,27 @@ export default test({

2

` ); + + increment.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + +

2

+ ` + ); + + increment.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + +

4

+ ` + ); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte new file mode 100644 index 0000000000..0f18e43e56 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js new file mode 100644 index 0000000000..21575231ee --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + async test({ assert, logs }) { + assert.deepEqual(logs, ['hello from child']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte new file mode 100644 index 0000000000..c4c0ef23ab --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte @@ -0,0 +1,11 @@ + + + + + + {#snippet pending()} +

Loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js b/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js new file mode 100644 index 0000000000..52c1bbd1bf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js @@ -0,0 +1,32 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, compileOptions }) { + const [toggle, increment] = target.querySelectorAll('button'); + + flushSync(() => increment.click()); + assert.htmlEqual( + target.innerHTML, + ` + + +

show: false

+ ` + ); + + assert.throws(() => { + flushSync(() => toggle.click()); + }, /NonExistent is not defined/); + + flushSync(() => increment.click()); + assert.htmlEqual( + target.innerHTML, + ` + + +

show: ${compileOptions.experimental?.async ? 'false' : 'true'}

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte b/packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte new file mode 100644 index 0000000000..03bfae2596 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte @@ -0,0 +1,13 @@ + + + + + +

show: {show}

+ +{#if show} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js b/packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js new file mode 100644 index 0000000000..c91fd59f26 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js @@ -0,0 +1,49 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, variant }) { + const [button] = target.querySelectorAll('button'); + const [select] = target.querySelectorAll('select'); + + flushSync(() => { + select.focus(); + select.value = '2'; + select.dispatchEvent(new InputEvent('change', { bubbles: true })); + }); + + assert.equal(select.selectedOptions[0].textContent, '2'); + + assert.htmlEqual( + target.innerHTML, + ` + +

selected: 2

+ + ` + ); + + flushSync(() => button.click()); + await tick(); + + assert.equal(select.selectedOptions[0].textContent, '2'); + + assert.htmlEqual( + target.innerHTML, + ` + +

selected: 2

+ + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte b/packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte new file mode 100644 index 0000000000..ebf6a6c082 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte @@ -0,0 +1,16 @@ + + + + +

selected: {selected}

+ + diff --git a/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js new file mode 100644 index 0000000000..ead5653dd4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js @@ -0,0 +1,19 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +// While we don't officially document it, `untrack` also allows to opt out of the "unsafe mutation" validation, which is what we test here +export default test({ + html: '', + test({ assert, target }) { + const button = target.querySelector('button'); + + flushSync(() => button?.click()); + + assert.htmlEqual( + target.innerHTML, + ` + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte new file mode 100644 index 0000000000..f61a23da84 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte @@ -0,0 +1,14 @@ + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8693c466c1..aa423e4923 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 devalue: - specifier: ^5.6.3 - version: 5.6.3 + specifier: ^5.6.4 + version: 5.6.4 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1267,8 +1267,8 @@ packages: engines: {node: '>=0.10'} hasBin: true - devalue@5.6.3: - resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} @@ -3563,7 +3563,7 @@ snapshots: detect-libc@1.0.3: optional: true - devalue@5.6.3: {} + devalue@5.6.4: {} dir-glob@3.0.1: dependencies: