diff --git a/.changeset/modern-towns-call.md b/.changeset/modern-towns-call.md deleted file mode 100644 index 77ca8e9185..0000000000 --- a/.changeset/modern-towns-call.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: discard batches made obsolete by commit diff --git a/.changeset/spicy-teeth-tan.md b/.changeset/spicy-teeth-tan.md new file mode 100644 index 0000000000..c7efa65130 --- /dev/null +++ b/.changeset/spicy-teeth-tan.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: defer batch resolution until earlier intersecting batches have committed diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 3ca0db8dec..7c1311ae84 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,31 @@ # svelte +## 5.54.0 + +### Minor Changes + +- feat: allow `css`, `runes`, `customElement` compiler options to be functions ([#17951](https://github.com/sveltejs/svelte/pull/17951)) + +### Patch Changes + +- fix: reinstate reactivity loss tracking ([#17801](https://github.com/sveltejs/svelte/pull/17801)) + +## 5.53.13 + +### Patch Changes + +- fix: ensure `$inspect` after top level await doesn't break builds ([#17943](https://github.com/sveltejs/svelte/pull/17943)) + +- fix: resume inert effects when they come from offscreen ([#17942](https://github.com/sveltejs/svelte/pull/17942)) + +- fix: don't eagerly access not-yet-initialized functions in template ([#17938](https://github.com/sveltejs/svelte/pull/17938)) + +- fix: discard batches made obsolete by commit ([#17934](https://github.com/sveltejs/svelte/pull/17934)) + +- fix: ensure "is standalone child" is correctly reset ([#17944](https://github.com/sveltejs/svelte/pull/17944)) + +- fix: remove nodes in boundary when work is pending and HMR is active ([#17932](https://github.com/sveltejs/svelte/pull/17932)) + ## 5.53.12 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index e06a9bf4a8..8c926d04c2 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.12", + "version": "5.54.0", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index e864c4a1f4..1d822514b9 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -23,6 +23,7 @@ export { print } from './print/index.js'; export function compile(source, options) { source = remove_bom(source); state.reset({ warning: options.warningFilter, filename: options.filename }); + const validated = validate_component_options(options, ''); let parsed = _parse(source); @@ -33,7 +34,9 @@ export function compile(source, options) { const combined_options = { ...validated, ...parsed_options, - customElementOptions + customElementOptions, + css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : validated.css, + runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes }; if (parsed.metadata.ts) { diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js index ce5387f4dd..0370155c12 100644 --- a/packages/svelte/src/compiler/migrate/index.js +++ b/packages/svelte/src/compiler/migrate/index.js @@ -146,6 +146,8 @@ export function migrate(source, { filename, use_ts } = {}) { ...parsed_options, customElementOptions, filename: filename ?? UNKNOWN_FILENAME, + css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : () => 'external', + runes: 'runes' in parsed_options ? () => parsed_options.runes : () => undefined, experimental: { async: true } diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index fbd2e1cb8a..cadd159b3e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -345,6 +345,8 @@ export function analyze_component(root, source, options) { let synthetic_stores_legacy_check = []; + const runes_option = options.runes?.({ filename: options.filename }); + // create synthetic bindings for store subscriptions for (const [name, references] of module.scope.references) { if (name[0] !== '$' || RESERVED.includes(name)) continue; @@ -359,7 +361,7 @@ export function analyze_component(root, source, options) { // If we're not in legacy mode through the compiler option, assume the user // is referencing a rune and not a global store. if ( - options.runes === false || + runes_option === false || !is_rune(name) || (declaration !== null && // const state = $state(0) is valid @@ -395,7 +397,7 @@ export function analyze_component(root, source, options) { e.store_invalid_scoped_subscription(is_nested_store_subscription_node); } - if (options.runes !== false) { + if (runes_option !== false) { if (declaration === null && /[a-z]/.test(store_name[0])) { e.global_reference_invalid(references[0].node, name); } else if (declaration !== null && is_rune(name)) { @@ -447,7 +449,7 @@ export function analyze_component(root, source, options) { const component_name = get_component_name(options.filename); const runes = - options.runes ?? + runes_option ?? (has_await || instance.has_await || Array.from(module.scope.references.keys()).some(is_rune)); if (!runes) { @@ -463,7 +465,10 @@ export function analyze_component(root, source, options) { } } - const is_custom_element = !!options.customElementOptions || options.customElement; + const custom_element_from_option = options.customElement({ filename: options.filename }); + const css = options.css({ filename: options.filename }); + const custom_element = options.customElementOptions ?? custom_element_from_option; + const is_custom_element = !!options.customElementOptions || custom_element_from_option; const name = module.scope.generate(options.name ?? component_name); @@ -491,7 +496,7 @@ export function analyze_component(root, source, options) { maybe_runes: !runes && // if they explicitly disabled runes, use the legacy behavior - options.runes !== false && + runes_option !== false && ![...module.scope.references.keys()].some((name) => ['$$props', '$$restProps'].includes(name) ) && @@ -523,8 +528,8 @@ export function analyze_component(root, source, options) { needs_props: false, event_directive_node: null, uses_event_attributes: false, - custom_element: is_custom_element, - inject_styles: options.css === 'injected' || is_custom_element, + custom_element, + inject_styles: css === 'injected' || is_custom_element, accessors: is_custom_element || (runes ? false : !!options.accessors) || @@ -680,7 +685,7 @@ export function analyze_component(root, source, options) { w.options_deprecated_accessors(attribute); } - if (attribute.name === 'customElement' && !options.customElement) { + if (attribute.name === 'customElement' && !custom_element_from_option) { w.options_missing_custom_element(attribute); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 75e12876a2..162b979e4c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -619,7 +619,7 @@ export function client_component(analysis, options) { ); } - const ce = options.customElementOptions ?? options.customElement; + const ce = analysis.custom_element; if (ce) { const ce_props = typeof ce === 'boolean' ? {} : ce.props || {}; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index be919d380c..ad0c487fb4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -63,6 +63,7 @@ export function Fragment(node, context) { /** @type {ComponentClientTransformState} */ const state = { ...context.state, + is_standalone, init: [], snippets: [], consts: [], @@ -128,7 +129,7 @@ export function Fragment(node, context) { // no need to create a template, we can just use the existing block's anchor process_children(trimmed, () => b.id('$$anchor'), false, { ...context, - state: { ...state, is_standalone } + state }); } else { /** @type {(is_text: boolean) => Expression} */ diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index 7256073096..1f3c7b2256 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -82,12 +82,16 @@ export class Memoizer { async_values() { if (this.#async.length === 0) return; - return b.array(this.#async.map((memo) => b.thunk(memo.expression, true))); + // use `b.arrow` rather than `b.thunk` so that deferred async/template effects + // always read live bindings rather than a possibly stale snapshot. + return b.array(this.#async.map((memo) => b.arrow([], memo.expression, true))); } sync_values() { if (this.#sync.length === 0) return; - return b.array(this.#sync.map((memo) => b.thunk(memo.expression))); + // use `b.arrow` rather than `b.thunk` so that deferred async/template effects + // always read live bindings rather than a possibly stale snapshot. + return b.array(this.#sync.map((memo) => b.arrow([], memo.expression))); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/css/index.js b/packages/svelte/src/compiler/phases/3-transform/css/index.js index cee7ab2791..92b2706f22 100644 --- a/packages/svelte/src/compiler/phases/3-transform/css/index.js +++ b/packages/svelte/src/compiler/phases/3-transform/css/index.js @@ -65,7 +65,7 @@ export function render_stylesheet(source, analysis, options) { merge_with_preprocessor_map(css, options, css.map.sources[0]); - if (dev && options.css === 'injected' && css.code) { + if (dev && analysis.inject_styles && css.code) { css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`; } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index b9f1441bff..90a693b99a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -300,7 +300,7 @@ export function server_component(analysis, options) { const body = [...state.hoisted, ...module.body]; - if (analysis.css.ast !== null && options.css === 'injected' && !options.customElement) { + if (analysis.css.ast !== null && analysis.inject_styles && !analysis.custom_element) { const hash = b.literal(analysis.css.hash); const code = b.literal(render_stylesheet(analysis.source, analysis, options).code); diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js b/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js index 95b9f585d3..5c8f901d7c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js +++ b/packages/svelte/src/compiler/phases/3-transform/shared/transform-async.js @@ -95,7 +95,8 @@ export function transform_body(instance_body, runner, transform) { ); if (expression.type === 'EmptyStatement') { - return null; + // Keep indices stable for async sequencing while avoiding array holes in run([...]). + return b.thunk(b.void0, false); } return expression.type === 'AwaitExpression' diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index 325d87ef6a..e033251257 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -73,9 +73,11 @@ export interface CompileOptions extends ModuleCompileOptions { /** * If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component. * + * You can also pass a function that receives `{ filename }` and returns a boolean. + * * @default false */ - customElement?: boolean; + customElement?: boolean | ((options: { filename: string }) => boolean); /** * If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`. * @@ -101,8 +103,10 @@ export interface CompileOptions extends ModuleCompileOptions { * - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root. * - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files. * This is always `'injected'` when compiling with `customElement` mode. + * + * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`. */ - css?: 'injected' | 'external'; + css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'); /** * A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS. * It defaults to returning `svelte-${hash(filename ?? css)}`. @@ -146,7 +150,7 @@ export interface CompileOptions extends ModuleCompileOptions { * which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead. * @default undefined */ - runes?: boolean | undefined; + runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined); /** * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`. * @@ -252,18 +256,22 @@ export type ValidatedCompileOptions = ValidatedModuleCompileOptions & Required, | keyof ModuleCompileOptions | 'name' + | 'customElement' | 'compatibility' | 'outputFilename' | 'cssOutputFilename' | 'sourcemap' + | 'css' | 'runes' > & { name: CompileOptions['name']; + customElement: (options: { filename: string }) => boolean; outputFilename: CompileOptions['outputFilename']; cssOutputFilename: CompileOptions['cssOutputFilename']; sourcemap: CompileOptions['sourcemap']; compatibility: Required['compatibility']>; - runes: CompileOptions['runes']; + css: (options: { filename: string }) => 'injected' | 'external'; + runes: (options: { filename: string }) => boolean | undefined; customElementOptions: AST.SvelteOptions['customElement']; hmr: CompileOptions['hmr']; }; diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index d4679ff8d7..6ac1356d8f 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -36,6 +36,13 @@ export function assignment_pattern(left, right) { * @returns {ESTree.ArrowFunctionExpression} */ export function arrow(params, body, async = false) { + // optimize `async () => await x()`, but not `async () => await x(await y)` + if (async && body.type === 'AwaitExpression') { + if (!has_await_expression(body.argument)) { + return arrow(params, body.argument); + } + } + return { type: 'ArrowFunctionExpression', params, @@ -462,13 +469,6 @@ export function thunk(expression, async = false) { * @returns {ESTree.Expression} */ export function unthunk(expression) { - // optimize `async () => await x()`, but not `async () => await x(await y)` - if (expression.async && expression.body.type === 'AwaitExpression') { - if (!has_await_expression(expression.body.argument)) { - return unthunk(arrow(expression.params, expression.body.argument)); - } - } - if ( expression.async === false && expression.body.type === 'CallExpression' && diff --git a/packages/svelte/src/compiler/validate-options.js b/packages/svelte/src/compiler/validate-options.js index e0215b4eff..b7b301dbfc 100644 --- a/packages/svelte/src/compiler/validate-options.js +++ b/packages/svelte/src/compiler/validate-options.js @@ -51,24 +51,28 @@ const common_options = { const component_options = { accessors: deprecate(w.options_deprecated_accessors, boolean(false)), - css: validator('external', (input) => { - if (input === true || input === false) { - throw_error( - 'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true' - ); - } - if (input === 'none') { - throw_error( - 'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.' - ); - } + /** @type {Validator<'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'), (options: { filename: string }) => 'injected' | 'external'>} */ + css: parametric( + /** @type {(options: { filename: string }) => 'injected' | 'external'} */ (() => 'external'), + (input) => { + if (input === true || input === false) { + throw_error( + 'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true' + ); + } + if (input === 'none') { + throw_error( + 'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.' + ); + } - if (input !== 'external' && input !== 'injected') { - throw_error(`css should be either "external" (default, recommended) or "injected"`); - } + if (input !== 'external' && input !== 'injected') { + throw_error(`css should be either "external" (default, recommended) or "injected"`); + } - return input; - }), + return /** @type {'external' | 'injected'} */ (input); + } + ), cssHash: fun(({ css, filename, hash }) => { return `svelte-${hash(filename === '(unknown)' ? css : filename ?? css)}`; @@ -77,7 +81,17 @@ const component_options = { // TODO this is a sourcemap option, would be good to put under a sourcemap namespace cssOutputFilename: string(undefined), - customElement: boolean(false), + /** @type {Validator boolean), (options: { filename: string }) => boolean>} */ + customElement: parametric( + /** @type {(options: { filename: string }) => boolean} */ (() => false), + (input, keypath) => { + if (typeof input !== 'boolean') { + throw_error(`${keypath} should be true or false`); + } + + return /** @type {boolean} */ (input); + } + ), discloseVersion: boolean(true), @@ -109,7 +123,8 @@ const component_options = { preserveWhitespace: boolean(false), - runes: boolean(undefined), + /** @type {Validator boolean | undefined), () => boolean | undefined>} */ + runes: parametric(() => /** @type {boolean | undefined} */ (undefined)), hmr: boolean(false), @@ -320,6 +335,28 @@ function fun(fallback) { }); } +/** + * @template {(...args: any[]) => any} F + * @param {F} fallback + * @param {(value: unknown, keypath: string) => ReturnType} [normalize] + * @returns {Validator} + */ +function parametric(fallback, normalize = (value) => /** @type {ReturnType} */ (value)) { + return validator(fallback, (input, keypath) => { + if (typeof input === 'function') { + /** @type {(...args: Parameters) => ReturnType} */ + const normalized = (...args) => normalize(input(...args), keypath); + + return /** @type {F} */ (/** @type {unknown} */ (normalized)); + } + + /** @type {(...args: Parameters) => ReturnType} */ + const normalized = (..._args) => normalize(input, keypath); + + return /** @type {F} */ (/** @type {unknown} */ (normalized)); + }); +} + /** @param {string} msg */ function throw_error(msg) { e.options_invalid_value(null, msg); diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js index 9fa4e6ccbd..071901aafc 100644 --- a/packages/svelte/src/internal/client/dev/hmr.js +++ b/packages/svelte/src/internal/client/dev/hmr.js @@ -6,6 +6,8 @@ import { block, branch, destroy_effect } from '../reactivity/effects.js'; import { set, source } from '../reactivity/sources.js'; import { set_should_intro } from '../render.js'; import { get } from '../runtime.js'; +import { assign_nodes } from '../dom/template.js'; +import { create_comment } from '../dom/operations.js'; /** * @template {(anchor: Comment, props: any) => any} Component @@ -27,6 +29,13 @@ export function hmr(fn) { let ran = false; + // Surround the wrapped effects with comments and assign the nodes + // on the wrapping effects so the parent can properly do DOM operations. + let start = create_comment(); + let end = create_comment(); + + anchor.before(start); + block(() => { if (component === (component = get(current))) { return; @@ -61,6 +70,10 @@ export function hmr(fn) { anchor = hydrate_node; } + anchor.before(end); + + assign_nodes(start, end); + return instance; } diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index b248ce5544..3acdf80d84 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -480,6 +480,14 @@ function reconcile(state, array, anchor, flags, get_key) { } } + if ((effect.f & INERT) !== 0) { + resume_effect(effect); + if (is_animated) { + effect.nodes?.a?.unfix(); + (to_animate ??= new Set()).delete(effect); + } + } + if ((effect.f & EFFECT_OFFSCREEN) !== 0) { effect.f ^= EFFECT_OFFSCREEN; @@ -508,14 +516,6 @@ function reconcile(state, array, anchor, flags, get_key) { } } - if ((effect.f & INERT) !== 0) { - resume_effect(effect); - if (is_animated) { - effect.nodes?.a?.unfix(); - (to_animate ??= new Set()).delete(effect); - } - } - if (effect !== current) { if (seen !== undefined && seen.has(effect)) { if (matched.length < stashed.length) { diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index f8f5fec290..d713385c75 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -19,10 +19,10 @@ import { import { Batch, current_batch } from './batch.js'; import { async_derived, - current_async_effect, + reactivity_loss_tracker, derived, derived_safe_equal, - set_from_async_derived + set_reactivity_loss_tracker } from './deriveds.js'; import { aborted } from './effects.js'; @@ -131,7 +131,7 @@ export function capture() { } if (DEV) { - set_from_async_derived(null); + set_reactivity_loss_tracker(null); set_dev_stack(previous_dev_stack); } }; @@ -163,11 +163,11 @@ export async function save(promise) { * @returns {Promise<() => T>} */ export async function track_reactivity_loss(promise) { - var previous_async_effect = current_async_effect; + var previous_async_effect = reactivity_loss_tracker; var value = await promise; return () => { - set_from_async_derived(previous_async_effect); + set_reactivity_loss_tracker(previous_async_effect); return value; }; } @@ -224,7 +224,7 @@ export function unset_context(deactivate_batch = true) { if (deactivate_batch) current_batch?.deactivate(); if (DEV) { - set_from_async_derived(null); + set_reactivity_loss_tracker(null); set_dev_stack(null); } } @@ -307,15 +307,16 @@ export function wait(blockers) { * @returns {(skip?: boolean) => void} */ export function increment_pending() { - var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b); + var effect = /** @type {Effect} */ (active_effect); + var boundary = /** @type {Boundary} */ (effect.b); var batch = /** @type {Batch} */ (current_batch); var blocking = boundary.is_rendered(); boundary.update_pending_count(1, batch); - batch.increment(blocking); + batch.increment(blocking, effect); return (skip = false) => { boundary.update_pending_count(-1, batch); - batch.decrement(blocking, skip); + batch.decrement(blocking, effect, skip); }; } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b100d559d2..3b10d6ebe6 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -90,20 +90,20 @@ var source_stacks = DEV ? new Set() : null; let uid = 1; export class Batch { - // for debugging. TODO remove once async is stable id = uid++; /** - * The current values of any sources that are updated in this batch + * The current values of any signals that are updated in this batch. + * Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment) * They keys of this map are identical to `this.#previous` - * @type {Map} + * @type {Map} */ current = new Map(); /** - * The values of any sources that are updated in this batch _before_ those updates took place. + * The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place. * They keys of this map are identical to `this.#current` - * @type {Map} + * @type {Map} */ previous = new Map(); @@ -121,14 +121,16 @@ export class Batch { #discard_callbacks = new Set(); /** - * The number of async effects that are currently in flight + * Async effects that are currently in flight + * @type {Map} */ - #pending = 0; + #pending = new Map(); /** - * The number of async effects that are currently in flight, _not_ inside a pending boundary + * Async effects that are currently in flight, _not_ inside a pending boundary + * @type {Map} */ - #blocking_pending = 0; + #blocking_pending = new Map(); /** * A deferred that resolves when the batch is committed, used with `settled()` @@ -168,8 +170,35 @@ export class Batch { #decrement_queued = false; + /** @type {Set} */ + #blockers = new Set(); + #is_deferred() { - return this.is_fork || this.#blocking_pending > 0; + return this.is_fork || this.#blocking_pending.size > 0; + } + + #is_blocked() { + for (const batch of this.#blockers) { + for (const effect of batch.#blocking_pending.keys()) { + var skipped = false; + var e = effect; + + while (e.parent !== null) { + if (this.#skipped_branches.has(e)) { + skipped = true; + break; + } + + e = e.parent; + } + + if (!skipped) { + return true; + } + } + } + + return false; } /** @@ -264,7 +293,7 @@ export class Batch { collected_effects = null; legacy_updates = null; - if (this.#is_deferred()) { + if (this.#is_deferred() || this.#is_blocked()) { this.#defer_effects(render_effects); this.#defer_effects(effects); @@ -272,7 +301,7 @@ export class Batch { reset_branch(e, t); } } else { - if (this.#pending === 0) { + if (this.#pending.size === 0) { batches.delete(this); } @@ -383,17 +412,18 @@ export class Batch { /** * Associate a change to a given source with the current * batch, noting its previous and current values - * @param {Source} source + * @param {Value} source * @param {any} old_value + * @param {boolean} [is_derived] */ - capture(source, old_value) { + capture(source, old_value, is_derived = false) { 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` if ((source.f & ERROR_VALUE) === 0) { - this.current.set(source, source.v); + this.current.set(source, [source.v, is_derived]); batch_values?.set(source, source.v); } } @@ -453,11 +483,13 @@ export class Batch { /** @type {Source[]} */ var sources = []; - for (const [source, value] of this.current) { + for (const [source, [value, is_derived]] of this.current) { if (batch.current.has(source)) { - if (is_earlier && value !== batch.current.get(source)) { + var batch_value = /** @type {[any, boolean]} */ (batch.current.get(source))[0]; // faster than destructuring + + if (is_earlier && value !== batch_value) { // bring the value up to date - batch.current.set(source, value); + batch.current.set(source, [value, is_derived]); } else { // same value or later batch has more recent value, // no need to re-run these effects @@ -507,24 +539,56 @@ export class Batch { batch.deactivate(); } } + + for (const batch of batches) { + if (batch.#blockers.has(this)) { + batch.#blockers.delete(this); + + if (batch.#blockers.size === 0 && !batch.#is_deferred()) { + batch.activate(); + batch.#process(); + } + } + } } /** - * * @param {boolean} blocking + * @param {Effect} effect */ - increment(blocking) { - this.#pending += 1; - if (blocking) this.#blocking_pending += 1; + increment(blocking, effect) { + let pending_count = this.#pending.get(effect) ?? 0; + this.#pending.set(effect, pending_count + 1); + + if (blocking) { + let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0; + this.#blocking_pending.set(effect, blocking_pending_count + 1); + } } /** * @param {boolean} blocking + * @param {Effect} effect * @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction) */ - decrement(blocking, skip) { - this.#pending -= 1; - if (blocking) this.#blocking_pending -= 1; + decrement(blocking, effect, skip) { + let pending_count = this.#pending.get(effect) ?? 0; + + if (pending_count === 1) { + this.#pending.delete(effect); + } else { + this.#pending.set(effect, pending_count - 1); + } + + if (blocking) { + let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0; + + if (blocking_pending_count === 1) { + this.#blocking_pending.delete(effect); + } else { + this.#blocking_pending.set(effect, blocking_pending_count - 1); + } + } if (this.#decrement_queued || skip) return; this.#decrement_queued = true; @@ -597,15 +661,37 @@ export class Batch { // if there are multiple batches, we are 'time travelling' — // we need to override values with the ones in this batch... - batch_values = new Map(this.current); + batch_values = new Map(); + for (const [source, [value]] of this.current) { + batch_values.set(source, value); + } - // ...and undo changes belonging to other batches + // ...and undo changes belonging to other batches unless they block this one for (const batch of batches) { if (batch === this || batch.is_fork) continue; - for (const [source, previous] of batch.previous) { - if (!batch_values.has(source)) { - batch_values.set(source, previous); + // A batch is blocked on an earlier batch if it overlaps with the earlier batch's changes but is not a superset + var intersects = false; + var differs = false; + + if (batch.id < this.id) { + for (const [source, [, is_derived]] of batch.current) { + // Derived values don't partake in the blocking mechanism, because a derived could + // be triggered in one batch already but not the other one yet, causing a false-positive + if (is_derived) continue; + + intersects ||= this.current.has(source); + differs ||= !this.current.has(source); + } + } + + if (intersects && differs) { + this.#blockers.add(batch); + } else { + for (const [source, previous] of batch.previous) { + if (!batch_values.has(source)) { + batch_values.set(source, previous); + } } } } @@ -1065,7 +1151,7 @@ export function fork(fn) { batch.is_fork = false; // apply changes and update write versions so deriveds see the change - for (var [source, value] of batch.current) { + for (var [source, [value]] of batch.current) { source.v = value; source.wv = increment_write_version(); } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index aed55f7fba..5da0df0670 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -45,12 +45,16 @@ 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'; -/** @type {Effect | null} */ -export let current_async_effect = null; +/** + * This allows us to track 'reactivity loss' that occurs when signals + * are read after a non-context-restoring `await`. Dev-only + * @type {{ effect: Effect, warned: boolean } | null} + */ +export let reactivity_loss_tracker = null; -/** @param {Effect | null} v */ -export function set_from_async_derived(v) { - current_async_effect = v; +/** @param {{ effect: Effect, warned: boolean } | null} v */ +export function set_reactivity_loss_tracker(v) { + reactivity_loss_tracker = v; } export const recent_async_deriveds = new Set(); @@ -124,7 +128,12 @@ export function async_derived(fn, label, location) { var deferreds = new Map(); async_effect(() => { - if (DEV) current_async_effect = active_effect; + if (DEV) { + reactivity_loss_tracker = { + effect: /** @type {Effect} */ (active_effect), + warned: false + }; + } var effect = /** @type {Effect} */ (active_effect); @@ -142,7 +151,9 @@ export function async_derived(fn, label, location) { unset_context(); } - if (DEV) current_async_effect = null; + if (DEV) { + reactivity_loss_tracker = null; + } var batch = /** @type {Batch} */ (current_batch); @@ -174,7 +185,9 @@ export function async_derived(fn, label, location) { * @param {unknown} error */ const handler = (value, error = undefined) => { - if (DEV) current_async_effect = null; + if (DEV) { + reactivity_loss_tracker = null; + } if (decrement_pending) { // don't trigger an update if we're only here because @@ -383,7 +396,7 @@ export function update_derived(derived) { // change, `derived.equals` may incorrectly return `true` if (!current_batch?.is_fork || derived.deps === null) { derived.v = value; - current_batch?.capture(derived, old_value); + current_batch?.capture(derived, old_value, true); // deriveds without dependencies should never be recomputed if (derived.deps === null) { diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 3260c24b8c..906d68fbf0 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -27,7 +27,7 @@ import { } from './constants.js'; import { old_values } from './reactivity/sources.js'; import { - destroy_derived_effects, + reactivity_loss_tracker, execute_derived, freeze_derived_effects, recent_async_deriveds, @@ -58,6 +58,7 @@ import { UNINITIALIZED } from '../../constants.js'; import { captured_signals } from './legacy.js'; import { without_reactive_context } from './dom/elements/bindings/shared.js'; import { set_signal_status, update_derived_status } from './reactivity/status.js'; +import * as w from './warnings.js'; let is_updating_effect = false; @@ -568,19 +569,20 @@ export function get(signal) { } if (DEV) { - // TODO reinstate this, but make it actually work - // if (current_async_effect) { - // var tracking = (current_async_effect.f & REACTION_IS_UPDATING) !== 0; - // var was_read = current_async_effect.deps?.includes(signal); + if ( + !untracking && + reactivity_loss_tracker && + !reactivity_loss_tracker.warned && + (reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0 + ) { + reactivity_loss_tracker.warned = true; - // if (!tracking && !untracking && !was_read) { - // w.await_reactivity_loss(/** @type {string} */ (signal.label)); + w.await_reactivity_loss(/** @type {string} */ (signal.label)); - // var trace = get_error('traced at'); - // // eslint-disable-next-line no-console - // if (trace) console.warn(trace); - // } - // } + var trace = get_error('traced at'); + // eslint-disable-next-line no-console + if (trace) console.warn(trace); + } recent_async_deriveds.delete(signal); @@ -595,7 +597,7 @@ export function get(signal) { if (signal.trace) { signal.trace(); } else { - var trace = get_error('traced at'); + trace = get_error('traced at'); if (trace) { var entry = tracing_expressions.entries.get(signal); diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 5045430cee..191d53016a 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.12'; +export const VERSION = '5.54.0'; export const PUBLIC_VERSION = '5'; diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js new file mode 100644 index 0000000000..9d97b736be --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js @@ -0,0 +1,29 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a, b, resolve] = target.querySelectorAll('button'); + + a.click(); + await tick(); + b.click(); + await tick(); + resolve.click(); + await tick(); + resolve.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + hi + 1 + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte new file mode 100644 index 0000000000..f0d3bdc809 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte @@ -0,0 +1,21 @@ + + + + + + +{#if a_b}hi{/if} +{await push(a_b)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/_config.js new file mode 100644 index 0000000000..7025e4000e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/_config.js @@ -0,0 +1,32 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const spam = /** @type {HTMLButtonElement} */ (target.querySelector('button.spam')); + const resolve = /** @type {HTMLButtonElement} */ (target.querySelector('button.resolve')); + + resolve.click(); + await tick(); + + for (let i = 0; i < 5; i += 1) { + spam.click(); + await tick(); + } + + for (let i = 0; i < 5; i += 1) { + resolve.click(); + await tick(); + } + + assert.equal(target.querySelectorAll('div').length, 1); + assert.htmlEqual( + target.innerHTML, + ` + + +
5
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/main.svelte new file mode 100644 index 0000000000..aeed4fa306 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-await-stale-rows/main.svelte @@ -0,0 +1,28 @@ + + + + + + + {#each [value.id] as s (s)} + {await wait()} +
{s}
+ {/each} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js deleted file mode 100644 index d03f9ad09d..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js +++ /dev/null @@ -1,102 +0,0 @@ -import { tick } from 'svelte'; -import { test } from '../../test'; - -export default test({ - skip: true, - async test({ assert, target }) { - const [add, shift] = target.querySelectorAll('button'); - - add.click(); - await tick(); - add.click(); - await tick(); - add.click(); - await tick(); - - // TODO pending count / number of pushes is off - - assert.htmlEqual( - target.innerHTML, - ` - - -

pending=6 values.length=1 values=[1]

-
not keyed: -
1
-
-
keyed: -
1
-
- ` - ); - - shift.click(); - await tick(); - shift.click(); - await tick(); - assert.htmlEqual( - target.innerHTML, - ` - - -

pending=4 values.length=2 values=[1,2]

-
not keyed: -
1
-
2
-
-
keyed: -
1
-
2
-
- ` - ); - - shift.click(); - await tick(); - shift.click(); - await tick(); - assert.htmlEqual( - target.innerHTML, - ` - - -

pending=2 values.length=3 values=[1,2,3]

-
not keyed: -
1
-
2
-
3
-
-
keyed: -
1
-
2
-
3
-
- ` - ); - - shift.click(); - await tick(); - shift.click(); - await tick(); - assert.htmlEqual( - target.innerHTML, - ` - - -

pending=0 values.length=4 values=[1,2,3,4]

-
not keyed: -
1
-
2
-
3
-
4
-
-
keyed: -
1
-
2
-
3
-
4
-
- ` - ); - } -}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js new file mode 100644 index 0000000000..1d1a489d67 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js @@ -0,0 +1,29 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [a, b] = target.querySelectorAll('button'); + + assert.htmlEqual(target.innerHTML, `

hello

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

hello

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

hello

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

hello

`); + + // if we don't skip over the never-resolving promise in the `else` block, we will never update + b.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `

hello

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte new file mode 100644 index 0000000000..4ef0694d40 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte @@ -0,0 +1,14 @@ + + + + + +{#if show} +

hello

+{:else} + {await new Promise(() => {})} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-inspect-build/_config.js b/packages/svelte/tests/runtime-runes/samples/async-inspect-build/_config.js new file mode 100644 index 0000000000..58dd53c762 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-inspect-build/_config.js @@ -0,0 +1,10 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + ssrHtml: 'works', + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, 'works'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-inspect-build/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-inspect-build/main.svelte new file mode 100644 index 0000000000..a138475293 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-inspect-build/main.svelte @@ -0,0 +1,7 @@ + + +works diff --git a/packages/svelte/tests/runtime-runes/samples/async-late-value-init/_config.js b/packages/svelte/tests/runtime-runes/samples/async-late-value-init/_config.js new file mode 100644 index 0000000000..efc8fe565d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-late-value-init/_config.js @@ -0,0 +1,9 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, 'aaa 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-late-value-init/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-late-value-init/main.svelte new file mode 100644 index 0000000000..daa539a9b2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-late-value-init/main.svelte @@ -0,0 +1,10 @@ + + +{name} +{aa()} diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js new file mode 100644 index 0000000000..a65c8a6fba --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [a_b, b, resolve] = target.querySelectorAll('button'); + + a_b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0' + ); + + b.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 0' + ); + + resolve.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' 1' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte new file mode 100644 index 0000000000..8a9e2a866c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte @@ -0,0 +1,17 @@ + + + + + +{await push(a)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js new file mode 100644 index 0000000000..2dad5babea --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js @@ -0,0 +1,205 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [add, shift, pop] = target.querySelectorAll('button'); + + add.click(); + await tick(); + add.click(); + await tick(); + add.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=6 values.length=1 values=[1]

+
not keyed: +
1
+
+
keyed: +
1
+
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=4 values.length=2 values=[1,2]

+
not keyed: +
1
+
2
+
+
keyed: +
1
+
2
+
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=2 values.length=3 values=[1,2,3]

+
not keyed: +
1
+
2
+
3
+
+
keyed: +
1
+
2
+
3
+
+ ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=0 values.length=4 values=[1,2,3,4]

+
not keyed: +
1
+
2
+
3
+
4
+
+
keyed: +
1
+
2
+
3
+
4
+
+ ` + ); + + add.click(); + await tick(); + add.click(); + await tick(); + add.click(); + await tick(); + add.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=8 values.length=4 values=[1,2,3,4]

+
not keyed: +
1
+
2
+
3
+
4
+
+
keyed: +
1
+
2
+
3
+
4
+
+ ` + ); + + // pop should have no effect until earlier promises have also resolved + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=6 values.length=4 values=[1,2,3,4]

+
not keyed: +
1
+
2
+
3
+
4
+
+
keyed: +
1
+
2
+
3
+
4
+
+ ` + ); + + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

pending=0 values.length=8 values=[1,2,3,4,5,6,7,8]

+
not keyed: +
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
+
keyed: +
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/main.svelte similarity index 84% rename from packages/svelte/tests/runtime-runes/samples/async-each-overlap/main.svelte rename to packages/svelte/tests/runtime-runes/samples/async-overlapping-array/main.svelte index af9d395457..e41b6926b8 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-overlapping-array/main.svelte @@ -11,18 +11,14 @@ return p.promise; } - function shift() { - const fn = queue.shift(); - if (fn) fn(); - } - function addValue() { values.push(values.length+1); } - + +

pending={$effect.pending()} diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js index ce7cd6bd49..a5dd7fa28a 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js @@ -1,10 +1,8 @@ import { tick } from 'svelte'; import { test } from '../../test'; +import { normalise_trace_logs } from '../../../helpers.js'; export default test({ - // TODO reinstate - skip: true, - compileOptions: { dev: true }, @@ -15,13 +13,10 @@ export default test({ await tick(); assert.htmlEqual(target.innerHTML, '

3

'); - assert.equal( - warnings[0], - 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' - ); - - assert.equal(warnings[1].name, 'traced at'); - - assert.equal(warnings.length, 2); + assert.deepEqual(normalise_trace_logs(warnings), [ + { + log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`' + } + ]); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js index ad333a573a..747648e83f 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js @@ -2,9 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - // TODO reinstate this - skip: true, - compileOptions: { dev: true }, diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Image.svelte b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Image.svelte new file mode 100644 index 0000000000..2696aeb8c2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Image.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Link.svelte b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Link.svelte new file mode 100644 index 0000000000..527bba1171 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/Link.svelte @@ -0,0 +1,7 @@ + + + + {@render children()} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/_config.js b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/_config.js new file mode 100644 index 0000000000..6e3ce0df88 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/_config.js @@ -0,0 +1,14 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['hydrate'], + + async test({ assert, target }) { + await tick(); + assert.htmlEqual( + target.innerHTML, + `
card
` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/main.svelte new file mode 100644 index 0000000000..cb411f6e6c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-render-component-hydration/main.svelte @@ -0,0 +1,11 @@ + + + +
card
+ + diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js index e8aa8dfa11..668c936e4e 100644 --- a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/client/index.svelte.js @@ -6,7 +6,7 @@ var root = $.from_html(`

`); export default function Async_top_level_inspect_server($$anchor) { var data; - var $$promises = $.run([async () => data = await Promise.resolve(42),,]); + var $$promises = $.run([async () => data = await Promise.resolve(42), () => void 0]); var p = root(); var text = $.child(p, true); diff --git a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js index cff5f2d569..5c3afc75d7 100644 --- a/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-top-level-inspect-server/_expected/server/index.svelte.js @@ -3,7 +3,7 @@ import * as $ from 'svelte/internal/server'; export default function Async_top_level_inspect_server($$renderer) { var data; - var $$promises = $$renderer.run([async () => data = await Promise.resolve(42),,]); + var $$promises = $$renderer.run([async () => data = await Promise.resolve(42), () => void 0]); $$renderer.push(`

`); $$renderer.async([$$promises[1]], ($$renderer) => $$renderer.push(() => $.escape(data))); diff --git a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js index d84b674f88..eafadfc2ea 100644 --- a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js +++ b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js @@ -32,7 +32,7 @@ export default function Main($$anchor) { $.set_attribute(div_1, 'foobar', $0); $.set_attribute(svg_1, 'viewBox', $1); }, - [y, y] + [() => y(), () => y()] ); $.append($$anchor, fragment); diff --git a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js index 464435cb0a..4148a546c8 100644 --- a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js @@ -19,6 +19,6 @@ export default function Text_nodes_deriveds($$anchor) { var text = $.child(p); $.reset(p); - $.template_effect(($0, $1) => $.set_text(text, `${$0 ?? ''}${$1 ?? ''}`), [text1, text2]); + $.template_effect(($0, $1) => $.set_text(text, `${$0 ?? ''}${$1 ?? ''}`), [() => text1(), () => text2()]); $.append($$anchor, p); } \ No newline at end of file diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 9305513278..f2d0549430 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1030,9 +1030,11 @@ declare module 'svelte/compiler' { /** * If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component. * + * You can also pass a function that receives `{ filename }` and returns a boolean. + * * @default false */ - customElement?: boolean; + customElement?: boolean | ((options: { filename: string }) => boolean); /** * If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`. * @@ -1058,8 +1060,10 @@ declare module 'svelte/compiler' { * - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root. * - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files. * This is always `'injected'` when compiling with `customElement` mode. + * + * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`. */ - css?: 'injected' | 'external'; + css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'); /** * A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS. * It defaults to returning `svelte-${hash(filename ?? css)}`. @@ -1103,7 +1107,7 @@ declare module 'svelte/compiler' { * which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead. * @default undefined */ - runes?: boolean | undefined; + runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined); /** * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`. * @@ -3017,9 +3021,11 @@ declare module 'svelte/types/compiler/interfaces' { /** * If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component. * + * You can also pass a function that receives `{ filename }` and returns a boolean. + * * @default false */ - customElement?: boolean; + customElement?: boolean | ((options: { filename: string }) => boolean); /** * If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`. * @@ -3045,8 +3051,10 @@ declare module 'svelte/types/compiler/interfaces' { * - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root. * - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files. * This is always `'injected'` when compiling with `customElement` mode. + * + * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`. */ - css?: 'injected' | 'external'; + css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'); /** * A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS. * It defaults to returning `svelte-${hash(filename ?? css)}`. @@ -3090,7 +3098,7 @@ declare module 'svelte/types/compiler/interfaces' { * which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead. * @default undefined */ - runes?: boolean | undefined; + runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined); /** * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`. *