`$inspect` rune (#9705)

* feat: add $log rune

* fix issues

* fix issues

* tune

* avoid static state reference validation

* work around unfortunate browser behavior

* call it ExpectedError

* cleanup

* Fix docs

* tweaks

* tweaks

* lint

* repl, dev: true

* repl dev mode

* Update sites/svelte-5-preview/src/lib/Repl.svelte

* squelch static-state-reference warning

* simplify

* remove redundant code

* Update packages/svelte/src/main/ambient.d.ts

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* Update packages/svelte/src/main/ambient.d.ts

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* Update packages/svelte/src/main/ambient.d.ts

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* only pause/trace on change

* Update packages/svelte/src/main/ambient.d.ts

* Update .changeset/chatty-hotels-grin.md

* Update sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* $log.break and $log.trace no-op during SSR

* Update sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* update test

* improve break experience

* fix ts

* remove unnecessary if (DEV) checks - log runes are removed in prod

* ensure hoisting doesnt mess up source maps

* check visited for cyclical values

* rename $log to $inspect, remove children

* custom inspect function

* implement custom inspect functions

* changeset

* update docs

* only fire on change

* lint

* make inspect take a single argument

* ugh eslint

* document console.trace trick

* demos

* fix site

---------

Co-authored-by: Dominic Gannaway <dg@domgan.com>
Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
Co-authored-by: Dominic Gannaway <trueadm@users.noreply.github.com>
pull/9713/head
Rich Harris 2 years ago committed by GitHub
parent 3e3ae925f3
commit bde42d5676
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: $inspect rune

@ -265,7 +265,7 @@ export function analyze_component(root, options) {
// is referencing a rune and not a global store. // is referencing a rune and not a global store.
if ( if (
options.runes === false || options.runes === false ||
!Runes.includes(name) || !Runes.includes(/** @type {any} */ (name)) ||
(declaration !== null && (declaration !== null &&
// const state = $state(0) is valid // const state = $state(0) is valid
get_rune(declaration.initial, instance.scope) === null && get_rune(declaration.initial, instance.scope) === null &&
@ -279,7 +279,7 @@ export function analyze_component(root, options) {
if (options.runes !== false) { if (options.runes !== false) {
if (declaration === null && /[a-z]/.test(store_name[0])) { if (declaration === null && /[a-z]/.test(store_name[0])) {
error(references[0].node, 'illegal-global', name); error(references[0].node, 'illegal-global', name);
} else if (declaration !== null && Runes.includes(name)) { } else if (declaration !== null && Runes.includes(/** @type {any} */ (name))) {
for (const { node, path } of references) { for (const { node, path } of references) {
if (path.at(-1)?.type === 'CallExpression') { if (path.at(-1)?.type === 'CallExpression') {
warn(warnings, node, [], 'store-with-rune-name', store_name); warn(warnings, node, [], 'store-with-rune-name', store_name);
@ -326,7 +326,10 @@ export function analyze_component(root, options) {
get_css_hash: options.cssHash get_css_hash: options.cssHash
}), }),
runes: runes:
options.runes ?? Array.from(module.scope.references).some(([name]) => Runes.includes(name)), options.runes ??
Array.from(module.scope.references).some(([name]) =>
Runes.includes(/** @type {any} */ (name))
),
exports: [], exports: [],
uses_props: false, uses_props: false,
uses_rest_props: false, uses_rest_props: false,
@ -660,6 +663,14 @@ const runes_scope_js_tweaker = {
/** @type {import('./types').Visitors} */ /** @type {import('./types').Visitors} */
const runes_scope_tweaker = { const runes_scope_tweaker = {
CallExpression(node, { state, next }) {
const rune = get_rune(node, state.scope);
// `$inspect(foo)` should not trigger the `static-state-reference` warning
if (rune === '$inspect') {
next({ ...state, function_depth: state.function_depth + 1 });
}
},
VariableDeclarator(node, { state }) { VariableDeclarator(node, { state }) {
const init = unwrap_ts_expression(node.init); const init = unwrap_ts_expression(node.init);
if (!init || init.type !== 'CallExpression') return; if (!init || init.type !== 'CallExpression') return;
@ -880,6 +891,7 @@ const common_visitors = {
Identifier(node, context) { Identifier(node, context) {
const parent = /** @type {import('estree').Node} */ (context.path.at(-1)); const parent = /** @type {import('estree').Node} */ (context.path.at(-1));
if (!is_reference(node, parent)) return; if (!is_reference(node, parent)) return;
const binding = context.state.scope.get(node.name); const binding = context.state.scope.get(node.name);
// if no binding, means some global variable // if no binding, means some global variable

@ -521,13 +521,19 @@ function validate_call_expression(node, scope, path) {
if (rune === '$effect.active') { if (rune === '$effect.active') {
if (node.arguments.length !== 0) { if (node.arguments.length !== 0) {
error(node, 'invalid-rune-args-length', '$effect.active', [0]); error(node, 'invalid-rune-args-length', rune, [0]);
} }
} }
if (rune === '$effect.root') { if (rune === '$effect.root') {
if (node.arguments.length !== 1) { if (node.arguments.length !== 1) {
error(node, 'invalid-rune-args-length', '$effect.root', [1]); error(node, 'invalid-rune-args-length', rune, [1]);
}
}
if (rune === '$inspect') {
if (node.arguments.length < 1 || node.arguments.length > 2) {
error(node, 'invalid-rune-args-length', rune, [1, 2]);
} }
} }
} }

@ -288,7 +288,8 @@ function get_hoistable_params(node, context) {
params.push(b.id(binding.node.name.slice(1))); params.push(b.id(binding.node.name.slice(1)));
params.push(b.id(binding.node.name)); params.push(b.id(binding.node.name));
} else { } else {
params.push(binding.node); // create a copy to remove start/end tags which would mess up source maps
params.push(b.id(binding.node.name));
} }
} }
} }

@ -136,7 +136,7 @@ export const javascript_visitors_runes = {
for (const declarator of node.declarations) { for (const declarator of node.declarations) {
const init = unwrap_ts_expression(declarator.init); const init = unwrap_ts_expression(declarator.init);
const rune = get_rune(init, state.scope); const rune = get_rune(init, state.scope);
if (!rune || rune === '$effect.active' || rune === '$effect.root') { if (!rune || rune === '$effect.active' || rune === '$effect.root' || rune === '$inspect') {
if (init != null && is_hoistable_function(init)) { if (init != null && is_hoistable_function(init)) {
const hoistable_function = visit(init); const hoistable_function = visit(init);
state.hoisted.push( state.hoisted.push(
@ -307,6 +307,19 @@ export const javascript_visitors_runes = {
return b.call('$.user_root_effect', ...args); return b.call('$.user_root_effect', ...args);
} }
if (rune === '$inspect') {
if (state.options.dev) {
const arg = /** @type {import('estree').Expression} */ (visit(node.arguments[0]));
const fn =
node.arguments[1] &&
/** @type {import('estree').Expression} */ (visit(node.arguments[1]));
return b.call('$.inspect', b.thunk(arg), fn);
}
return b.unary('void', b.literal(0));
}
next(); next();
} }
}; };

@ -575,7 +575,7 @@ const javascript_visitors_runes = {
for (const declarator of node.declarations) { for (const declarator of node.declarations) {
const init = unwrap_ts_expression(declarator.init); const init = unwrap_ts_expression(declarator.init);
const rune = get_rune(init, state.scope); const rune = get_rune(init, state.scope);
if (!rune || rune === '$effect.active') { if (!rune || rune === '$effect.active' || rune === '$inspect') {
declarations.push(/** @type {import('estree').VariableDeclarator} */ (visit(declarator))); declarations.push(/** @type {import('estree').VariableDeclarator} */ (visit(declarator)));
continue; continue;
} }
@ -630,13 +630,25 @@ const javascript_visitors_runes = {
} }
context.next(); context.next();
}, },
CallExpression(node, { state, next }) { CallExpression(node, { state, next, visit }) {
const rune = get_rune(node, state.scope); const rune = get_rune(node, state.scope);
if (rune === '$effect.active') { if (rune === '$effect.active') {
return b.literal(false); return b.literal(false);
} }
if (rune === '$inspect') {
if (state.options.dev) {
const args = /** @type {import('estree').Expression[]} */ (
node.arguments.map((arg) => visit(arg))
);
return b.call('console.log', ...args);
}
return b.unary('void', b.literal(0));
}
next(); next();
} }
}; };

@ -70,15 +70,16 @@ export const ElementBindings = [
'indeterminate' 'indeterminate'
]; ];
export const Runes = [ export const Runes = /** @type {const} */ ([
'$state', '$state',
'$props', '$props',
'$derived', '$derived',
'$effect', '$effect',
'$effect.pre', '$effect.pre',
'$effect.active', '$effect.active',
'$effect.root' '$effect.root',
]; '$inspect'
]);
/** /**
* Whitespace inside one of these elements will not result in * Whitespace inside one of these elements will not result in

@ -672,6 +672,7 @@ export function set_scope(scopes) {
* Returns the name of the rune if the given expression is a `CallExpression` using a rune. * Returns the name of the rune if the given expression is a `CallExpression` using a rune.
* @param {import('estree').Node | null | undefined} node * @param {import('estree').Node | null | undefined} node
* @param {Scope} scope * @param {Scope} scope
* @returns {Runes[number] | null}
*/ */
export function get_rune(node, scope) { export function get_rune(node, scope) {
if (!node) return null; if (!node) return null;
@ -691,10 +692,10 @@ export function get_rune(node, scope) {
if (n.type !== 'Identifier') return null; if (n.type !== 'Identifier') return null;
joined = n.name + joined; joined = n.name + joined;
if (!Runes.includes(joined)) return null; if (!Runes.includes(/** @type {any} */ (joined))) return null;
const binding = scope.get(n.name); const binding = scope.get(n.name);
if (binding !== null) return null; // rune name, but references a variable or store if (binding !== null) return null; // rune name, but references a variable or store
return joined; return /** @type {Runes[number] | null} */ (joined);
} }

@ -72,7 +72,7 @@ export function labeled(name, body) {
/** /**
* @param {string | import('estree').Expression} callee * @param {string | import('estree').Expression} callee
* @param {...import('estree').Expression} args * @param {...(import('estree').Expression | import('estree').SpreadElement)} args
* @returns {import('estree').CallExpression} * @returns {import('estree').CallExpression}
*/ */
export function call(callee, ...args) { export function call(callee, ...args) {

@ -2,7 +2,7 @@ import { DEV } from 'esm-env';
import { subscribe_to_store } from '../../store/utils.js'; import { subscribe_to_store } from '../../store/utils.js';
import { EMPTY_FUNC, run_all } from '../common.js'; import { EMPTY_FUNC, run_all } from '../common.js';
import { unwrap } from './render.js'; import { unwrap } from './render.js';
import { is_array } from './utils.js'; import { get_descriptors, is_array } from './utils.js';
export const SOURCE = 1; export const SOURCE = 1;
export const DERIVED = 1 << 1; export const DERIVED = 1 << 1;
@ -69,8 +69,14 @@ let current_skip_consumer = false;
// Handle collecting all signals which are read during a specific time frame // Handle collecting all signals which are read during a specific time frame
let is_signals_recorded = false; let is_signals_recorded = false;
let captured_signals = new Set(); let captured_signals = new Set();
// Handle rendering tree blocks and anchors
/** @type {Function | null} */
let inspect_fn = null;
/** @type {Array<import('./types.js').SourceSignal & import('./types.js').SourceSignalDebug>} */
let inspect_captured_signals = [];
// Handle rendering tree blocks and anchors
/** @type {null | import('./types.js').Block} */ /** @type {null | import('./types.js').Block} */
export let current_block = null; export let current_block = null;
// Handling runtime component context // Handling runtime component context
@ -145,10 +151,26 @@ function default_equals(a, b) {
* @template V * @template V
* @param {import('./types.js').SignalFlags} flags * @param {import('./types.js').SignalFlags} flags
* @param {V} value * @param {V} value
* @returns {import('./types.js').SourceSignal<V>} * @returns {import('./types.js').SourceSignal<V> | import('./types.js').SourceSignal<V> & import('./types.js').SourceSignalDebug}
*/ */
function create_source_signal(flags, value) { function create_source_signal(flags, value) {
const source = { if (DEV) {
return {
// consumers
c: null,
// equals
e: null,
// flags
f: flags,
// value
v: value,
// context: We can remove this if we get rid of beforeUpdate/afterUpdate
x: null,
// this is for DEV only
inspect: new Set()
};
}
return {
// consumers // consumers
c: null, c: null,
// equals // equals
@ -160,7 +182,6 @@ function create_source_signal(flags, value) {
// context: We can remove this if we get rid of beforeUpdate/afterUpdate // context: We can remove this if we get rid of beforeUpdate/afterUpdate
x: null x: null
}; };
return source;
} }
/** /**
@ -688,7 +709,7 @@ export function store_get(store, store_name, stores) {
/** /**
* @template V * @template V
* @param {import('./types.js').Store<V> | null | undefined} store * @param {import('./types.js').Store<V> | null | undefined} store
* @param {import('./types.js').Signal<V>} source * @param {import('./types.js').SourceSignal<V>} source
*/ */
function connect_store_to_signal(store, source) { function connect_store_to_signal(store, source) {
if (store == null) { if (store == null) {
@ -756,6 +777,14 @@ export function exposable(fn) {
* @returns {V} * @returns {V}
*/ */
export function get(signal) { export function get(signal) {
// @ts-expect-error
if (DEV && signal.inspect && inspect_fn) {
// @ts-expect-error
signal.inspect.add(inspect_fn);
// @ts-expect-error
inspect_captured_signals.push(signal);
}
const flags = signal.f; const flags = signal.f;
if ((flags & DESTROYED) !== 0) { if ((flags & DESTROYED) !== 0) {
return signal.v; return signal.v;
@ -811,7 +840,7 @@ export function set(signal, value) {
* @returns {void} * @returns {void}
*/ */
export function set_sync(signal, value) { export function set_sync(signal, value) {
flushSync(() => set_signal_value(signal, value)); flushSync(() => set(signal, value));
} }
/** /**
@ -1016,6 +1045,12 @@ export function set_signal_value(signal, value) {
}); });
} }
} }
// @ts-expect-error
if (DEV && signal.inspect) {
// @ts-expect-error
for (const fn of signal.inspect) fn();
}
} }
} }
@ -1727,3 +1762,69 @@ export function pop(accessors) {
context_stack_item.m = true; context_stack_item.m = true;
} }
} }
/**
* @param {any} value
* @param {Set<any>} visited
* @returns {void}
*/
function deep_read(value, visited = new Set()) {
if (typeof value === 'object' && value !== null && !visited.has(value)) {
visited.add(value);
for (let key in value) {
deep_read(value[key], visited);
}
const proto = Object.getPrototypeOf(value);
if (
proto !== Object.prototype &&
proto !== Array.prototype &&
proto !== Map.prototype &&
proto !== Set.prototype &&
proto !== Date.prototype
) {
const descriptors = get_descriptors(proto);
for (let key in descriptors) {
const get = descriptors[key].get;
if (get) {
get.call(value);
}
}
}
}
}
/**
* @param {() => import('./types.js').MaybeSignal<>} get_value
* @param {Function} inspect
* @returns {void}
*/
// eslint-disable-next-line no-console
export function inspect(get_value, inspect = console.log) {
let initial = true;
pre_effect(() => {
const fn = () => {
const value = get_value();
inspect(value, initial ? 'init' : 'update');
};
inspect_fn = fn;
const value = get_value();
deep_read(value);
inspect_fn = null;
const signals = inspect_captured_signals.slice();
inspect_captured_signals = [];
if (initial) {
fn();
initial = false;
}
return () => {
for (const s of signals) {
s.inspect.delete(fn);
}
};
});
}

@ -80,6 +80,11 @@ export type SourceSignal<V = unknown> = {
v: V; v: V;
}; };
export type SourceSignalDebug = {
/** This is DEV only */
inspect: Set<Function>;
};
export type ComputationSignal<V = unknown> = { export type ComputationSignal<V = unknown> = {
/** block: The block associated with this effect/computed */ /** block: The block associated with this effect/computed */
b: null | Block; b: null | Block;

@ -37,7 +37,8 @@ export {
push, push,
reactive_import, reactive_import,
effect_active, effect_active,
user_root_effect user_root_effect,
inspect
} from './client/runtime.js'; } from './client/runtime.js';
export * from './client/validate.js'; export * from './client/validate.js';

@ -130,3 +130,14 @@ declare namespace $effect {
* https://svelte-5-preview.vercel.app/docs/runes#$props * https://svelte-5-preview.vercel.app/docs/runes#$props
*/ */
declare function $props<T>(): T; declare function $props<T>(): T;
/**
* Logs the arguments whenever they, or the properties they contain, change. Example:
*
* ```ts
* $inspect(someValue, someOtherValue)
* ```
*
* https://svelte-5-preview.vercel.app/docs/runes#$inspect
*/
declare function $inspect(): void;

@ -0,0 +1,39 @@
import { test } from '../../test';
/**
* @type {any[]}
*/
let log;
/**
* @type {typeof console.log}}
*/
let original_log;
export default test({
compileOptions: {
dev: true
},
before_test() {
log = [];
original_log = console.log;
console.log = (...v) => {
log.push(...v);
};
},
after_test() {
console.log = original_log;
},
async test({ assert, target }) {
assert.deepEqual(log, []);
const [b1, b2] = target.querySelectorAll('button');
b1.click();
b2.click();
await Promise.resolve();
assert.ok(
log[0].stack.startsWith('Error:') && log[0].stack.includes('HTMLButtonElement.on_click')
);
assert.deepEqual(log[1], 1);
}
});

@ -0,0 +1,11 @@
<script>
let x = $state(0);
let y = $state(0);
$inspect(x, (x, type) => {
if (type === 'update') console.log(new Error(), x);
});
</script>
<button on:click={() => x++}>{x}</button>
<button on:click={() => y++}>{y}</button>

@ -0,0 +1,34 @@
import { test } from '../../test';
/**
* @type {any[]}
*/
let log;
/**
* @type {typeof console.log}}
*/
let original_log;
export default test({
compileOptions: {
dev: true
},
before_test() {
log = [];
original_log = console.log;
console.log = (...v) => {
log.push(...v);
};
},
after_test() {
console.log = original_log;
},
async test({ assert, target, component }) {
const [b1, b2] = target.querySelectorAll('button');
b1.click();
b2.click();
await Promise.resolve();
assert.deepEqual(log, [0, 'init', 1, 'update']);
}
});

@ -0,0 +1,9 @@
<script>
let x = $state(0);
let y = $state(0);
$inspect(x);
</script>
<button on:click={() => x++}>{x}</button>
<button on:click={() => y++}>{y}</button>

@ -35,6 +35,7 @@ for (const generate of ['client', 'server']) {
const source = fs.readFileSync(input, 'utf-8'); const source = fs.readFileSync(input, 'utf-8');
const output_js = `${cwd}/output/${generate}/${file}.js`; const output_js = `${cwd}/output/${generate}/${file}.js`;
const output_map = `${cwd}/output/${generate}/${file}.js.map`;
const output_css = `${cwd}/output/${generate}/${file}.css`; const output_css = `${cwd}/output/${generate}/${file}.css`;
mkdirp(path.dirname(output_js)); mkdirp(path.dirname(output_js));
@ -48,12 +49,17 @@ for (const generate of ['client', 'server']) {
} }
const compiled = compile(source, { const compiled = compile(source, {
dev: true,
filename: input, filename: input,
generate, generate,
runes: argv.runes runes: argv.runes
}); });
fs.writeFileSync(output_js, compiled.js.code); fs.writeFileSync(
output_js,
compiled.js.code + '\n//# sourceMappingURL=' + path.basename(output_map)
);
fs.writeFileSync(output_map, compiled.js.map.toString());
if (compiled.css) { if (compiled.css) {
fs.writeFileSync(output_css, compiled.css.code); fs.writeFileSync(output_css, compiled.css.code);
} }

@ -205,15 +205,17 @@
return { return {
from: word.from - 1, from: word.from - 1,
options: [ options: [
{ label: '$state', type: 'keyword', boost: 5 }, { label: '$state', type: 'keyword', boost: 10 },
{ label: '$props', type: 'keyword', boost: 4 }, { label: '$props', type: 'keyword', boost: 9 },
{ label: '$derived', type: 'keyword', boost: 3 }, { label: '$derived', type: 'keyword', boost: 8 },
snip('$effect(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 2 }), snip('$effect(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 7 }),
snip('$effect.pre(() => {\n\t${}\n});', { snip('$effect.pre(() => {\n\t${}\n});', {
label: '$effect.pre', label: '$effect.pre',
type: 'keyword', type: 'keyword',
boost: 1 boost: 6
}) }),
{ label: '$effect.active', type: 'keyword', boost: 5 },
{ label: '$inspect', type: 'keyword', boost: 4 }
] ]
}; };
} }

@ -182,7 +182,7 @@ async function resolve_from_pkg(pkg, subpath, uid, pkg_url_base) {
const [resolved] = const [resolved] =
resolve.exports(pkg, subpath, { resolve.exports(pkg, subpath, {
browser: true, browser: true,
conditions: ['svelte', 'production'] conditions: ['svelte', 'development']
}) ?? []; }) ?? [];
return resolved; return resolved;

@ -241,6 +241,50 @@ let { a, b, c, ...everythingElse } = $props<MyProps>();
Note that you can still use `export const` and `export function` to expose things to users of your component (if they're using `bind:this`, for example). Note that you can still use `export const` and `export function` to expose things to users of your component (if they're using `bind:this`, for example).
## `$inspect`
The `$inspect` rune is roughly equivalent to `console.log`, with the exception that it will re-run whenever its
argument changes. `$inspect` tracks reactive state deeply, meaning that updating something inside an object
or array using [fine-grained reactivity](/docs/fine-grained-reactivity) will cause it to re-fire. ([Demo:](/#H4sIAAAAAAAAE0WQ0W6DMAxFf8WKKhXUquyZAtIe9w1lEjS4ENU4EXFaTRH_Plq69fH6nutrOaqLIfQqP0XF7YgqV5_Oqb2SH_cQ_oYkuGhvw6Qfk8LryTipaq6FUEDbwAIlbLy0gslHevxzRvS-7fHtbQckstsnsTAbw96hliSuS_b_iTk9QpbB3RAtFntLeCDbw31AhuYJN2AnaF6BBvTQco81F9n7PC7OQcQyWNZk9LWMSQpltZbtdnP1xXrCEVmKbCWXVGHYBYGz4S6_tRSwjK-SGbJqecRoO3Mx2KlcpoDz9_wLBx9LikMBAAA=))
```svelte
<script>
let count = $state(0);
let message = $state('hello');
$inspect({ count, message }); // will console.log when `count` or `message` change
</script>
<button onclick={() => count++}>Increment</button>
<input bind:value={message} />
```
If a callback is also provided, it will be invoked instead of `console.log`. The first argument to the callback
is the current value. The second is either `"init"` or `"update"`. [Demo:](/#H4sIAAAAAAAAE0VP24qDMBD9lSEUqlTqPlsj7ON-w1qojWM3rE5CMmkpkn_fxFL26XBuw5lVTHpGL5rvVdCwoGjEp7WiEvy0mfg7zoyJexOcykrrldOWu556npFBmUAMEnaeB8biozwlJ3k7Td6i4mILVPDGfLgE2cGaUz3rCYqsgZQS9sGO6cq-fLs9j3gNtxu6E9Q1GAcXZcibGY_sBoWXKmuPn1S6o4OnCfAYiF_lmCHmQW39v5raa2A2BIbUrNWvXIttz7bvcIjdFymHCxK39SvZpf8XM-pJ4ygadgHjOf4B8TXIiDoBAAA=)
```svelte
<script>
let count = $state(0);
$inspect(count, (count, type) => {
if (type === 'update') {
debugger; // or `console.trace`, or whatever you want
}
});
</script>
<button onclick={() => count++}>Increment</button>
```
A convenient way to find the origin of some change is to pass `console.trace` as the second argument:
```js
// @errors: 2304
$inspect(stuff, console.trace);
```
> `$inspect` only works during development.
## How to opt in ## How to opt in
Current Svelte code will continue to work without any adjustments. Components using the Svelte 4 syntax can use components using runes and vice versa. Current Svelte code will continue to work without any adjustments. Components using the Svelte 4 syntax can use components using runes and vice versa.

Loading…
Cancel
Save