diff --git a/.changeset/angry-ideas-listen.md b/.changeset/angry-ideas-listen.md new file mode 100644 index 0000000000..cb2bf00d1c --- /dev/null +++ b/.changeset/angry-ideas-listen.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: proactively defer effects in pending boundary diff --git a/.changeset/icy-otters-fall.md b/.changeset/icy-otters-fall.md deleted file mode 100644 index b15c282ca4..0000000000 --- a/.changeset/icy-otters-fall.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: locate Rollup annontaion friendly to JS downgraders diff --git a/.changeset/volatile-each-key.md b/.changeset/volatile-each-key.md new file mode 100644 index 0000000000..674bce9bec --- /dev/null +++ b/.changeset/volatile-each-key.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: detect and error on non-idempotent each block keys in dev mode diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 8601a728a7..7fccac5808 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -62,6 +62,14 @@ Keyed each block has duplicate key at indexes %a% and %b% Keyed each block has duplicate key `%value%` at indexes %a% and %b% ``` +### each_key_volatile + +``` +Keyed each block has key that is not idempotent — the key for item at index %index% was `%a%` but is now `%b%`. Keys must be the same each time for a given item +``` + +The key expression in a keyed each block must return the same value when called multiple times for the same item. Using expressions like `[item.a, item.b]` creates a new array each time, which will never be equal to itself. Instead, use a primitive value or create a stable key like `item.a + '-' + item.b`. + ### effect_in_teardown ``` diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 380de4b901..4e4f4f6088 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,17 @@ # svelte +## 5.51.3 + +### Patch Changes + +- fix: prevent event delegation logic conflicting between svelte instances ([#17728](https://github.com/sveltejs/svelte/pull/17728)) + +- fix: treat CSS attribute selectors as case-insensitive for HTML enumerated attributes ([#17712](https://github.com/sveltejs/svelte/pull/17712)) + +- fix: locate Rollup annontaion friendly to JS downgraders ([#17724](https://github.com/sveltejs/svelte/pull/17724)) + +- fix: run effects in pending snippets ([#17719](https://github.com/sveltejs/svelte/pull/17719)) + ## 5.51.2 ### Patch Changes diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index bedf6db0a5..3f20cb989d 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -42,6 +42,12 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long > Keyed each block has duplicate key `%value%` at indexes %a% and %b% +## each_key_volatile + +> Keyed each block has key that is not idempotent — the key for item at index %index% was `%a%` but is now `%b%`. Keys must be the same each time for a given item + +The key expression in a keyed each block must return the same value when called multiple times for the same item. Using expressions like `[item.a, item.b]` creates a new array each time, which will never be equal to itself. Instead, use a primitive value or create a stable key like `item.a + '-' + item.b`. + ## effect_in_teardown > `%rune%` cannot be used inside an effect cleanup function diff --git a/packages/svelte/package.json b/packages/svelte/package.json index ee08526201..e9f4713d1c 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.51.2", + "version": "5.51.3", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js index 7242c69d8c..7e05d2e7d3 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js @@ -22,6 +22,50 @@ const whitelist_attribute_selector = new Map([ ['dialog', ['open']] ]); +/** + * HTML attributes whose enumerated values are case-insensitive per the HTML spec. + * CSS attribute selectors match these values case-insensitively in HTML documents. + * @see {@link https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors HTML spec} + */ +const case_insensitive_attributes = new Set([ + 'accept-charset', + 'autocapitalize', + 'autocomplete', + 'behavior', + 'charset', + 'crossorigin', + 'decoding', + 'dir', + 'direction', + 'draggable', + 'enctype', + 'enterkeyhint', + 'fetchpriority', + 'formenctype', + 'formmethod', + 'formtarget', + 'hidden', + 'http-equiv', + 'inputmode', + 'kind', + 'loading', + 'method', + 'preload', + 'referrerpolicy', + 'rel', + 'rev', + 'role', + 'rules', + 'scope', + 'shape', + 'spellcheck', + 'target', + 'translate', + 'type', + 'valign', + 'wrap' +]); + /** @type {Compiler.AST.CSS.Combinator} */ const descendant_combinator = { type: 'Combinator', @@ -523,7 +567,9 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element, selector.name, selector.value && unquote(selector.value), selector.matcher, - selector.flags?.includes('i') ?? false + (selector.flags?.includes('i') ?? false) || + (!selector.flags?.includes('s') && + case_insensitive_attributes.has(selector.name.toLowerCase())) ) ) { return false; diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index e8c9cf0643..43af3d8dd3 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,6 +1,5 @@ /** @import { Blocker, TemplateNode, Value } from '#client' */ -import { flatten } from '../../reactivity/async.js'; -import { Batch, current_batch } from '../../reactivity/batch.js'; +import { flatten, increment_pending } from '../../reactivity/async.js'; import { get } from '../../runtime.js'; import { hydrate_next, @@ -10,7 +9,6 @@ import { set_hydrating, skip_nodes } from '../hydration.js'; -import { get_boundary } from './boundary.js'; /** * @param {TemplateNode} node @@ -44,12 +42,7 @@ export function async(node, blockers = [], expressions = [], fn) { return; } - var boundary = get_boundary(); - var batch = /** @type {Batch} */ (current_batch); - var blocking = boundary.is_rendered(); - - boundary.update_pending_count(1); - batch.increment(blocking); + const decrement_pending = increment_pending(); if (was_hydrating) { var previous_hydrate_node = hydrate_node; @@ -72,8 +65,7 @@ export function async(node, blockers = [], expressions = [], fn) { set_hydrating(false); } - boundary.update_pending_count(-1); - batch.decrement(blocking); + decrement_pending(); } }); } diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index ef5f0e116d..8f23fb1a2e 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,8 +1,6 @@ /** @import { Effect, Source, TemplateNode, } from '#client' */ import { - BLOCK_EFFECT, BOUNDARY_EFFECT, - COMMENT_NODE, DIRTY, EFFECT_PRESERVED, EFFECT_TRANSPARENT, @@ -53,7 +51,7 @@ import { set_signal_status } from '../../reactivity/status.js'; * }} BoundaryProps */ -var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT; +var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED; /** * @param {TemplateNode} node @@ -98,15 +96,10 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; - /** @type {TemplateNode | null} */ - #pending_anchor = null; - #local_pending_count = 0; #pending_count = 0; #pending_count_update_queued = false; - #is_creating_fallback = false; - /** @type {Set} */ #dirty_effects = new Set(); @@ -142,51 +135,31 @@ export class Boundary { constructor(node, props, children) { this.#anchor = node; this.#props = props; - this.#children = children; - this.parent = /** @type {Effect} */ (active_effect).b; + this.#children = (anchor) => { + var effect = /** @type {Effect} */ (active_effect); - this.is_pending = !!this.#props.pending; + effect.b = this; + effect.f |= BOUNDARY_EFFECT; - this.#effect = block(() => { - /** @type {Effect} */ (active_effect).b = this; + children(anchor); + }; + + this.parent = /** @type {Effect} */ (active_effect).b; + this.#effect = block(() => { if (hydrating) { - const comment = this.#hydrate_open; + const comment = /** @type {Comment} */ (this.#hydrate_open); hydrate_next(); - const server_rendered_pending = - /** @type {Comment} */ (comment).nodeType === COMMENT_NODE && - /** @type {Comment} */ (comment).data === HYDRATION_START_ELSE; - - if (server_rendered_pending) { + if (comment.data === HYDRATION_START_ELSE) { this.#hydrate_pending_content(); } else { this.#hydrate_resolved_content(); - - if (this.#pending_count === 0) { - this.is_pending = false; - } } } else { - var anchor = this.#get_anchor(); - - try { - this.#main_effect = branch(() => children(anchor)); - } catch (error) { - this.error(error); - } - - if (this.#pending_count > 0) { - this.#show_pending_snippet(); - } else { - this.is_pending = false; - } + this.#render(); } - - return () => { - this.#pending_anchor?.remove(); - }; }, flags); if (hydrating) { @@ -206,39 +179,75 @@ export class Boundary { const pending = this.#props.pending; if (!pending) return; + this.is_pending = true; this.#pending_effect = branch(() => pending(this.#anchor)); queue_micro_task(() => { - var anchor = this.#get_anchor(); + var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); + var anchor = create_text(); + + fragment.append(anchor); this.#main_effect = this.#run(() => { Batch.ensure(); return branch(() => this.#children(anchor)); }); - if (this.#pending_count > 0) { - this.#show_pending_snippet(); - } else { + if (this.#pending_count === 0) { + this.#anchor.before(fragment); + this.#offscreen_fragment = null; + pause_effect(/** @type {Effect} */ (this.#pending_effect), () => { this.#pending_effect = null; }); - this.is_pending = false; + this.#resolve(); } }); } - #get_anchor() { - var anchor = this.#anchor; + #render() { + try { + this.is_pending = this.has_pending_snippet(); + this.#pending_count = 0; + this.#local_pending_count = 0; + + this.#main_effect = branch(() => { + this.#children(this.#anchor); + }); - if (this.is_pending) { - this.#pending_anchor = create_text(); - this.#anchor.before(this.#pending_anchor); + if (this.#pending_count > 0) { + var fragment = (this.#offscreen_fragment = document.createDocumentFragment()); + move_effect(this.#main_effect, fragment); - anchor = this.#pending_anchor; + const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); + this.#pending_effect = branch(() => pending(this.#anchor)); + } else { + this.#resolve(); + } + } catch (error) { + this.error(error); + } + } + + #resolve() { + 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); + schedule_effect(e); + } + + for (const e of this.#maybe_dirty_effects) { + set_signal_status(e, MAYBE_DIRTY); + schedule_effect(e); } - return anchor; + this.#dirty_effects.clear(); + this.#maybe_dirty_effects.clear(); } /** @@ -262,7 +271,8 @@ export class Boundary { } /** - * @param {() => Effect | null} fn + * @template T + * @param {() => T} fn */ #run(fn) { var previous_effect = active_effect; @@ -285,20 +295,6 @@ export class Boundary { } } - #show_pending_snippet() { - const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending); - - if (this.#main_effect !== null) { - this.#offscreen_fragment = document.createDocumentFragment(); - this.#offscreen_fragment.append(/** @type {TemplateNode} */ (this.#pending_anchor)); - move_effect(this.#main_effect, this.#offscreen_fragment); - } - - if (this.#pending_effect === null) { - this.#pending_effect = branch(() => pending(this.#anchor)); - } - } - /** * Updates the pending count associated with the currently visible pending snippet, * if any, such that we can replace the snippet with content once work is done @@ -317,24 +313,7 @@ export class Boundary { this.#pending_count += d; if (this.#pending_count === 0) { - this.is_pending = false; - - // any effects that were encountered and deferred during traversal - // 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); - schedule_effect(e); - } - - for (const e of this.#maybe_dirty_effects) { - set_signal_status(e, MAYBE_DIRTY); - schedule_effect(e); - } - - this.#dirty_effects.clear(); - this.#maybe_dirty_effects.clear(); + this.#resolve(); if (this.#pending_effect) { pause_effect(this.#pending_effect, () => { @@ -383,7 +362,7 @@ export class Boundary { // If we have nothing to capture the error, or if we hit an error while // rendering the fallback, re-throw for another boundary to handle - if (this.#is_creating_fallback || (!onerror && !failed)) { + if (!onerror && !failed) { throw error; } @@ -423,31 +402,18 @@ export class Boundary { e.svelte_boundary_reset_onerror(); } - // If the failure happened while flushing effects, current_batch can be null - Batch.ensure(); - - this.#local_pending_count = 0; - if (this.#failed_effect !== null) { pause_effect(this.#failed_effect, () => { this.#failed_effect = null; }); } - // we intentionally do not try to find the nearest pending boundary. If this boundary has one, we'll render it on reset - // but it would be really weird to show the parent's boundary on a child reset. - this.is_pending = this.has_pending_snippet(); + this.#run(() => { + // If the failure happened while flushing effects, current_batch can be null + Batch.ensure(); - this.#main_effect = this.#run(() => { - this.#is_creating_fallback = false; - return branch(() => this.#children(this.#anchor)); + this.#render(); }); - - if (this.#pending_count > 0) { - this.#show_pending_snippet(); - } else { - this.is_pending = false; - } }; queue_micro_task(() => { @@ -462,10 +428,16 @@ export class Boundary { if (failed) { this.#failed_effect = this.#run(() => { Batch.ensure(); - this.#is_creating_fallback = true; try { return branch(() => { + // errors in `failed` snippets cause the boundary to error again + // TODO Svelte 6: revisit this decision, most likely better to go to parent boundary instead + var effect = /** @type {Effect} */ (active_effect); + + effect.b = this; + effect.f |= BOUNDARY_EFFECT; + failed( this.#anchor, () => error, @@ -475,8 +447,6 @@ export class Boundary { } catch (error) { invoke_error_boundary(error, /** @type {Effect} */ (this.#effect.parent)); return null; - } finally { - this.#is_creating_fallback = false; } }); } @@ -484,10 +454,6 @@ export class Boundary { } } -export function get_boundary() { - return /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b); -} - export function pending() { if (active_effect === null) { e.effect_pending_outside_reaction(); diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 25f7cf91eb..7ae02d073c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -250,6 +250,14 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var value = array[index]; var key = get_key(value, index); + if (DEV) { + // Check that the key function is idempotent (returns the same value when called twice) + var key_again = get_key(value, index); + if (key !== key_again) { + e.each_key_volatile(String(index), String(key), String(key_again)); + } + } + var item = first_run ? null : items.get(key); if (item) { diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index 041698eb9d..e598a78949 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -11,8 +11,11 @@ import { set_active_reaction } from '../../runtime.js'; import { without_reactive_context } from './bindings/shared.js'; -import { can_delegate_event } from '../../../../utils.js'; +/** + * Used on elements, as a map of event type -> event handler, + * and on events themselves to track which element handled an event + */ export const event_symbol = Symbol('events'); /** @type {Set} */ @@ -177,8 +180,8 @@ export function handle_event_propagation(event) { last_propagated_event = event; // composedPath contains list of nodes the event has propagated through. - // We check __root to skip all nodes below it in case this is a - // parent of the __root node, which indicates that there's nested + // We check `event_symbol` to skip all nodes below it in case this is a + // parent of the `event_symbol` node, which indicates that there's nested // mounted apps. In this case we don't want to trigger events multiple times. var path_idx = 0; @@ -186,7 +189,7 @@ export function handle_event_propagation(event) { // without it the variable will be DCE'd and things will // fail mysteriously in Firefox // @ts-expect-error is added below - var handled_at = last_propagated_event === event && event.__root; + var handled_at = last_propagated_event === event && event[event_symbol]; if (handled_at) { var at_idx = path.indexOf(handled_at); @@ -198,7 +201,7 @@ export function handle_event_propagation(event) { // -> ignore, but set handle_at to document/window so that we're resetting the event // chain in case someone manually dispatches the same event object again. // @ts-expect-error - event.__root = handler_element; + event[event_symbol] = handler_element; return; } @@ -298,7 +301,7 @@ export function handle_event_propagation(event) { } } finally { // @ts-expect-error is used above - event.__root = handler_element; + event[event_symbol] = handler_element; // @ts-ignore remove proxy on currentTarget delete event.currentTarget; set_active_reaction(previous_reaction); diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 34f1d85540..d60c2dd280 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -147,6 +147,25 @@ export function each_key_duplicate(a, b, value) { } } +/** + * Keyed each block has key that is not idempotent — the key for item at index %index% was `%a%` but is now `%b%`. Keys must be the same each time for a given item + * @param {string} index + * @param {string} a + * @param {string} b + * @returns {never} + */ +export function each_key_volatile(index, a, b) { + if (DEV) { + const error = new Error(`each_key_volatile\nKeyed each block has key that is not idempotent — the key for item at index ${index} was \`${a}\` but is now \`${b}\`. Keys must be the same each time for a given item\nhttps://svelte.dev/e/each_key_volatile`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/each_key_volatile`); + } +} + /** * `%rune%` cannot be used inside an effect cleanup function * @param {string} rune diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index b6eba3bf8a..b3c5248179 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -8,7 +8,7 @@ import { set_component_context, set_dev_stack } from '../context.js'; -import { get_boundary } from '../dom/blocks/boundary.js'; +import { Boundary } from '../dom/blocks/boundary.js'; import { invoke_error_boundary } from '../error-handling.js'; import { active_effect, @@ -224,12 +224,7 @@ export function unset_context() { export function run(thunks) { const restore = capture(); - var boundary = get_boundary(); - var batch = /** @type {Batch} */ (current_batch); - var blocking = boundary.is_rendered(); - - boundary.update_pending_count(1); - batch.increment(blocking); + const decrement_pending = increment_pending(); var active = /** @type {Effect} */ (active_effect); @@ -286,10 +281,7 @@ export function run(thunks) { // wait one more tick, so that template effects are // guaranteed to run before `$effect(...)` .then(() => Promise.resolve()) - .finally(() => { - boundary.update_pending_count(-1); - batch.decrement(blocking); - }); + .finally(decrement_pending); return blockers; } @@ -300,3 +292,17 @@ export function run(thunks) { export function wait(blockers) { return Promise.all(blockers.map((b) => b.promise)); } + +export function increment_pending() { + var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b); + var batch = /** @type {Batch} */ (current_batch); + var blocking = boundary.is_rendered(); + + boundary.update_pending_count(1); + batch.increment(blocking); + + return () => { + boundary.update_pending_count(-1); + batch.decrement(blocking); + }; +} diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 82a2d4f484..297049fd6b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -18,7 +18,8 @@ import { EAGER_EFFECT, HEAD_EFFECT, ERROR_VALUE, - MANAGED_EFFECT + MANAGED_EFFECT, + REACTION_RAN } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property, includes } from '../../shared/utils.js'; @@ -142,7 +143,7 @@ export class Batch { #decrement_queued = false; - is_deferred() { + #is_deferred() { return this.is_fork || this.#blocking_pending > 0; } @@ -202,7 +203,7 @@ export class Batch { // log_inconsistent_branches(root); } - if (this.is_deferred()) { + if (this.#is_deferred()) { this.#defer_effects(render_effects); this.#defer_effects(effects); @@ -246,9 +247,6 @@ export class Batch { var effect = root.first; - /** @type {Effect | null} */ - var pending_boundary = null; - while (effect !== null) { var flags = effect.f; var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; @@ -256,26 +254,9 @@ export class Batch { var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect); - // Inside a `` with a pending snippet, - // all effects are deferred until the boundary resolves - // (except block/async effects, which run immediately) - if ( - async_mode_flag && - pending_boundary === null && - (flags & BOUNDARY_EFFECT) !== 0 && - effect.b?.is_pending - ) { - pending_boundary = effect; - } - if (!skip && effect.fn !== null) { if (is_branch) { effect.f ^= CLEAN; - } else if ( - pending_boundary !== null && - (flags & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 - ) { - /** @type {Boundary} */ (pending_boundary.b).defer_effect(effect); } else if ((flags & EFFECT) !== 0) { effects.push(effect); } else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) { @@ -293,16 +274,15 @@ export class Batch { } } - var parent = effect.parent; - effect = effect.next; + while (effect !== null) { + var next = effect.next; - while (effect === null && parent !== null) { - if (parent === pending_boundary) { - pending_boundary = null; + if (next !== null) { + effect = next; + break; } - effect = parent.next; - parent = parent.parent; + effect = effect.parent; } } } @@ -472,7 +452,7 @@ export class Batch { queue_micro_task(() => { this.#decrement_queued = false; - if (!this.is_deferred()) { + if (!this.#is_deferred()) { // we only reschedule previously-deferred effects if we expect // to be able to run them after processing the batch this.revive(); @@ -836,6 +816,19 @@ function depends_on(reaction, sources, checked) { export function schedule_effect(signal) { var effect = (last_scheduled_effect = signal); + var boundary = effect.b; + + // defer render effects inside a pending boundary + // TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later + if ( + boundary?.is_pending && + (signal.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 && + (signal.f & REACTION_RAN) === 0 + ) { + boundary.defer_effect(signal); + return; + } + while (effect.parent !== null) { effect = effect.parent; var flags = effect.f; @@ -847,13 +840,18 @@ export function schedule_effect(signal) { is_flushing && effect === active_effect && (flags & BLOCK_EFFECT) !== 0 && - (flags & HEAD_EFFECT) === 0 + (flags & HEAD_EFFECT) === 0 && + (flags & REACTION_RAN) !== 0 ) { return; } if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) { - if ((flags & CLEAN) === 0) return; + if ((flags & CLEAN) === 0) { + // branch is already dirty, bail + return; + } + effect.f ^= CLEAN; } } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index d11854fc91..80da5528c8 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -40,7 +40,7 @@ import { Boundary } from '../dom/blocks/boundary.js'; import { component_context } from '../context.js'; import { UNINITIALIZED } from '../../../constants.js'; import { batch_values, current_batch } from './batch.js'; -import { unset_context } from './async.js'; +import { increment_pending, unset_context } from './async.js'; import { deferred, includes, noop } from '../../shared/utils.js'; import { set_signal_status, update_derived_status } from './status.js'; @@ -111,8 +111,6 @@ export function async_derived(fn, label, location) { e.async_derived_orphan(); } - var boundary = /** @type {Boundary} */ (parent.b); - var promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined)); var signal = source(/** @type {V} */ (UNINITIALIZED)); @@ -156,10 +154,7 @@ export function async_derived(fn, label, location) { var batch = /** @type {Batch} */ (current_batch); if (should_suspend) { - var blocking = boundary.is_rendered(); - - boundary.update_pending_count(1); - batch.increment(blocking); + var decrement_pending = increment_pending(); deferreds.get(batch)?.reject(STALE_REACTION); deferreds.delete(batch); // delete to ensure correct order in Map iteration below @@ -208,9 +203,8 @@ export function async_derived(fn, label, location) { } } - if (should_suspend) { - boundary.update_pending_count(-1); - batch.decrement(blocking); + if (decrement_pending) { + decrement_pending(); } }; diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 512c435a27..157587e218 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -40,8 +40,8 @@ import { DEV } from 'esm-env'; import { define_property } from '../../shared/utils.js'; import { get_next_sibling } from '../dom/operations.js'; import { component_context, dev_current_component_function, dev_stack } from '../context.js'; -import { Batch, current_batch, schedule_effect } from './batch.js'; -import { flatten } from './async.js'; +import { Batch, schedule_effect } from './batch.js'; +import { flatten, increment_pending } from './async.js'; import { without_reactive_context } from '../dom/elements/bindings/shared.js'; import { set_signal_status } from './status.js'; @@ -376,14 +376,16 @@ export function template_effect(fn, sync = [], async = [], blockers = []) { * @param {Blocker[]} blockers */ export function deferred_template_effect(fn, sync = [], async = [], blockers = []) { - var batch = /** @type {Batch} */ (current_batch); - var is_async = async.length > 0 || blockers.length > 0; - - if (is_async) batch.increment(true); + if (async.length > 0 || blockers.length > 0) { + var decrement_pending = increment_pending(); + } flatten(blockers, sync, async, (values) => { create_effect(EFFECT, () => fn(...values.map(get)), false); - if (is_async) batch.decrement(true); + + if (decrement_pending) { + decrement_pending(); + } }); } diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index 0d5bc6cb49..76a73852d5 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -161,48 +161,6 @@ const listeners = new Map(); function _mount(Component, { target, anchor, props = {}, events, context, intro = true }) { init_operations(); - /** @type {Set} */ - var registered_events = new Set(); - - /** @param {Array} events */ - var event_handle = (events) => { - for (var i = 0; i < events.length; i++) { - var event_name = events[i]; - - if (registered_events.has(event_name)) continue; - registered_events.add(event_name); - - var passive = is_passive_event(event_name); - - // Add the event listener to both the container and the document. - // The container listener ensures we catch events from within in case - // the outer content stops propagation of the event. - // - // The document listener ensures we catch events that originate from elements that were - // manually moved outside of the container (e.g. via manual portals). - for (const node of [target, document]) { - var counts = listeners.get(node); - - if (counts === undefined) { - counts = new Map(); - listeners.set(node, counts); - } - - var count = counts.get(event_name); - - if (count === undefined) { - node.addEventListener(event_name, handle_event_propagation, { passive }); - counts.set(event_name, 1); - } else { - counts.set(event_name, count + 1); - } - } - } - }; - - event_handle(array_from(all_registered_events)); - root_event_handles.add(event_handle); - /** @type {Exports} */ // @ts-expect-error will be defined because the render effect runs synchronously var component = undefined; @@ -251,6 +209,49 @@ function _mount(Component, { target, anchor, props = {}, events, context, intro } ); + // Setup event delegation _after_ component is mounted - if an error would happen during mount, it would otherwise not be cleaned up + /** @type {Set} */ + var registered_events = new Set(); + + /** @param {Array} events */ + var event_handle = (events) => { + for (var i = 0; i < events.length; i++) { + var event_name = events[i]; + + if (registered_events.has(event_name)) continue; + registered_events.add(event_name); + + var passive = is_passive_event(event_name); + + // Add the event listener to both the container and the document. + // The container listener ensures we catch events from within in case + // the outer content stops propagation of the event. + // + // The document listener ensures we catch events that originate from elements that were + // manually moved outside of the container (e.g. via manual portals). + for (const node of [target, document]) { + var counts = listeners.get(node); + + if (counts === undefined) { + counts = new Map(); + listeners.set(node, counts); + } + + var count = counts.get(event_name); + + if (count === undefined) { + node.addEventListener(event_name, handle_event_propagation, { passive }); + counts.set(event_name, 1); + } else { + counts.set(event_name, count + 1); + } + } + } + }; + + event_handle(array_from(all_registered_events)); + root_event_handles.add(event_handle); + return () => { for (var event_name of registered_events) { for (const node of [target, document]) { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index d6d42c7e97..c27b247dba 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.51.2'; +export const VERSION = '5.51.3'; export const PUBLIC_VERSION = '5'; diff --git a/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.css b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.css new file mode 100644 index 0000000000..01ec1b2269 --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.css @@ -0,0 +1,11 @@ + form[method="get"].svelte-xyz h1:where(.svelte-xyz) { + color: red; + } + + form[method="post"].svelte-xyz h1:where(.svelte-xyz) { + color: blue; + } + + input[type="text"].svelte-xyz { + color: green; + } diff --git a/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.html b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.html new file mode 100644 index 0000000000..101c03f836 --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/expected.html @@ -0,0 +1 @@ +

