feat: add $inspect.trace rune (#14290)

* feat: add $trace rune

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

* lint

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* more tweaks

* lint

* improve label for derived cached

* improve label for derived cached

* lint

* better stacks

* complete redesign

* fixes

* dead code

* dead code

* improve change detection

* rename rune

* lint

* lint

* fix bug

* tweaks

* Update packages/svelte/src/internal/client/dev/tracing.js

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* Update packages/svelte/src/internal/client/dev/tracing.js

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* Update packages/svelte/src/internal/client/dev/tracing.js

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* Update packages/svelte/src/internal/client/dev/tracing.js

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* todos

* add test + some docs

* changeset

* update messages

* address feedback

* address feedback

* limit to first statement of function

* remove unreachable trace_rune_duplicate error

* tweak message

* remove the expression statement, not the expression

* revert

* make label optional

* relax restriction on label - no longer necessary with new design

* update errors

* newline

* tweak

* add some docs

* fix playground

* fix playground

* tweak message when function runs outside an effect

* unused

* tweak

* handle async functions

* fail on generators

* regenerate, update docs

* better labelling

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/14715/head
Dominic Gannaway 1 week ago committed by GitHub
parent 64a32cec38
commit 5483495c8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: adds $inspect.trace rune

@ -42,3 +42,20 @@ A convenient way to find the origin of some change is to pass `console.trace` to
// @errors: 2304 // @errors: 2304
$inspect(stuff).with(console.trace); $inspect(stuff).with(console.trace);
``` ```
## $inspect.trace(...)
This rune, added in 5.14, causes the surrounding function to be _traced_ in development. Any time the function re-runs as part of an [effect]($effect) or a [derived]($derived), information will be printed to the console about which pieces of reactive state caused the effect to fire.
```svelte
<script>
import { doSomeWork } from './elsewhere';
$effect(() => {
+++$inspect.trace();+++
doSomeWork();
});
</script>
```
`$inspect.trace` takes an optional first argument which will be used as the label.

@ -442,6 +442,18 @@ Expected whitespace
Imports of `svelte/internal/*` are forbidden. It contains private runtime code which is subject to change without notice. If you're importing from `svelte/internal/*` to work around a limitation of Svelte, please open an issue at https://github.com/sveltejs/svelte and explain your use case Imports of `svelte/internal/*` are forbidden. It contains private runtime code which is subject to change without notice. If you're importing from `svelte/internal/*` to work around a limitation of Svelte, please open an issue at https://github.com/sveltejs/svelte and explain your use case
``` ```
### inspect_trace_generator
```
`$inspect.trace(...)` cannot be used inside a generator function
```
### inspect_trace_invalid_placement
```
`$inspect.trace(...)` must be the first statement of a function body
```
### invalid_arguments_usage ### invalid_arguments_usage
``` ```

@ -54,6 +54,14 @@
> Imports of `svelte/internal/*` are forbidden. It contains private runtime code which is subject to change without notice. If you're importing from `svelte/internal/*` to work around a limitation of Svelte, please open an issue at https://github.com/sveltejs/svelte and explain your use case > Imports of `svelte/internal/*` are forbidden. It contains private runtime code which is subject to change without notice. If you're importing from `svelte/internal/*` to work around a limitation of Svelte, please open an issue at https://github.com/sveltejs/svelte and explain your use case
## inspect_trace_generator
> `$inspect.trace(...)` cannot be used inside a generator function
## inspect_trace_invalid_placement
> `$inspect.trace(...)` must be the first statement of a function body
## invalid_arguments_usage ## invalid_arguments_usage
> The arguments keyword cannot be used within the template or at the top level of a component > The arguments keyword cannot be used within the template or at the top level of a component

@ -371,6 +371,25 @@ declare function $inspect<T extends any[]>(
...values: T ...values: T
): { with: (fn: (type: 'init' | 'update', ...values: T) => void) => void }; ): { with: (fn: (type: 'init' | 'update', ...values: T) => void) => void };
declare namespace $inspect {
/**
* Tracks which reactive state changes caused an effect to re-run. Must be the first
* statement of a function body. Example:
*
* ```svelte
* <script>
* let count = $state(0);
*
* $effect(() => {
* $inspect.trace('my effect');
*
* count;
* });
* </script>
*/
export function trace(name: string): void;
}
/** /**
* Retrieves the `this` reference of the custom element that contains this component. Example: * Retrieves the `this` reference of the custom element that contains this component. Example:
* *

@ -206,6 +206,24 @@ export function import_svelte_internal_forbidden(node) {
e(node, "import_svelte_internal_forbidden", `Imports of \`svelte/internal/*\` are forbidden. It contains private runtime code which is subject to change without notice. If you're importing from \`svelte/internal/*\` to work around a limitation of Svelte, please open an issue at https://github.com/sveltejs/svelte and explain your use case\nhttps://svelte.dev/e/import_svelte_internal_forbidden`); e(node, "import_svelte_internal_forbidden", `Imports of \`svelte/internal/*\` are forbidden. It contains private runtime code which is subject to change without notice. If you're importing from \`svelte/internal/*\` to work around a limitation of Svelte, please open an issue at https://github.com/sveltejs/svelte and explain your use case\nhttps://svelte.dev/e/import_svelte_internal_forbidden`);
} }
/**
* `$inspect.trace(...)` cannot be used inside a generator function
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function inspect_trace_generator(node) {
e(node, "inspect_trace_generator", `\`$inspect.trace(...)\` cannot be used inside a generator function\nhttps://svelte.dev/e/inspect_trace_generator`);
}
/**
* `$inspect.trace(...)` must be the first statement of a function body
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function inspect_trace_invalid_placement(node) {
e(node, "inspect_trace_invalid_placement", `\`$inspect.trace(...)\` must be the first statement of a function body\nhttps://svelte.dev/e/inspect_trace_invalid_placement`);
}
/** /**
* The arguments keyword cannot be used within the template or at the top level of a component * The arguments keyword cannot be used within the template or at the top level of a component
* @param {null | number | NodeLike} node * @param {null | number | NodeLike} node

@ -1,11 +1,12 @@
/** @import { CallExpression, VariableDeclarator } from 'estree' */ /** @import { ArrowFunctionExpression, CallExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, VariableDeclarator } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
import { get_rune } from '../../scope.js'; import { get_rune } from '../../scope.js';
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
import { get_parent, unwrap_optional } from '../../../utils/ast.js'; import { get_parent, unwrap_optional } from '../../../utils/ast.js';
import { is_pure, is_safe_identifier } from './shared/utils.js'; import { is_pure, is_safe_identifier } from './shared/utils.js';
import { mark_subtree_dynamic } from './shared/fragment.js'; import { dev, locate_node, source } from '../../../state.js';
import * as b from '../../../utils/builders.js';
/** /**
* @param {CallExpression} node * @param {CallExpression} node
@ -136,6 +137,45 @@ export function CallExpression(node, context) {
break; break;
case '$inspect.trace': {
if (node.arguments.length > 1) {
e.rune_invalid_arguments_length(node, rune, 'zero or one arguments');
}
const grand_parent = context.path.at(-2);
const fn = context.path.at(-3);
if (
parent.type !== 'ExpressionStatement' ||
grand_parent?.type !== 'BlockStatement' ||
!(
fn?.type === 'FunctionDeclaration' ||
fn?.type === 'FunctionExpression' ||
fn?.type === 'ArrowFunctionExpression'
) ||
grand_parent.body[0] !== parent
) {
e.inspect_trace_invalid_placement(node);
}
if (fn.generator) {
e.inspect_trace_generator(node);
}
if (dev) {
if (node.arguments[0]) {
context.state.scope.tracing = b.thunk(/** @type {Expression} */ (node.arguments[0]));
} else {
const label = get_function_label(context.path.slice(0, -2)) ?? 'trace';
const loc = `(${locate_node(fn)})`;
context.state.scope.tracing = b.thunk(b.literal(label + ' ' + loc));
}
}
break;
}
case '$state.snapshot': case '$state.snapshot':
if (node.arguments.length !== 1) { if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument'); e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
@ -182,3 +222,31 @@ export function CallExpression(node, context) {
} }
} }
} }
/**
* @param {AST.SvelteNode[]} nodes
*/
function get_function_label(nodes) {
const fn = /** @type {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} */ (
nodes.at(-1)
);
if ((fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression') && fn.id != null) {
return fn.id.name;
}
const parent = nodes.at(-2);
if (!parent) return;
if (parent.type === 'CallExpression') {
return source.slice(parent.callee.start, parent.callee.end) + '(...)';
}
if (parent.type === 'Property' && !parent.computed) {
return /** @type {Identifier} */ (parent.key).name;
}
if (parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') {
return parent.id.name;
}
}

@ -8,7 +8,7 @@ import {
get_attribute_expression, get_attribute_expression,
is_event_attribute is_event_attribute
} from '../../../../utils/ast.js'; } from '../../../../utils/ast.js';
import { dev, filename, is_ignored, locator } from '../../../../state.js'; import { dev, filename, is_ignored, locate_node, locator } from '../../../../state.js';
import { build_proxy_reassignment, should_proxy } from '../utils.js'; import { build_proxy_reassignment, should_proxy } from '../utils.js';
import { visit_assignment_expression } from '../../shared/assignments.js'; import { visit_assignment_expression } from '../../shared/assignments.js';
@ -183,9 +183,6 @@ function build_assignment(operator, left, right, context) {
if (left.type === 'MemberExpression' && should_transform) { if (left.type === 'MemberExpression' && should_transform) {
const callee = callees[operator]; const callee = callees[operator];
const loc = /** @type {Location} */ (locator(/** @type {number} */ (left.start)));
const location = `${filename}:${loc.line}:${loc.column}`;
return /** @type {Expression} */ ( return /** @type {Expression} */ (
context.visit( context.visit(
b.call( b.call(
@ -197,7 +194,7 @@ function build_assignment(operator, left, right, context) {
: b.literal(/** @type {Identifier} */ (left.property).name) : b.literal(/** @type {Identifier} */ (left.property).name)
), ),
right, right,
b.literal(location) b.literal(locate_node(left))
) )
) )
); );

@ -1,6 +1,7 @@
/** @import { BlockStatement } from 'estree' */ /** @import { ArrowFunctionExpression, BlockStatement, CallExpression, Expression, FunctionDeclaration, FunctionExpression, Statement } from 'estree' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import { add_state_transformers } from './shared/declarations.js'; import { add_state_transformers } from './shared/declarations.js';
import * as b from '../../../../utils/builders.js';
/** /**
* @param {BlockStatement} node * @param {BlockStatement} node
@ -8,5 +9,24 @@ import { add_state_transformers } from './shared/declarations.js';
*/ */
export function BlockStatement(node, context) { export function BlockStatement(node, context) {
add_state_transformers(context); add_state_transformers(context);
const tracing = context.state.scope.tracing;
if (tracing !== null) {
const parent =
/** @type {ArrowFunctionExpression | FunctionDeclaration | FunctionExpression} */ (
context.path.at(-1)
);
const is_async = parent.async;
const call = b.call(
'$.trace',
/** @type {Expression} */ (tracing),
b.thunk(b.block(node.body.map((n) => /** @type {Statement} */ (context.visit(n)))), is_async)
);
return b.block([b.return(is_async ? b.await(call) : call)]);
}
context.next(); context.next();
} }

@ -20,6 +20,10 @@ export function ExpressionStatement(node, context) {
return b.stmt(expr); return b.stmt(expr);
} }
if (rune === '$inspect.trace') {
return b.empty;
}
} }
context.next(); context.next();

