diff --git a/.changeset/rich-olives-yell.md b/.changeset/rich-olives-yell.md new file mode 100644 index 000000000000..e771b37ef797 --- /dev/null +++ b/.changeset/rich-olives-yell.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: replace proxy-based readonly validation with stack-trace-based ownership tracking 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 80628cc2f2d4..8e2b5acef2f0 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 @@ -262,8 +262,11 @@ export function client_component(source, analysis, options) { } } + const push_args = [b.id('$$props'), b.literal(analysis.runes)]; + if (options.dev) push_args.push(b.id(analysis.name)); + const component_block = b.block([ - b.stmt(b.call('$.push', b.id('$$props'), b.literal(analysis.runes))), + b.stmt(b.call('$.push', ...push_args)), ...store_setup, ...legacy_reactive_declarations, ...group_binding_declarations, @@ -343,6 +346,27 @@ export function client_component(source, analysis, options) { ) ]; + if (options.dev) { + if (options.filename) { + let filename = options.filename; + if (/(\/|\w:)/.test(options.filename)) { + // filename is absolute — truncate it + const parts = filename.split(/[/\\]/); + filename = parts.length > 3 ? ['...', ...parts.slice(-3)].join('/') : filename; + } + + // add `App.filename = 'App.svelte'` so that we can print useful messages later + body.push( + b.stmt( + b.assignment('=', b.member(b.id(analysis.name), b.id('filename')), b.literal(filename)) + ) + ); + } + + body.unshift(b.stmt(b.call(b.id('$.mark_module_start'), b.id(analysis.name)))); + body.push(b.stmt(b.call(b.id('$.mark_module_end')))); + } + if (options.discloseVersion) { body.unshift(b.imports([], 'svelte/internal/disclose-version')); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index 754aec21b4ab..d33be2fdce04 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -764,6 +764,8 @@ function serialize_inline_component(node, component_name, context) { /** @type {import('estree').Identifier | import('estree').MemberExpression | null} */ let bind_this = null; + const binding_initializers = []; + /** * If this component has a slot property, it is a named slot within another component. In this case * the slot scope applies to the component itself, too, and not just its children. @@ -843,8 +845,6 @@ function serialize_inline_component(node, component_name, context) { arg = b.call('$.get', id); } - if (context.state.options.dev) arg = b.call('$.readonly', arg); - push_prop(b.get(attribute.name, [b.return(arg)])); } else { push_prop(b.init(attribute.name, value)); @@ -853,14 +853,23 @@ function serialize_inline_component(node, component_name, context) { if (attribute.name === 'this') { bind_this = attribute.expression; } else { - push_prop( - b.get(attribute.name, [ - b.return( - /** @type {import('estree').Expression} */ (context.visit(attribute.expression)) - ) - ]) + const expression = /** @type {import('estree').Expression} */ ( + context.visit(attribute.expression) ); + if (context.state.options.dev) { + binding_initializers.push( + b.stmt( + b.call( + b.id('$.pre_effect'), + b.thunk(b.call(b.id('$.add_owner'), expression, b.id(component_name))) + ) + ) + ); + } + + push_prop(b.get(attribute.name, [b.return(expression)])); + const assignment = b.assignment('=', attribute.expression, b.id('$$value')); push_prop( b.set(attribute.name, [ @@ -1004,14 +1013,13 @@ function serialize_inline_component(node, component_name, context) { ); } - /** @type {import('estree').Statement} */ - let statement = b.stmt(fn(context.state.node)); - - if (snippet_declarations.length > 0) { - statement = b.block([...snippet_declarations, statement]); - } + const statements = [ + ...snippet_declarations, + ...binding_initializers, + b.stmt(fn(context.state.node)) + ]; - return statement; + return statements.length > 1 ? b.block(statements) : statements[0]; } /** diff --git a/packages/svelte/src/internal/client/dev/ownership.js b/packages/svelte/src/internal/client/dev/ownership.js new file mode 100644 index 000000000000..7d03e3c37f31 --- /dev/null +++ b/packages/svelte/src/internal/client/dev/ownership.js @@ -0,0 +1,154 @@ +/** @typedef {{ file: string, line: number, column: number }} Location */ + +import { STATE_SYMBOL } from '../proxy.js'; +import { untrack } from '../runtime.js'; + +/** @type {Record>} */ +const boundaries = {}; + +const chrome_pattern = /at (?:.+ \()?(.+):(\d+):(\d+)\)?$/; +const firefox_pattern = /@(.+):(\d+):(\d+)$/; + +export function get_stack() { + const stack = new Error().stack; + if (!stack) return null; + + const entries = []; + + for (const line of stack.split('\n')) { + let match = chrome_pattern.exec(line) ?? firefox_pattern.exec(line); + + if (match) { + entries.push({ + file: match[1], + line: +match[2], + column: +match[3] + }); + } + } + + return entries; +} + +/** + * Determines which `.svelte` component is responsible for a given state change + * @returns {Function | null} + */ +export function get_component() { + const stack = get_stack(); + if (!stack) return null; + + for (const entry of stack) { + const modules = boundaries[entry.file]; + if (!modules) continue; + + for (const module of modules) { + if (module.start.line < entry.line && module.end.line > entry.line) { + return module.component; + } + } + } + + return null; +} + +/** + * Together with `mark_module_end`, this function establishes the boundaries of a `.svelte` file, + * such that subsequent calls to `get_component` can tell us which component is responsible + * for a given state change + * @param {Function} component + */ +export function mark_module_start(component) { + const start = get_stack()?.[2]; + + if (start) { + (boundaries[start.file] ??= []).push({ + start, + // @ts-expect-error + end: null, + component + }); + } +} + +export function mark_module_end() { + const end = get_stack()?.[2]; + + if (end) { + // @ts-expect-error + boundaries[end.file].at(-1).end = end; + } +} + +/** + * + * @param {any} object + * @param {any} owner + */ +export function add_owner(object, owner) { + untrack(() => { + add_owner_to_object(object, owner); + }); +} + +/** + * @param {any} object + * @param {Function} owner + */ +function add_owner_to_object(object, owner) { + if (object?.[STATE_SYMBOL]?.o && !object[STATE_SYMBOL].o.has(owner)) { + object[STATE_SYMBOL].o.add(owner); + + for (const key in object) { + add_owner_to_object(object[key], owner); + } + } +} + +/** + * @param {any} object + */ +export function strip_owner(object) { + untrack(() => { + strip_owner_from_object(object); + }); +} + +/** + * @param {any} object + */ +function strip_owner_from_object(object) { + if (object?.[STATE_SYMBOL]?.o) { + object[STATE_SYMBOL].o = null; + + for (const key in object) { + strip_owner(object[key]); + } + } +} + +/** + * @param {Set} owners + */ +export function check_ownership(owners) { + const component = get_component(); + + if (component && !owners.has(component)) { + let original = [...owners][0]; + + let message = + // @ts-expect-error + original.filename !== component.filename + ? // @ts-expect-error + `${component.filename} mutated a value owned by ${original.filename}. This is strongly discouraged` + : 'Mutating a value outside the component that created it is strongly discouraged'; + + // eslint-disable-next-line no-console + console.warn( + `${message}. Consider passing values to child components with \`bind:\`, or use a callback instead.` + ); + + // eslint-disable-next-line no-console + console.trace(); + } +} diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index b988bb4c559d..e17edc248d47 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -8,7 +8,8 @@ import { updating_derived, UNINITIALIZED, mutable_source, - batch_inspect + batch_inspect, + current_component_context } from './runtime.js'; import { array_prototype, @@ -20,24 +21,38 @@ import { is_frozen, object_prototype } from './utils.js'; +import { add_owner, check_ownership, strip_owner } from './dev/ownership.js'; export const STATE_SYMBOL = Symbol('$state'); -export const READONLY_SYMBOL = Symbol('readonly'); /** * @template T * @param {T} value * @param {boolean} [immutable] + * @param {Function[]} [owners] * @returns {import('./types.js').ProxyStateObject | T} */ -export function proxy(value, immutable = true) { +export function proxy(value, immutable = true, owners) { if (typeof value === 'object' && value != null && !is_frozen(value)) { // If we have an existing proxy, return it... if (STATE_SYMBOL in value) { const metadata = /** @type {import('./types.js').ProxyMetadata} */ (value[STATE_SYMBOL]); // ...unless the proxy belonged to a different object, because // someone copied the state symbol using `Reflect.ownKeys(...)` - if (metadata.t === value || metadata.p === value) return metadata.p; + if (metadata.t === value || metadata.p === value) { + if (DEV) { + // update ownership + if (owners) { + for (const owner of owners) { + add_owner(value, owner); + } + } else { + strip_owner(value); + } + } + + return metadata.p; + } } const prototype = get_prototype_of(value); @@ -59,6 +74,19 @@ export function proxy(value, immutable = true) { enumerable: false }); + if (DEV) { + // set ownership — either of the parent proxy's owners (if provided) or, + // when calling `$.proxy(...)`, to the current component if such there be + // @ts-expect-error + value[STATE_SYMBOL].o = + owners === undefined + ? current_component_context + ? // @ts-expect-error + new Set([current_component_context.function]) + : null + : new Set(owners); + } + return proxy; } } @@ -95,7 +123,7 @@ function unwrap(value, already_unwrapped) { already_unwrapped.set(value, obj); for (const key of keys) { - if (key === STATE_SYMBOL || (DEV && key === READONLY_SYMBOL)) continue; + if (key === STATE_SYMBOL) continue; if (descriptors[key].get) { define_property(obj, key, descriptors[key]); } else { @@ -130,7 +158,7 @@ const state_proxy_handler = { const metadata = target[STATE_SYMBOL]; const s = metadata.s.get(prop); - if (s !== undefined) set(s, proxy(descriptor.value, metadata.i)); + if (s !== undefined) set(s, proxy(descriptor.value, metadata.i, metadata.o)); } return Reflect.defineProperty(target, prop, descriptor); @@ -163,9 +191,6 @@ const state_proxy_handler = { }, get(target, prop, receiver) { - if (DEV && prop === READONLY_SYMBOL) { - return Reflect.get(target, READONLY_SYMBOL); - } if (prop === STATE_SYMBOL) { return Reflect.get(target, STATE_SYMBOL); } @@ -180,7 +205,7 @@ const state_proxy_handler = { (effect_active() || updating_derived) && (!(prop in target) || get_descriptor(target, prop)?.writable) ) { - s = (metadata.i ? source : mutable_source)(proxy(target[prop], metadata.i)); + s = (metadata.i ? source : mutable_source)(proxy(target[prop], metadata.i, metadata.o)); metadata.s.set(prop, s); } @@ -212,9 +237,6 @@ const state_proxy_handler = { }, has(target, prop) { - if (DEV && prop === READONLY_SYMBOL) { - return Reflect.has(target, READONLY_SYMBOL); - } if (prop === STATE_SYMBOL) { return true; } @@ -225,7 +247,7 @@ const state_proxy_handler = { if (s !== undefined || (effect_active() && (!has || get_descriptor(target, prop)?.writable))) { if (s === undefined) { s = (metadata.i ? source : mutable_source)( - has ? proxy(target[prop], metadata.i) : UNINITIALIZED + has ? proxy(target[prop], metadata.i, metadata.o) : UNINITIALIZED ); metadata.s.set(prop, s); } @@ -238,16 +260,16 @@ const state_proxy_handler = { }, set(target, prop, value) { - if (DEV && prop === READONLY_SYMBOL) { - target[READONLY_SYMBOL] = value; - return true; - } const metadata = target[STATE_SYMBOL]; const s = metadata.s.get(prop); - if (s !== undefined) set(s, proxy(value, metadata.i)); + if (s !== undefined) set(s, proxy(value, metadata.i, metadata.o)); const is_array = metadata.a; const not_has = !(prop in target); + if (DEV && metadata.o) { + check_ownership(metadata.o); + } + // variable.length = value -> clear all signals with index >= value if (is_array && prop === 'length') { for (let i = value; i < target.length; i += 1) { @@ -292,69 +314,3 @@ if (DEV) { throw new Error('Cannot set prototype of $state object'); }; } - -/** - * Expects a value that was wrapped with `proxy` and makes it readonly. - * - * @template {Record} T - * @template {import('./types.js').ProxyReadonlyObject | T} U - * @param {U} value - * @returns {Proxy | U} - */ -export function readonly(value) { - const proxy = value && value[READONLY_SYMBOL]; - if (proxy) { - const metadata = value[STATE_SYMBOL]; - // Check that the incoming value is the same proxy that this readonly symbol was created for: - // If someone copies over the readonly symbol to a new object (using Reflect.ownKeys) the referenced - // proxy could be stale and we should not return it. - if (metadata.p === value) return proxy; - } - - if ( - typeof value === 'object' && - value != null && - !is_frozen(value) && - STATE_SYMBOL in value && // TODO handle Map and Set as well - !(READONLY_SYMBOL in value) - ) { - const proxy = new Proxy( - value, - /** @type {ProxyHandler>} */ ( - readonly_proxy_handler - ) - ); - define_property(value, READONLY_SYMBOL, { value: proxy, writable: false }); - return proxy; - } - - return value; -} - -/** - * @param {any} _ - * @param {string} prop - * @returns {never} - */ -const readonly_error = (_, prop) => { - throw new Error( - `Non-bound props cannot be mutated — to make the \`${prop}\` settable, ensure the object it is used within is bound as a prop \`bind:={...}\`. Fallback values can never be mutated.` - ); -}; - -/** @type {ProxyHandler} */ -const readonly_proxy_handler = { - defineProperty: readonly_error, - deleteProperty: readonly_error, - set: readonly_error, - - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - - if (!(prop in target)) { - return readonly(value); - } - - return value; - } -}; diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 8db5cfa54f91..da08baf1a10e 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -17,7 +17,7 @@ import { PROPS_IS_RUNES, PROPS_IS_UPDATED } from '../../constants.js'; -import { READONLY_SYMBOL, STATE_SYMBOL, proxy, readonly, unstate } from './proxy.js'; +import { STATE_SYMBOL, unstate } from './proxy.js'; import { EACH_BLOCK, IF_BLOCK } from './block.js'; export const SOURCE = 1; @@ -1608,10 +1608,6 @@ export function prop(props, key, flags, initial) { // @ts-expect-error would need a cumbersome method overload to type this if ((flags & PROPS_IS_LAZY_INITIAL) !== 0) initial = initial(); - if (DEV && runes) { - initial = readonly(proxy(/** @type {any} */ (initial))); - } - prop_value = /** @type {V} */ (initial); if (setter) setter(prop_value); @@ -1883,9 +1879,10 @@ function on_destroy(fn) { /** * @param {Record} props * @param {any} runes + * @param {Function} [fn] * @returns {void} */ -export function push(props, runes = false) { +export function push(props, runes = false, fn) { current_component_context = { // exports (and props, if `accessors: true`) x: null, @@ -1906,6 +1903,12 @@ export function push(props, runes = false) { // update_callbacks u: null }; + + if (DEV) { + // component function + // @ts-expect-error + current_component_context.function = fn; + } } /** @@ -2147,10 +2150,6 @@ export function freeze(value) { if (STATE_SYMBOL in value) { return object_freeze(unstate(value)); } - // If the value is already read-only then just use that - if (DEV && READONLY_SYMBOL in value) { - return value; - } // Otherwise freeze the object object_freeze(value); } diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 937028a84ecd..8b49dc51d460 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -10,7 +10,7 @@ import { DYNAMIC_ELEMENT_BLOCK, SNIPPET_BLOCK } from './block.js'; -import type { READONLY_SYMBOL, STATE_SYMBOL } from './proxy.js'; +import type { STATE_SYMBOL } from './proxy.js'; import { DERIVED, EFFECT, RENDER_EFFECT, SOURCE, PRE_EFFECT } from './runtime.js'; // Put all internal types in this file. Once we convert to JSDoc, we can make this a d.ts file @@ -403,15 +403,13 @@ export interface ProxyMetadata> { /** Immutable: Whether to use a source or mutable source under the hood */ i: boolean; /** The associated proxy */ - p: ProxyStateObject | ProxyReadonlyObject; + p: ProxyStateObject; /** The original target this proxy was created for */ t: T; + /** Dev-only — the components that 'own' this state, if any */ + o: null | Set; } export type ProxyStateObject> = T & { [STATE_SYMBOL]: ProxyMetadata; }; - -export type ProxyReadonlyObject> = ProxyStateObject & { - [READONLY_SYMBOL]: ProxyMetadata; -}; diff --git a/packages/svelte/src/internal/index.js b/packages/svelte/src/internal/index.js index 31e52917c01d..9b2b6d5bce41 100644 --- a/packages/svelte/src/internal/index.js +++ b/packages/svelte/src/internal/index.js @@ -40,6 +40,7 @@ export { freeze, init } from './client/runtime.js'; +export * from './client/dev/ownership.js'; export { await_block as await } from './client/dom/blocks/await.js'; export { if_block as if } from './client/dom/blocks/if.js'; export { key_block as key } from './client/dom/blocks/key.js'; @@ -47,7 +48,7 @@ export * from './client/dom/blocks/each.js'; export * from './client/render.js'; export * from './client/validate.js'; export { raf } from './client/timing.js'; -export { proxy, readonly, unstate } from './client/proxy.js'; +export { proxy, unstate } from './client/proxy.js'; export { create_custom_element } from './client/custom-element.js'; export { child, diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js index d08b81b5701c..16c923391d6b 100644 --- a/packages/svelte/tests/helpers.js +++ b/packages/svelte/tests/helpers.js @@ -81,8 +81,12 @@ export async function compile_directory( if (file.endsWith('.js')) { const out = `${output_dir}/${file}`; if (file.endsWith('.svelte.js')) { - const compiled = compileModule(text, opts); - write(out, compiled.js.code); + const compiled = compileModule(text, { + filename: opts.filename, + generate: opts.generate, + dev: opts.dev + }); + write(out, compiled.js.code.replace(`v${VERSION}`, 'VERSION')); } else { // for non-runes tests, just re-export from the original source file — this // allows the `_config.js` module to import shared state to use in tests diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-readonly/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/Counter.svelte similarity index 100% rename from packages/svelte/tests/runtime-runes/samples/proxy-prop-readonly/Counter.svelte rename to packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/Counter.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/_config.js b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/_config.js new file mode 100644 index 000000000000..7a78630da530 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/_config.js @@ -0,0 +1,47 @@ +import { test } from '../../test'; + +/** @type {typeof console.warn} */ +let warn; + +/** @type {typeof console.trace} */ +let trace; + +/** @type {any[]} */ +let warnings = []; + +export default test({ + html: ``, + + compileOptions: { + dev: true + }, + + before_test: () => { + warn = console.warn; + trace = console.trace; + + console.warn = (...args) => { + warnings.push(...args); + }; + + console.trace = () => {}; + }, + + after_test: () => { + console.warn = warn; + console.trace = trace; + + warnings = []; + }, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + await btn?.click(); + + assert.htmlEqual(target.innerHTML, ``); + + assert.deepEqual(warnings, [ + '.../samples/non-local-mutation-discouraged/Counter.svelte mutated a value owned by .../samples/non-local-mutation-discouraged/main.svelte. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead.' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-readonly/main.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/main.svelte similarity index 100% rename from packages/svelte/tests/runtime-runes/samples/proxy-prop-readonly/main.svelte rename to packages/svelte/tests/runtime-runes/samples/non-local-mutation-discouraged/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/Counter.svelte new file mode 100644 index 000000000000..ffe6ef75c4ed --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/Counter.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-reassigned/_config.js b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/_config.js similarity index 50% rename from packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-reassigned/_config.js rename to packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/_config.js index 9787b1ca9271..dc600e1461b5 100644 --- a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-reassigned/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/_config.js @@ -1,5 +1,11 @@ import { test } from '../../test'; +/** @type {typeof console.warn} */ +let warn; + +/** @type {any[]} */ +let warnings = []; + export default test({ html: ``, @@ -7,13 +13,25 @@ export default test({ dev: true }, + before_test: () => { + warn = console.warn; + + console.warn = (...args) => { + warnings.push(...args); + }; + }, + + after_test: () => { + console.warn = warn; + warnings = []; + }, + async test({ assert, target }) { const btn = target.querySelector('button'); - await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); - await btn?.click(); - assert.htmlEqual(target.innerHTML, ``); + assert.deepEqual(warnings, []); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/main.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/main.svelte new file mode 100644 index 000000000000..5f1c7461f636 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/main.svelte @@ -0,0 +1,9 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/state.svelte.js b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/state.svelte.js new file mode 100644 index 000000000000..6881c2faf66b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-inherited-owner/state.svelte.js @@ -0,0 +1,3 @@ +export let global = $state({ + object: { count: -1 } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-bail/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/Counter.svelte similarity index 66% rename from packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-bail/Counter.svelte rename to packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/Counter.svelte index 20f744f07d36..d1be32683013 100644 --- a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-bail/Counter.svelte +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/Counter.svelte @@ -3,6 +3,6 @@ let { object } = $props(); - diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/_config.js b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/_config.js new file mode 100644 index 000000000000..dc600e1461b5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/_config.js @@ -0,0 +1,37 @@ +import { test } from '../../test'; + +/** @type {typeof console.warn} */ +let warn; + +/** @type {any[]} */ +let warnings = []; + +export default test({ + html: ``, + + compileOptions: { + dev: true + }, + + before_test: () => { + warn = console.warn; + + console.warn = (...args) => { + warnings.push(...args); + }; + }, + + after_test: () => { + console.warn = warn; + warnings = []; + }, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + await btn?.click(); + + assert.htmlEqual(target.innerHTML, ``); + + assert.deepEqual(warnings, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/main.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/main.svelte new file mode 100644 index 000000000000..e8bd966fbd1b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding/main.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/props-array-each/_config.js b/packages/svelte/tests/runtime-runes/samples/props-array-each/_config.js new file mode 100644 index 000000000000..b3d63d3732e3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-array-each/_config.js @@ -0,0 +1,15 @@ +import { test } from '../../test'; + +export default test({ + html: `

1

1

1

`, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + + await btn?.click(); + assert.htmlEqual( + target.innerHTML, + `

1

2

1

2

1

2

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/props-array-each/child.svelte b/packages/svelte/tests/runtime-runes/samples/props-array-each/child.svelte new file mode 100644 index 000000000000..9f5a561f522c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-array-each/child.svelte @@ -0,0 +1,15 @@ + + +{#each array as number} +

{number.v}

+{/each} + +{#each array as number (number)} +

{number.v}

+{/each} + +{#each array as number (number.v)} +

{number.v}

+{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/props-array-each/main.svelte b/packages/svelte/tests/runtime-runes/samples/props-array-each/main.svelte new file mode 100644 index 000000000000..05e36da61645 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-array-each/main.svelte @@ -0,0 +1,12 @@ + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-reassigned/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/Counter.svelte similarity index 63% rename from packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-reassigned/Counter.svelte rename to packages/svelte/tests/runtime-runes/samples/props-default-reactivity/Counter.svelte index 1097b68f6f30..2ba2ed91006b 100644 --- a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-reassigned/Counter.svelte +++ b/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/Counter.svelte @@ -3,6 +3,10 @@ let { object = { count: 0 } } = $props(); + + diff --git a/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/_config.js b/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/_config.js new file mode 100644 index 000000000000..cf703fcea591 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/_config.js @@ -0,0 +1,30 @@ +import { test } from '../../test'; + +export default test({ + html: ` + + + `, + + async test({ assert, target }) { + const [btn1, btn2] = target.querySelectorAll('button'); + + await btn1?.click(); + assert.htmlEqual( + target.innerHTML, + ` + + + ` + ); + + await btn2?.click(); + assert.htmlEqual( + target.innerHTML, + ` + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-reassigned/main.svelte b/packages/svelte/tests/runtime-runes/samples/props-default-reactivity/main.svelte similarity index 100% rename from packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-reassigned/main.svelte rename to packages/svelte/tests/runtime-runes/samples/props-default-reactivity/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/props-equality/_config.js b/packages/svelte/tests/runtime-runes/samples/props-equality/_config.js new file mode 100644 index 000000000000..eda378947bdd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-equality/_config.js @@ -0,0 +1,45 @@ +import { test } from '../../test'; + +export default test({ + html: ` + + + + `, + + async test({ assert, target }) { + let [btn1, _btn2, btn3, _btn4, btn5] = target.querySelectorAll('button'); + + await btn1.click(); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + [btn1, _btn2, btn3, _btn4, btn5] = target.querySelectorAll('button'); + await btn3.click(); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + [btn1, _btn2, btn3, _btn4, btn5] = target.querySelectorAll('button'); + await btn5.click(); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/props-equality/item.svelte b/packages/svelte/tests/runtime-runes/samples/props-equality/item.svelte new file mode 100644 index 000000000000..c40c50d9ce0c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-equality/item.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/props-equality/main.svelte b/packages/svelte/tests/runtime-runes/samples/props-equality/main.svelte new file mode 100644 index 000000000000..7c7ee0c31efb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-equality/main.svelte @@ -0,0 +1,19 @@ + + + + +{#each items as item (item)} + item.name = item.name + '+'} /> +{/each} + +{#each items as item (item.name)} + {console.log('hello'); item.name = item.name + '+'}} /> +{/each} + +{#each items as item} + item.name = item.name + '+'} /> +{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-bail/_config.js b/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-bail/_config.js deleted file mode 100644 index 8726eead09cc..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-bail/_config.js +++ /dev/null @@ -1,22 +0,0 @@ -import { test } from '../../test'; - -// Tests that readonly bails on setters/classes -export default test({ - html: ``, - - compileOptions: { - dev: true - }, - - async test({ assert, target }) { - const [btn1, btn2] = target.querySelectorAll('button'); - - await btn1.click(); - await btn2.click(); - assert.htmlEqual(target.innerHTML, ``); - - await btn1.click(); - await btn2.click(); - assert.htmlEqual(target.innerHTML, ``); - } -}); diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-bail/main.svelte b/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-bail/main.svelte deleted file mode 100644 index 7ee12ca30613..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly-bail/main.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - - diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/Counter.svelte b/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/Counter.svelte deleted file mode 100644 index 91ccc2af8188..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/Counter.svelte +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/_config.js b/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/_config.js deleted file mode 100644 index d78db389d74e..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/_config.js +++ /dev/null @@ -1,19 +0,0 @@ -import { test } from '../../test'; - -export default test({ - html: ``, - - compileOptions: { - dev: true - }, - - async test({ assert, target }) { - const btn = target.querySelector('button'); - await btn?.click(); - - assert.htmlEqual(target.innerHTML, ``); - }, - - runtime_error: - 'Non-bound props cannot be mutated — to make the `count` settable, ensure the object it is used within is bound as a prop `bind:={...}`. Fallback values can never be mutated.' -}); diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/main.svelte b/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/main.svelte deleted file mode 100644 index 391afc4b232e..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/main.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/packages/svelte/tests/runtime-runes/samples/proxy-prop-readonly/_config.js b/packages/svelte/tests/runtime-runes/samples/proxy-prop-readonly/_config.js deleted file mode 100644 index d78db389d74e..000000000000 --- a/packages/svelte/tests/runtime-runes/samples/proxy-prop-readonly/_config.js +++ /dev/null @@ -1,19 +0,0 @@ -import { test } from '../../test'; - -export default test({ - html: ``, - - compileOptions: { - dev: true - }, - - async test({ assert, target }) { - const btn = target.querySelector('button'); - await btn?.click(); - - assert.htmlEqual(target.innerHTML, ``); - }, - - runtime_error: - 'Non-bound props cannot be mutated — to make the `count` settable, ensure the object it is used within is bound as a prop `bind:={...}`. Fallback values can never be mutated.' -}); diff --git a/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client/index.svelte.js index 7bf677e7e9ad..effa734e1d5f 100644 --- a/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client/index.svelte.js @@ -28,4 +28,4 @@ export default function Each_string_template($$anchor, $$props) { $.close_frag($$anchor, fragment); $.pop(); -} +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/export-state/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/export-state/_expected/client/index.svelte.js index 4cc594e9d06c..c9ba1e7c7334 100644 --- a/packages/svelte/tests/snapshot/samples/export-state/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/export-state/_expected/client/index.svelte.js @@ -1,4 +1,4 @@ /* index.svelte.js generated by Svelte VERSION */ import * as $ from "svelte/internal"; -export const object = $.proxy({ ok: true }); \ No newline at end of file +export const object = $.proxy({ ok: true }); diff --git a/packages/svelte/tests/snapshot/samples/export-state/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/export-state/_expected/server/index.svelte.js index a3b619df6eef..d7bc4c8f334d 100644 --- a/packages/svelte/tests/snapshot/samples/export-state/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/export-state/_expected/server/index.svelte.js @@ -1,4 +1,4 @@ /* index.svelte.js generated by Svelte VERSION */ import * as $ from "svelte/internal/server"; -export const object = { ok: true }; \ No newline at end of file +export const object = { ok: true }; diff --git a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js index 12e7295f6a08..f34a4933a345 100644 --- a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js @@ -33,4 +33,4 @@ export default function Function_prop_no_getter($$anchor, $$props) { $.close_frag($$anchor, fragment); $.pop(); -} +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js index 7eb39880c4df..f57dd8a9fdc0 100644 --- a/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js @@ -14,4 +14,4 @@ export default function Hello_world($$anchor, $$props) { $.close($$anchor, h1); $.pop(); -} +} \ No newline at end of file