Hello

World

diff --git a/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/input.svelte b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/input.svelte new file mode 100644 index 0000000000..e11a922d75 --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-html-case-insensitive/input.svelte @@ -0,0 +1,23 @@ +
+

Hello

+
+ +
+

World

+
+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/_config.js b/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/_config.js new file mode 100644 index 0000000000..1d860cded5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/_config.js @@ -0,0 +1,38 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` + + +

0

+ `, + + async test({ assert, target }) { + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

1

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

resolved

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/main.svelte new file mode 100644 index 0000000000..764fd20a51 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-pending-live/main.svelte @@ -0,0 +1,31 @@ + + + + + + + +

{await push('resolved')}

+ + {#snippet pending()} +

{count}

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/each-key-volatile/_config.js b/packages/svelte/tests/runtime-runes/samples/each-key-volatile/_config.js new file mode 100644 index 0000000000..87d4bf45c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-key-volatile/_config.js @@ -0,0 +1,11 @@ +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + + mode: ['client'], + + error: 'each_key_volatile' +}); diff --git a/packages/svelte/tests/runtime-runes/samples/each-key-volatile/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-key-volatile/main.svelte new file mode 100644 index 0000000000..689c257cfa --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-key-volatile/main.svelte @@ -0,0 +1,10 @@ + + +{#each things as thing ([thing.group, thing.id])} +

{thing.group}-{thing.id}

+{/each}