@ -27,6 +27,7 @@ export function VariableDeclaration(node, context) {
rune === '$effect.tracking' || rune === '$effect.tracking' ||
rune === '$effect.root' || rune === '$effect.root' ||
rune === '$inspect' || rune === '$inspect' ||
rune === '$inspect.trace' ||
rune === '$state.snapshot' || rune === '$state.snapshot' ||
rune === '$host' rune === '$host'
) { ) {

@ -10,7 +10,12 @@ import { get_rune } from '../../../scope.js';
export function ExpressionStatement(node, context) { export function ExpressionStatement(node, context) {
const rune = get_rune(node.expression, context.state.scope); const rune = get_rune(node.expression, context.state.scope);
if (rune === '$effect' || rune === '$effect.pre' || rune === '$effect.root') { if (
rune === '$effect' ||
rune === '$effect.pre' ||
rune === '$effect.root' ||
rune === '$inspect.trace'
) {
return b.empty; return b.empty;
} }

@ -58,6 +58,12 @@ export class Scope {
*/ */
function_depth = 0; function_depth = 0;
/**
* If tracing of reactive dependencies is enabled for this scope
* @type {null | Expression}
*/
tracing = null;
/** /**
* *
* @param {ScopeRoot} root * @param {ScopeRoot} root

@ -1,6 +1,8 @@
/** @import { Location } from 'locate-character' */
/** @import { CompileOptions } from './types' */ /** @import { CompileOptions } from './types' */
/** @import { AST, Warning } from '#compiler' */ /** @import { AST, Warning } from '#compiler' */
import { getLocator } from 'locate-character'; import { getLocator } from 'locate-character';
import { sanitize_location } from '../utils.js';
/** @typedef {{ start?: number, end?: number }} NodeLike */ /** @typedef {{ start?: number, end?: number }} NodeLike */
@ -28,6 +30,14 @@ export let dev;
export let locator = getLocator('', { offsetLine: 1 }); export let locator = getLocator('', { offsetLine: 1 });
/**
* @param {AST.SvelteNode & { start?: number | undefined }} node
*/
export function locate_node(node) {
const loc = /** @type {Location} */ (locator(/** @type {number} */ (node.start)));
return `${sanitize_location(filename)}:${loc?.line}:${loc.column}`;
}
/** @type {NonNullable<CompileOptions['warningFilter']>} */ /** @type {NonNullable<CompileOptions['warningFilter']>} */
export let warning_filter; export let warning_filter;

@ -1,6 +1,6 @@
import { sanitize_location } from '../../../utils.js';
import { untrack } from '../runtime.js'; import { untrack } from '../runtime.js';
import * as w from '../warnings.js'; import * as w from '../warnings.js';
import { sanitize_location } from './location.js';
/** /**
* *

@ -1,25 +0,0 @@
import { DEV } from 'esm-env';
import { FILENAME } from '../../../constants.js';
import { dev_current_component_function } from '../runtime.js';
/**
*
* @param {number} [line]
* @param {number} [column]
*/
export function get_location(line, column) {
if (!DEV || line === undefined) return undefined;
var filename = dev_current_component_function?.[FILENAME];
var location = filename && `${filename}:${line}:${column}`;
return sanitize_location(location);
}
/**
* Prevent devtools trying to make `location` a clickable link by inserting a zero-width space
* @param {string | undefined} location
*/
export function sanitize_location(location) {
return location?.replace(/\//g, '/\u200b');
}

@ -0,0 +1,180 @@
/** @import { Derived, Reaction, Signal, Value } from '#client' */
import { UNINITIALIZED } from '../../../constants.js';
import { snapshot } from '../../shared/clone.js';
import { define_property } from '../../shared/utils.js';
import { DERIVED, STATE_SYMBOL } from '../constants.js';
import { effect_tracking } from '../reactivity/effects.js';
import { active_reaction, captured_signals, set_captured_signals, untrack } from '../runtime.js';
/** @type { any } */
export let tracing_expressions = null;
/**
* @param { Value } signal
* @param { { read: Error[] } } [entry]
*/
function log_entry(signal, entry) {
const debug = signal.debug;
const value = signal.v;
if (value === UNINITIALIZED) {
return;
}
if (debug) {
var previous_captured_signals = captured_signals;
var captured = new Set();
set_captured_signals(captured);
try {
untrack(() => {
debug();
});
} finally {
set_captured_signals(previous_captured_signals);
}
if (captured.size > 0) {
for (const dep of captured) {
log_entry(dep);
}
return;
}
}
const type = (signal.f & DERIVED) !== 0 ? '$derived' : '$state';
const current_reaction = /** @type {Reaction} */ (active_reaction);
const status =
signal.version > current_reaction.version || current_reaction.version === 0 ? 'dirty' : 'clean';
// eslint-disable-next-line no-console
console.groupCollapsed(
`%c${type}`,
status !== 'clean'
? 'color: CornflowerBlue; font-weight: bold'
: 'color: grey; font-weight: bold',
typeof value === 'object' && STATE_SYMBOL in value ? snapshot(value, true) : value
);
if (type === '$derived') {
const deps = new Set(/** @type {Derived} */ (signal).deps);
for (const dep of deps) {
log_entry(dep);
}
}
if (signal.created) {
// eslint-disable-next-line no-console
console.log(signal.created);
}
if (signal.updated) {
// eslint-disable-next-line no-console
console.log(signal.updated);
}
const read = entry?.read;
if (read && read.length > 0) {
for (var stack of read) {
// eslint-disable-next-line no-console
console.log(stack);
}
}
// eslint-disable-next-line no-console
console.groupEnd();
}
/**
* @template T
* @param {() => string} label
* @param {() => T} fn
*/
export function trace(label, fn) {
var previously_tracing_expressions = tracing_expressions;
try {
tracing_expressions = { entries: new Map(), reaction: active_reaction };
var start = performance.now();
var value = fn();
var time = (performance.now() - start).toFixed(2);
if (!effect_tracking()) {
// eslint-disable-next-line no-console
console.log(`${label()} %cran outside of an effect (${time}ms)`, 'color: grey');
} else if (tracing_expressions.entries.size === 0) {
// eslint-disable-next-line no-console
console.log(`${label()} %cno reactive dependencies (${time}ms)`, 'color: grey');
} else {
// eslint-disable-next-line no-console
console.group(`${label()} %c(${time}ms)`, 'color: grey');
var entries = tracing_expressions.entries;
tracing_expressions = null;
for (const [signal, entry] of entries) {
log_entry(signal, entry);
}
// eslint-disable-next-line no-console
console.groupEnd();
}
if (previously_tracing_expressions !== null) {
for (const [signal, entry] of tracing_expressions.entries) {
var prev_entry = previously_tracing_expressions.get(signal);
if (prev_entry === undefined) {
previously_tracing_expressions.set(signal, entry);
} else {
prev_entry.read.push(...entry.read);
}
}
}
return value;
} finally {
tracing_expressions = previously_tracing_expressions;
}
}
/**
* @param {string} label
*/
export function get_stack(label) {
let error = Error();
const stack = error.stack;
if (stack) {
const lines = stack.split('\n');
const new_lines = ['\n'];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === 'Error') {
continue;
}
if (line.includes('validate_each_keys')) {
return null;
}
if (line.includes('svelte/src/internal')) {
continue;
}
new_lines.push(line);
}
if (new_lines.length === 1) {
return null;
}
define_property(error, 'stack', {
value: new_lines.join('\n')
});
define_property(error, 'name', {
// 'Error' suffix is required for stack traces to be rendered properly
value: `${label}Error`
});
}
return error;
}

@ -36,6 +36,7 @@ import { array_from, is_array } from '../../../shared/utils.js';
import { INERT } from '../../constants.js'; import { INERT } from '../../constants.js';
import { queue_micro_task } from '../task.js'; import { queue_micro_task } from '../task.js';
import { active_effect, active_reaction } from '../../runtime.js'; import { active_effect, active_reaction } from '../../runtime.js';
import { DEV } from 'esm-env';
/** /**
* The row of a keyed each block that is currently updating. We track this * The row of a keyed each block that is currently updating. We track this
@ -191,7 +192,18 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var value = array[i]; var value = array[i];
var key = get_key(value, i); var key = get_key(value, i);
item = create_item(hydrate_node, state, prev, null, value, key, i, render_fn, flags); item = create_item(
hydrate_node,
state,
prev,
null,
value,
key,
i,
render_fn,
flags,
get_collection
);
state.items.set(key, item); state.items.set(key, item);
prev = item; prev = item;
@ -205,7 +217,16 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
if (!hydrating) { if (!hydrating) {
var effect = /** @type {Effect} */ (active_reaction); var effect = /** @type {Effect} */ (active_reaction);
reconcile(array, state, anchor, render_fn, flags, (effect.f & INERT) !== 0, get_key); reconcile(
array,
state,
anchor,
render_fn,
flags,
(effect.f & INERT) !== 0,
get_key,
get_collection
);
} }
if (fallback_fn !== null) { if (fallback_fn !== null) {
@ -251,9 +272,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
* @param {number} flags * @param {number} flags
* @param {boolean} is_inert * @param {boolean} is_inert
* @param {(value: V, index: number) => any} get_key * @param {(value: V, index: number) => any} get_key
* @param {() => V[]} get_collection
* @returns {void} * @returns {void}
*/ */
function reconcile(array, state, anchor, render_fn, flags, is_inert, get_key) { function reconcile(array, state, anchor, render_fn, flags, is_inert, get_key, get_collection) {
var is_animated = (flags & EACH_IS_ANIMATED) !== 0; var is_animated = (flags & EACH_IS_ANIMATED) !== 0;
var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0; var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0;
@ -319,7 +341,8 @@ function reconcile(array, state, anchor, render_fn, flags, is_inert, get_key) {
key, key,
i, i,
render_fn, render_fn,
flags flags,
get_collection
); );
items.set(key, prev); items.set(key, prev);
@ -486,9 +509,21 @@ function update_item(item, value, index, type) {
* @param {number} index * @param {number} index
* @param {(anchor: Node, item: V | Source<V>, index: number | Value<number>) => void} render_fn * @param {(anchor: Node, item: V | Source<V>, index: number | Value<number>) => void} render_fn
* @param {number} flags * @param {number} flags
* @param {() => V[]} get_collection
* @returns {EachItem} * @returns {EachItem}
*/ */
function create_item(anchor, state, prev, next, value, key, index, render_fn, flags) { function create_item(
anchor,
state,
prev,
next,
value,
key,
index,
render_fn,
flags,
get_collection
) {
var previous_each_item = current_each_item; var previous_each_item = current_each_item;
var reactive = (flags & EACH_ITEM_REACTIVE) !== 0; var reactive = (flags & EACH_ITEM_REACTIVE) !== 0;
var mutable = (flags & EACH_ITEM_IMMUTABLE) === 0; var mutable = (flags & EACH_ITEM_IMMUTABLE) === 0;
@ -496,6 +531,16 @@ function create_item(anchor, state, prev, next, value, key, index, render_fn, fl
var v = reactive ? (mutable ? mutable_source(value) : source(value)) : value; var v = reactive ? (mutable ? mutable_source(value) : source(value)) : value;
var i = (flags & EACH_INDEX_REACTIVE) === 0 ? index : source(index); var i = (flags & EACH_INDEX_REACTIVE) === 0 ? index : source(index);
if (DEV && reactive) {
// For tracing purposes, we need to link the source signal we create with the
// collection + index so that tracing works as intended
/** @type {Value} */ (v).debug = () => {
var collection_index = typeof i === 'number' ? index : i.v;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
get_collection()[collection_index];
};
}
/** @type {EachItem} */ /** @type {EachItem} */
var item = { var item = {
i, i,

@ -5,11 +5,10 @@ import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from '../hydr
import { create_fragment_from_html } from '../reconciler.js'; import { create_fragment_from_html } from '../reconciler.js';
import { assign_nodes } from '../template.js'; import { assign_nodes } from '../template.js';
import * as w from '../../warnings.js'; import * as w from '../../warnings.js';
import { hash } from '../../../../utils.js'; import { hash, sanitize_location } from '../../../../utils.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { dev_current_component_function } from '../../runtime.js'; import { dev_current_component_function } from '../../runtime.js';
import { get_first_child, get_next_sibling } from '../operations.js'; import { get_first_child, get_next_sibling } from '../operations.js';
import { sanitize_location } from '../../dev/location.js';
/** /**
* @param {Element} element * @param {Element} element

@ -12,6 +12,7 @@ export {
skip_ownership_validation skip_ownership_validation
} from './dev/ownership.js'; } from './dev/ownership.js';
export { check_target, legacy_api } from './dev/legacy.js'; export { check_target, legacy_api } from './dev/legacy.js';
export { trace } from './dev/tracing.js';
export { inspect } from './dev/inspect.js'; export { inspect } from './dev/inspect.js';
export { await_block as await } from './dom/blocks/await.js'; export { await_block as await } from './dom/blocks/await.js';
export { if_block as if } from './dom/blocks/if.js'; export { if_block as if } from './dom/blocks/if.js';

@ -13,6 +13,7 @@ import { source, set } from './reactivity/sources.js';
import { STATE_SYMBOL, STATE_SYMBOL_METADATA } from './constants.js'; import { STATE_SYMBOL, STATE_SYMBOL_METADATA } from './constants.js';
import { UNINITIALIZED } from '../../constants.js'; import { UNINITIALIZED } from '../../constants.js';
import * as e from './errors.js'; import * as e from './errors.js';
import { get_stack } from './dev/tracing.js';
/** /**
* @template T * @template T
@ -22,6 +23,11 @@ import * as e from './errors.js';
* @returns {T} * @returns {T}
*/ */
export function proxy(value, parent = null, prev) { export function proxy(value, parent = null, prev) {
/** @type {Error | null} */
var stack = null;
if (DEV) {
stack = get_stack('CreatedAt');
}
// if non-proxyable, or is already a proxy, return `value` // if non-proxyable, or is already a proxy, return `value`
if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) { if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {
return value; return value;
@ -41,7 +47,7 @@ export function proxy(value, parent = null, prev) {
if (is_proxied_array) { if (is_proxied_array) {
// We need to create the length source eagerly to ensure that // We need to create the length source eagerly to ensure that
// mutations to the array are properly synced with our proxy // mutations to the array are properly synced with our proxy
sources.set('length', source(/** @type {any[]} */ (value).length)); sources.set('length', source(/** @type {any[]} */ (value).length, stack));
} }
/** @type {ProxyMetadata} */ /** @type {ProxyMetadata} */
@ -87,7 +93,7 @@ export function proxy(value, parent = null, prev) {
var s = sources.get(prop); var s = sources.get(prop);
if (s === undefined) { if (s === undefined) {
s = source(descriptor.value); s = source(descriptor.value, stack);
sources.set(prop, s); sources.set(prop, s);
} else { } else {
set(s, proxy(descriptor.value, metadata)); set(s, proxy(descriptor.value, metadata));
@ -101,7 +107,7 @@ export function proxy(value, parent = null, prev) {
if (s === undefined) { if (s === undefined) {
if (prop in target) { if (prop in target) {
sources.set(prop, source(UNINITIALIZED)); sources.set(prop, source(UNINITIALIZED, stack));
} }
} else { } else {
// When working with arrays, we need to also ensure we update the length when removing // When working with arrays, we need to also ensure we update the length when removing
@ -135,7 +141,7 @@ export function proxy(value, parent = null, prev) {
// create a source, but only if it's an own property and not a prototype property // create a source, but only if it's an own property and not a prototype property
if (s === undefined && (!exists || get_descriptor(target, prop)?.writable)) { if (s === undefined && (!exists || get_descriptor(target, prop)?.writable)) {
s = source(proxy(exists ? target[prop] : UNINITIALIZED, metadata)); s = source(proxy(exists ? target[prop] : UNINITIALIZED, metadata), stack);
sources.set(prop, s); sources.set(prop, s);
} }
@ -203,7 +209,7 @@ export function proxy(value, parent = null, prev) {
(active_effect !== null && (!has || get_descriptor(target, prop)?.writable)) (active_effect !== null && (!has || get_descriptor(target, prop)?.writable))
) { ) {
if (s === undefined) { if (s === undefined) {
s = source(has ? proxy(target[prop], metadata) : UNINITIALIZED); s = source(has ? proxy(target[prop], metadata) : UNINITIALIZED, stack);
sources.set(prop, s); sources.set(prop, s);
} }
@ -230,7 +236,7 @@ export function proxy(value, parent = null, prev) {
// If the item exists in the original, we need to create a uninitialized source, // If the item exists in the original, we need to create a uninitialized source,
// else a later read of the property would result in a source being created with // else a later read of the property would result in a source being created with
// the value of the original item at that index. // the value of the original item at that index.
other_s = source(UNINITIALIZED); other_s = source(UNINITIALIZED, stack);
sources.set(i + '', other_s); sources.set(i + '', other_s);
} }
} }
@ -242,7 +248,7 @@ export function proxy(value, parent = null, prev) {
// object property before writing to that property. // object property before writing to that property.
if (s === undefined) { if (s === undefined) {
if (!has || get_descriptor(target, prop)?.writable) { if (!has || get_descriptor(target, prop)?.writable) {
s = source(undefined); s = source(undefined, stack);
set(s, proxy(value, metadata)); set(s, proxy(value, metadata));
sources.set(prop, s); sources.set(prop, s);
} }

@ -24,6 +24,7 @@ import { equals, safe_equals } from './equality.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import { destroy_effect } from './effects.js'; import { destroy_effect } from './effects.js';
import { inspect_effects, set_inspect_effects } from './sources.js'; import { inspect_effects, set_inspect_effects } from './sources.js';
import { get_stack } from '../dev/tracing.js';
/** /**
* @template V * @template V
@ -61,6 +62,10 @@ export function derived(fn) {
parent: parent_derived ?? active_effect parent: parent_derived ?? active_effect
}; };
if (DEV) {
signal.created = get_stack('CreatedAt');
}
if (parent_derived !== null) { if (parent_derived !== null) {
(parent_derived.children ??= []).push(signal); (parent_derived.children ??= []).push(signal);
} }

@ -1,4 +1,4 @@
/** @import { ComponentContext, ComponentContextLegacy, Derived, Effect, Reaction, TemplateNode, TransitionManager } from '#client' */ /** @import { ComponentContext, ComponentContextLegacy, Derived, Effect, TemplateNode, TransitionManager } from '#client' */
import { import {
check_dirtiness, check_dirtiness,
component_context, component_context,
@ -16,8 +16,7 @@ import {
set_is_flushing_effect, set_is_flushing_effect,
set_signal_status, set_signal_status,
untrack, untrack,
skip_reaction, skip_reaction
capture_signals
} from '../runtime.js'; } from '../runtime.js';
import { import {
DIRTY, DIRTY,
@ -40,13 +39,10 @@ import {
} from '../constants.js'; } from '../constants.js';
import { set } from './sources.js'; import { set } from './sources.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import * as w from '../warnings.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js'; import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js'; import { get_next_sibling } from '../dom/operations.js';
import { destroy_derived } from './deriveds.js'; import { destroy_derived } from './deriveds.js';
import { FILENAME } from '../../../constants.js';
import { get_location } from '../dev/location.js';
/** /**
* @param {'$effect' | '$effect.pre' | '$inspect'} rune * @param {'$effect' | '$effect.pre' | '$inspect'} rune

@ -33,6 +33,7 @@ import {
} from '../constants.js'; } from '../constants.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import { legacy_mode_flag } from '../../flags/index.js'; import { legacy_mode_flag } from '../../flags/index.js';
import { get_stack } from '../dev/tracing.js';
export let inspect_effects = new Set(); export let inspect_effects = new Set();
@ -46,16 +47,25 @@ export function set_inspect_effects(v) {
/** /**
* @template V * @template V
* @param {V} v * @param {V} v
* @param {Error | null} [stack]
* @returns {Source<V>} * @returns {Source<V>}
*/ */
export function source(v) { export function source(v, stack) {
return { /** @type {Value} */
var signal = {
f: 0, // TODO ideally we could skip this altogether, but it causes type errors f: 0, // TODO ideally we could skip this altogether, but it causes type errors
v, v,
reactions: null, reactions: null,
equals, equals,
version: 0 version: 0
}; };
if (DEV) {
signal.created = stack ?? get_stack('CreatedAt');
signal.debug = null;
}
return signal;
} }
/** /**
@ -160,6 +170,10 @@ export function internal_set(source, value) {
source.v = value; source.v = value;
source.version = increment_version(); source.version = increment_version();
if (DEV) {
source.updated = get_stack('UpdatedAt');
}
mark_reactions(source, DIRTY); mark_reactions(source, DIRTY);
// If the current signal is running for the first time, it won't have any // If the current signal is running for the first time, it won't have any

@ -14,6 +14,10 @@ export interface Value<V = unknown> extends Signal {
equals: Equals; equals: Equals;
/** The latest value for this signal */ /** The latest value for this signal */
v: V; v: V;
/** Dev only */
created?: Error | null;
updated?: Error | null;
debug?: null | (() => void);
} }
export interface Reaction extends Signal { export interface Reaction extends Signal {

@ -35,6 +35,7 @@ import * as e from './errors.js';
import { lifecycle_outside_component } from '../shared/errors.js'; import { lifecycle_outside_component } from '../shared/errors.js';
import { FILENAME } from '../../constants.js'; import { FILENAME } from '../../constants.js';
import { legacy_mode_flag } from '../flags/index.js'; import { legacy_mode_flag } from '../flags/index.js';
import { tracing_expressions, get_stack } from './dev/tracing.js';
const FLUSH_MICROTASK = 0; const FLUSH_MICROTASK = 0;
const FLUSH_SYNC = 1; const FLUSH_SYNC = 1;
@ -136,6 +137,11 @@ export let skip_reaction = false;
/** @type {Set<Value> | null} */ /** @type {Set<Value> | null} */
export let captured_signals = null; export let captured_signals = null;
/** @param {Set<Value> | null} value */
export function set_captured_signals(value) {
captured_signals = value;
}
// Handling runtime component context // Handling runtime component context
/** @type {ComponentContext | null} */ /** @type {ComponentContext | null} */
export let component_context = null; export let component_context = null;
@ -356,7 +362,7 @@ export function handle_error(error, effect, previous_effect, component_context)
new_lines.push(line); new_lines.push(line);
} }
define_property(error, 'stack', { define_property(error, 'stack', {
value: error.stack + new_lines.join('\n') value: new_lines.join('\n')
}); });
} }
@ -908,6 +914,27 @@ export function get(signal) {
} }
} }
if (
DEV &&
tracing_expressions !== null &&
active_reaction !== null &&
tracing_expressions.reaction === active_reaction
) {
// Used when mapping state between special blocks like `each`
if (signal.debug) {
signal.debug();
} else if (signal.created) {
var entry = tracing_expressions.entries.get(signal);
if (entry === undefined) {
entry = { read: [] };
tracing_expressions.entries.set(signal, entry);
}
entry.read.push(get_stack('TracedAt'));
}
}
return signal.v; return signal.v;
} }

@ -431,6 +431,7 @@ const RUNES = /** @type {const} */ ([
'$effect.root', '$effect.root',
'$inspect', '$inspect',
'$inspect().with', '$inspect().with',
'$inspect.trace',
'$host' '$host'
]); ]);
@ -449,3 +450,11 @@ const RAW_TEXT_ELEMENTS = /** @type {const} */ (['textarea', 'script', 'style',
export function is_raw_text_element(name) { export function is_raw_text_element(name) {
return RAW_TEXT_ELEMENTS.includes(/** @type {RAW_TEXT_ELEMENTS[number]} */ (name)); return RAW_TEXT_ELEMENTS.includes(/** @type {RAW_TEXT_ELEMENTS[number]} */ (name));
} }
/**
* Prevent devtools trying to make `location` a clickable link by inserting a zero-width space
* @param {string | undefined} location
*/
export function sanitize_location(location) {
return location?.replace(/\//g, '/\u200b');
}

@ -0,0 +1,19 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
async test({ assert, target, logs }) {
assert.deepEqual(logs, []);
const [b1, b2] = target.querySelectorAll('button');
b1.click();
b2.click();
await Promise.resolve();
assert.ok(logs[0].stack.startsWith('Error:') && logs[0].stack.includes('HTMLButtonElement.'));
assert.deepEqual(logs[1], 1);
}
});

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

@ -1,19 +1,41 @@
import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
/**
* @param {any[]} logs
*/
function normalise_trace_logs(logs) {
let normalised = [];
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
if (typeof log === 'string' && log.includes('%c')) {
const split = log.split('%c');
normalised.push((split[0].length !== 0 ? split[0] : split[1]).trim());
i++;
} else if (log instanceof Error) {
continue;
} else {
normalised.push(log);
}
}
return normalised;
}
export default test({ export default test({
compileOptions: { compileOptions: {
dev: true dev: true
}, },
async test({ assert, target, logs }) { test({ assert, target, logs }) {
assert.deepEqual(logs, []); assert.deepEqual(normalise_trace_logs(logs), ['effect', '$derived', 0, '$state', 0]);
logs.length = 0;
const [b1, b2] = target.querySelectorAll('button'); const button = target.querySelector('button');
b1.click(); button?.click();
b2.click(); flushSync();
await Promise.resolve();
assert.ok(logs[0].stack.startsWith('Error:') && logs[0].stack.includes('HTMLButtonElement.')); assert.deepEqual(normalise_trace_logs(logs), ['effect', '$derived', 2, '$state', 1]);
assert.deepEqual(logs[1], 1);
} }
}); });

@ -1,11 +1,12 @@
<script> <script>
let x = $state(0); let count = $state(0);
let y = $state(0); let double = $derived(count * 2);
$inspect(x).with((type, x) => { $effect(() => {
if (type === 'update') console.log(new Error(), x); $inspect.trace('effect');
});
double;
})
</script> </script>
<button on:click={() => x++}>{x}</button> <button onclick={() => count++}>{double}</button>
<button on:click={() => y++}>{y}</button>

@ -3029,6 +3029,25 @@ declare function $inspect<T extends any[]>(
...values: T ...values: T
): { with: (fn: (type: 'init' | 'update', ...values: T) => void) => void }; ): { with: (fn: (type: 'init' | 'update', ...values: T) => void) => void };
declare namespace $inspect {
/**
* Tracks which reactive state changes caused an effect to re-run. Must be the first
* statement of a function body. Example:
*
* ```svelte
* <script>
* let count = $state(0);
*
* $effect(() => {
* $inspect.trace('my effect');
*
* count;
* });
* </script>
*/
export function trace(name: string): void;
}
/** /**
* Retrieves the `this` reference of the custom element that contains this component. Example: * Retrieves the `this` reference of the custom element that contains this component. Example:
* *

Loading…
Cancel
Save