fix: stack-trace-based readonly validation (#10464)

* fix: remove readonly validation

The readonly dev time validation results in false-negative object equality checks: The original object is proxified and thus comparisons could be between the unproxified and proxified version, which will always return false. Fixes #10372

There's also the problem that an object could be passed into a component but then passed upwards into shared state. At that point, it should no longer be readonly, but it's not possible to statically analyze these points. Fixes #10372

Lastly, the each block logic mutates an internal array and that also throws errors with the readonly logic. Fixes #10037

* reinstate tests

* track ownership of state and mutations

* working?

* remove old changeset

* tidy

* error

* simplify

* fix

* fix

* fix

* tidy

* make it a warning

* rename test

* remove unused test

* update tests

* slap ts-expect-error everywhere, because its too finicky otherwise

* oops

* oh no the hall monitor is here

* only call add_owner in dev

* only owners can transfer ownership

* simplify

* fixes

* tidy up

* fix type error

* while we're at it

* rename file

* rename functions

* add some comments

* move ownership checking logic

* ugh eslint

* more detailed message

* only add filename in dev

* comment

* update tests

* move more code

* undo change to sourcemap tests

* allow proxy to have multiple owners

* use SignalDebug

* i was doing this all wrong

* tidy up

* implement inheritance

* fix

* tidy up

* update filename stuff

* changeset

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/10568/head
Rich Harris 2 years ago committed by GitHub
parent f1550b2ea3
commit 8613a6f28a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: replace proxy-based readonly validation with stack-trace-based ownership tracking

@ -258,8 +258,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([ 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, ...store_setup,
...legacy_reactive_declarations, ...legacy_reactive_declarations,
...group_binding_declarations, ...group_binding_declarations,
@ -339,6 +342,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) { if (options.discloseVersion) {
body.unshift(b.imports([], 'svelte/internal/disclose-version')); body.unshift(b.imports([], 'svelte/internal/disclose-version'));
} }

@ -764,6 +764,8 @@ function serialize_inline_component(node, component_name, context) {
/** @type {import('estree').Identifier | import('estree').MemberExpression | null} */ /** @type {import('estree').Identifier | import('estree').MemberExpression | null} */
let bind_this = 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 * 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. * 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); arg = b.call('$.get', id);
} }
if (context.state.options.dev) arg = b.call('$.readonly', arg);
push_prop(b.get(attribute.name, [b.return(arg)])); push_prop(b.get(attribute.name, [b.return(arg)]));
} else { } else {
push_prop(b.init(attribute.name, value)); push_prop(b.init(attribute.name, value));
@ -853,13 +853,22 @@ function serialize_inline_component(node, component_name, context) {
if (attribute.name === 'this') { if (attribute.name === 'this') {
bind_this = attribute.expression; bind_this = attribute.expression;
} else { } else {
push_prop( const expression = /** @type {import('estree').Expression} */ (
b.get(attribute.name, [ context.visit(attribute.expression)
b.return( );
/** @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')); const assignment = b.assignment('=', attribute.expression, b.id('$$value'));
push_prop( push_prop(
@ -1004,14 +1013,13 @@ function serialize_inline_component(node, component_name, context) {
); );
} }
/** @type {import('estree').Statement} */ const statements = [
let statement = b.stmt(fn(context.state.node)); ...snippet_declarations,
...binding_initializers,
if (snippet_declarations.length > 0) { b.stmt(fn(context.state.node))
statement = b.block([...snippet_declarations, statement]); ];
}
return statement; return statements.length > 1 ? b.block(statements) : statements[0];
} }
/** /**

@ -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<string, Array<{ start: Location, end: Location, component: Function }>>} */
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<Function>} 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();
}
}

@ -8,7 +8,8 @@ import {
updating_derived, updating_derived,
UNINITIALIZED, UNINITIALIZED,
mutable_source, mutable_source,
batch_inspect batch_inspect,
current_component_context
} from './runtime.js'; } from './runtime.js';
import { import {
array_prototype, array_prototype,
@ -20,24 +21,38 @@ import {
is_frozen, is_frozen,
object_prototype object_prototype
} from './utils.js'; } from './utils.js';
import { add_owner, check_ownership, strip_owner } from './dev/ownership.js';
export const STATE_SYMBOL = Symbol('$state'); export const STATE_SYMBOL = Symbol('$state');
export const READONLY_SYMBOL = Symbol('readonly');
/** /**
* @template T * @template T
* @param {T} value * @param {T} value
* @param {boolean} [immutable] * @param {boolean} [immutable]
* @param {Function[]} [owners]
* @returns {import('./types.js').ProxyStateObject<T> | T} * @returns {import('./types.js').ProxyStateObject<T> | T}
*/ */
export function proxy(value, immutable = true) { export function proxy(value, immutable = true, owners) {
if (typeof value === 'object' && value != null && !is_frozen(value)) { if (typeof value === 'object' && value != null && !is_frozen(value)) {
// If we have an existing proxy, return it... // If we have an existing proxy, return it...
if (STATE_SYMBOL in value) { if (STATE_SYMBOL in value) {
const metadata = /** @type {import('./types.js').ProxyMetadata<T>} */ (value[STATE_SYMBOL]); const metadata = /** @type {import('./types.js').ProxyMetadata<T>} */ (value[STATE_SYMBOL]);
// ...unless the proxy belonged to a different object, because // ...unless the proxy belonged to a different object, because
// someone copied the state symbol using `Reflect.ownKeys(...)` // 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); const prototype = get_prototype_of(value);
@ -59,6 +74,19 @@ export function proxy(value, immutable = true) {
enumerable: false 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; return proxy;
} }
} }
@ -95,7 +123,7 @@ function unwrap(value, already_unwrapped) {
already_unwrapped.set(value, obj); already_unwrapped.set(value, obj);
for (const key of keys) { for (const key of keys) {
if (key === STATE_SYMBOL || (DEV && key === READONLY_SYMBOL)) continue; if (key === STATE_SYMBOL) continue;
if (descriptors[key].get) { if (descriptors[key].get) {
define_property(obj, key, descriptors[key]); define_property(obj, key, descriptors[key]);
} else { } else {
@ -130,7 +158,7 @@ const state_proxy_handler = {
const metadata = target[STATE_SYMBOL]; const metadata = target[STATE_SYMBOL];
const s = metadata.s.get(prop); 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); return Reflect.defineProperty(target, prop, descriptor);
@ -163,9 +191,6 @@ const state_proxy_handler = {
}, },
get(target, prop, receiver) { get(target, prop, receiver) {
if (DEV && prop === READONLY_SYMBOL) {
return Reflect.get(target, READONLY_SYMBOL);
}
if (prop === STATE_SYMBOL) { if (prop === STATE_SYMBOL) {
return Reflect.get(target, STATE_SYMBOL); return Reflect.get(target, STATE_SYMBOL);
} }
@ -180,7 +205,7 @@ const state_proxy_handler = {
(effect_active() || updating_derived) && (effect_active() || updating_derived) &&
(!(prop in target) || get_descriptor(target, prop)?.writable) (!(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); metadata.s.set(prop, s);
} }
@ -212,9 +237,6 @@ const state_proxy_handler = {
}, },
has(target, prop) { has(target, prop) {
if (DEV && prop === READONLY_SYMBOL) {
return Reflect.has(target, READONLY_SYMBOL);
}
if (prop === STATE_SYMBOL) { if (prop === STATE_SYMBOL) {
return true; return true;
} }
@ -225,7 +247,7 @@ const state_proxy_handler = {
if (s !== undefined || (effect_active() && (!has || get_descriptor(target, prop)?.writable))) { if (s !== undefined || (effect_active() && (!has || get_descriptor(target, prop)?.writable))) {
if (s === undefined) { if (s === undefined) {
s = (metadata.i ? source : mutable_source)( 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); metadata.s.set(prop, s);
} }
@ -238,16 +260,16 @@ const state_proxy_handler = {
}, },
set(target, prop, value) { set(target, prop, value) {
if (DEV && prop === READONLY_SYMBOL) {
target[READONLY_SYMBOL] = value;
return true;
}
const metadata = target[STATE_SYMBOL]; const metadata = target[STATE_SYMBOL];
const s = metadata.s.get(prop); 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 is_array = metadata.a;
const not_has = !(prop in target); const not_has = !(prop in target);
if (DEV && metadata.o) {
check_ownership(metadata.o);
}
// variable.length = value -> clear all signals with index >= value // variable.length = value -> clear all signals with index >= value
if (is_array && prop === 'length') { if (is_array && prop === 'length') {
for (let i = value; i < target.length; i += 1) { for (let i = value; i < target.length; i += 1) {
@ -292,69 +314,3 @@ if (DEV) {
throw new Error('Cannot set prototype of $state object'); throw new Error('Cannot set prototype of $state object');
}; };
} }
/**
* Expects a value that was wrapped with `proxy` and makes it readonly.
*
* @template {Record<string | symbol, any>} T
* @template {import('./types.js').ProxyReadonlyObject<T> | T} U
* @param {U} value
* @returns {Proxy<U> | 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<import('./types.js').ProxyReadonlyObject<U>>} */ (
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:<prop>={...}\`. Fallback values can never be mutated.`
);
};
/** @type {ProxyHandler<import('./types.js').ProxyReadonlyObject>} */
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;
}
};

@ -17,7 +17,7 @@ import {
PROPS_IS_RUNES, PROPS_IS_RUNES,
PROPS_IS_UPDATED PROPS_IS_UPDATED
} from '../../constants.js'; } 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'; import { EACH_BLOCK, IF_BLOCK } from './block.js';
export const SOURCE = 1; export const SOURCE = 1;
@ -1635,10 +1635,6 @@ export function prop(props, key, flags, initial) {
// @ts-expect-error would need a cumbersome method overload to type this // @ts-expect-error would need a cumbersome method overload to type this
if ((flags & PROPS_IS_LAZY_INITIAL) !== 0) initial = initial(); if ((flags & PROPS_IS_LAZY_INITIAL) !== 0) initial = initial();
if (DEV && runes) {
initial = readonly(proxy(/** @type {any} */ (initial)));
}
prop_value = /** @type {V} */ (initial); prop_value = /** @type {V} */ (initial);
if (setter) setter(prop_value); if (setter) setter(prop_value);
@ -1910,9 +1906,10 @@ function on_destroy(fn) {
/** /**
* @param {Record<string, unknown>} props * @param {Record<string, unknown>} props
* @param {any} runes * @param {any} runes
* @param {Function} [fn]
* @returns {void} * @returns {void}
*/ */
export function push(props, runes = false) { export function push(props, runes = false, fn) {
current_component_context = { current_component_context = {
// exports (and props, if `accessors: true`) // exports (and props, if `accessors: true`)
x: null, x: null,
@ -1933,6 +1930,12 @@ export function push(props, runes = false) {
// update_callbacks // update_callbacks
u: null u: null
}; };
if (DEV) {
// component function
// @ts-expect-error
current_component_context.function = fn;
}
} }
/** /**
@ -2176,10 +2179,6 @@ export function freeze(value) {
if (STATE_SYMBOL in value) { if (STATE_SYMBOL in value) {
return object_freeze(unstate(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 // Otherwise freeze the object
object_freeze(value); object_freeze(value);
} }

@ -10,7 +10,7 @@ import {
DYNAMIC_ELEMENT_BLOCK, DYNAMIC_ELEMENT_BLOCK,
SNIPPET_BLOCK SNIPPET_BLOCK
} from './block.js'; } 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'; 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 // Put all internal types in this file. Once we convert to JSDoc, we can make this a d.ts file
@ -407,15 +407,13 @@ export interface ProxyMetadata<T = Record<string | symbol, any>> {
/** Immutable: Whether to use a source or mutable source under the hood */ /** Immutable: Whether to use a source or mutable source under the hood */
i: boolean; i: boolean;
/** The associated proxy */ /** The associated proxy */
p: ProxyStateObject<T> | ProxyReadonlyObject<T>; p: ProxyStateObject<T>;
/** The original target this proxy was created for */ /** The original target this proxy was created for */
t: T; t: T;
/** Dev-only — the components that 'own' this state, if any */
o: null | Set<Function>;
} }
export type ProxyStateObject<T = Record<string | symbol, any>> = T & { export type ProxyStateObject<T = Record<string | symbol, any>> = T & {
[STATE_SYMBOL]: ProxyMetadata; [STATE_SYMBOL]: ProxyMetadata;
}; };
export type ProxyReadonlyObject<T = Record<string | symbol, any>> = ProxyStateObject<T> & {
[READONLY_SYMBOL]: ProxyMetadata;
};

@ -41,6 +41,7 @@ export {
init, init,
deep_read deep_read
} from './client/runtime.js'; } from './client/runtime.js';
export * from './client/dev/ownership.js';
export { await_block as await } from './client/dom/blocks/await.js'; export { await_block as await } from './client/dom/blocks/await.js';
export { if_block as if } from './client/dom/blocks/if.js'; export { if_block as if } from './client/dom/blocks/if.js';
export { key_block as key } from './client/dom/blocks/key.js'; export { key_block as key } from './client/dom/blocks/key.js';
@ -48,7 +49,7 @@ export * from './client/dom/blocks/each.js';
export * from './client/render.js'; export * from './client/render.js';
export * from './client/validate.js'; export * from './client/validate.js';
export { raf } from './client/timing.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 { create_custom_element } from './client/custom-element.js';
export { export {
child, child,

@ -81,8 +81,12 @@ export async function compile_directory(
if (file.endsWith('.js')) { if (file.endsWith('.js')) {
const out = `${output_dir}/${file}`; const out = `${output_dir}/${file}`;
if (file.endsWith('.svelte.js')) { if (file.endsWith('.svelte.js')) {
const compiled = compileModule(text, opts); const compiled = compileModule(text, {
write(out, compiled.js.code); filename: opts.filename,
generate: opts.generate,
dev: opts.dev
});
write(out, compiled.js.code.replace(`v${VERSION}`, 'VERSION'));
} else { } else {
// for non-runes tests, just re-export from the original source file — this // 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 // allows the `_config.js` module to import shared state to use in tests

@ -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: `<button>clicks: 0</button>`,
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, `<button>clicks: 1</button>`);
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.'
]);
}
});

@ -0,0 +1,7 @@
<script>
import { global } from './state.svelte.js';
</script>
<button onclick={() => global.object.count += 1}>
clicks: {global.object.count}
</button>

@ -1,5 +1,11 @@
import { test } from '../../test'; import { test } from '../../test';
/** @type {typeof console.warn} */
let warn;
/** @type {any[]} */
let warnings = [];
export default test({ export default test({
html: `<button>clicks: 0</button>`, html: `<button>clicks: 0</button>`,
@ -7,13 +13,25 @@ export default test({
dev: true dev: true
}, },
before_test: () => {
warn = console.warn;
console.warn = (...args) => {
warnings.push(...args);
};
},
after_test: () => {
console.warn = warn;
warnings = [];
},
async test({ assert, target }) { async test({ assert, target }) {
const btn = target.querySelector('button'); const btn = target.querySelector('button');
await btn?.click(); await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button>`); assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button>`);
await btn?.click(); assert.deepEqual(warnings, []);
assert.htmlEqual(target.innerHTML, `<button>clicks: 2</button>`);
} }
}); });

@ -0,0 +1,9 @@
<script>
import Counter from './Counter.svelte';
import { global } from './state.svelte.js';
let object = $state({ count: 0 });
global.object = object;
</script>
<Counter />

@ -3,6 +3,6 @@
let { object } = $props(); let { object } = $props();
</script> </script>
<button onclick={() => object = { count: object.count + 1 } }> <button onclick={() => object.count += 1}>
clicks: {object.count} clicks: {object.count}
</button> </button>

@ -0,0 +1,37 @@
import { test } from '../../test';
/** @type {typeof console.warn} */
let warn;
/** @type {any[]} */
let warnings = [];
export default test({
html: `<button>clicks: 0</button>`,
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, `<button>clicks: 1</button>`);
assert.deepEqual(warnings, []);
}
});

@ -0,0 +1,7 @@
<script>
import Counter from './Counter.svelte';
let object = $state({ count: 0 });
</script>
<Counter bind:object={object} />

@ -0,0 +1,15 @@
import { test } from '../../test';
export default test({
html: `<button>add</button> <p>1</p><p>1</p><p>1</p>`,
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(
target.innerHTML,
`<button>add</button> <p>1</p><p>2</p><p>1</p><p>2</p><p>1</p><p>2</p>`
);
}
});

@ -0,0 +1,15 @@
<script>
let { array } = $props();
</script>
{#each array as number}
<p>{number.v}</p>
{/each}
{#each array as number (number)}
<p>{number.v}</p>
{/each}
{#each array as number (number.v)}
<p>{number.v}</p>
{/each}

@ -0,0 +1,12 @@
<script>
import Child from './child.svelte';
let array = $state([{v: 1}]);
const addNew = () => {
array.push({v: 2})
}
</script>
<button onclick={addNew}>add</button>
<Child {array} />

@ -3,6 +3,10 @@
let { object = { count: 0 } } = $props(); let { object = { count: 0 } } = $props();
</script> </script>
<button onclick={() => object.count += 1}>
mutate: {object.count}
</button>
<button onclick={() => object = { count: object.count + 1 } }> <button onclick={() => object = { count: object.count + 1 } }>
clicks: {object.count} reassign: {object.count}
</button> </button>

@ -0,0 +1,30 @@
import { test } from '../../test';
export default test({
html: `
<button>mutate: 0</button>
<button>reassign: 0</button>
`,
async test({ assert, target }) {
const [btn1, btn2] = target.querySelectorAll('button');
await btn1?.click();
assert.htmlEqual(
target.innerHTML,
`
<button>mutate: 0</button>
<button>reassign: 0</button>
`
);
await btn2?.click();
assert.htmlEqual(
target.innerHTML,
`
<button>mutate: 2</button>
<button>reassign: 2</button>
`
);
}
});

@ -0,0 +1,45 @@
import { test } from '../../test';
export default test({
html: `
<button>a true</button><button>b true</button>
<button>a true</button><button>b true</button>
<button>a true</button><button>b true</button>
`,
async test({ assert, target }) {
let [btn1, _btn2, btn3, _btn4, btn5] = target.querySelectorAll('button');
await btn1.click();
assert.htmlEqual(
target.innerHTML,
`
<button>a+ true</button><button>b true</button>
<button>a+ true</button><button>b true</button>
<button>a+ true</button><button>b true</button>
`
);
[btn1, _btn2, btn3, _btn4, btn5] = target.querySelectorAll('button');
await btn3.click();
assert.htmlEqual(
target.innerHTML,
`
<button>a++ true</button><button>b true</button>
<button>a++ true</button><button>b true</button>
<button>a++ true</button><button>b true</button>
`
);
[btn1, _btn2, btn3, _btn4, btn5] = target.querySelectorAll('button');
await btn5.click();
assert.htmlEqual(
target.innerHTML,
`
<button>a+++ true</button><button>b true</button>
<button>a+++ true</button><button>b true</button>
<button>a+++ true</button><button>b true</button>
`
);
}
});

@ -0,0 +1,7 @@
<script>
let {item, items, onclick} = $props()
</script>
<button {onclick}>
{item.name} {items.includes(item)}
</button>

@ -0,0 +1,19 @@
<script>
import Item from './item.svelte'
let items = $state([{name: 'a'}, {name: 'b'}]);
</script>
<!-- test that each block doesn't mess with item identity -->
{#each items as item (item)}
<Item {item} {items} onclick={() => item.name = item.name + '+'} />
{/each}
{#each items as item (item.name)}
<Item {item} {items} onclick={() => {console.log('hello'); item.name = item.name + '+'}} />
{/each}
{#each items as item}
<Item {item} {items} onclick={() => item.name = item.name + '+'} />
{/each}

@ -1,22 +0,0 @@
import { test } from '../../test';
// Tests that readonly bails on setters/classes
export default test({
html: `<button>clicks: 0</button><button>clicks: 0</button>`,
compileOptions: {
dev: true
},
async test({ assert, target }) {
const [btn1, btn2] = target.querySelectorAll('button');
await btn1.click();
await btn2.click();
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button><button>clicks: 1</button>`);
await btn1.click();
await btn2.click();
assert.htmlEqual(target.innerHTML, `<button>clicks: 2</button><button>clicks: 2</button>`);
}
});

@ -1,25 +0,0 @@
<script>
import Counter from './Counter.svelte';
function createCounter() {
let count = $state(0)
return {
get count() {
return count;
},
set count(upd) {
count = upd
}
}
}
class CounterClass {
count = $state(0);
}
const counterSetter = createCounter();
const counterClass = new CounterClass();
</script>
<Counter object={counterSetter} />
<Counter object={counterClass} />

@ -1,8 +0,0 @@
<script>
/** @type {{ object?: { count: number }}} */
let { object = { count: 0 } } = $props();
</script>
<button onclick={() => object.count += 1}>
clicks: {object.count}
</button>

@ -1,19 +0,0 @@
import { test } from '../../test';
export default test({
html: `<button>clicks: 0</button>`,
compileOptions: {
dev: true
},
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>clicks: 0</button>`);
},
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:<prop>={...}`. Fallback values can never be mutated.'
});

@ -1,5 +0,0 @@
<script>
import Counter from './Counter.svelte';
</script>
<Counter />

@ -1,19 +0,0 @@
import { test } from '../../test';
export default test({
html: `<button>clicks: 0</button>`,
compileOptions: {
dev: true
},
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>clicks: 0</button>`);
},
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:<prop>={...}`. Fallback values can never be mutated.'
});
Loading…
Cancel
Save