diff --git a/.changeset/tame-ants-peel.md b/.changeset/tame-ants-peel.md
new file mode 100644
index 000000000000..ed7a69f8ccf2
--- /dev/null
+++ b/.changeset/tame-ants-peel.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: adds $inspect.trace rune
diff --git a/documentation/docs/02-runes/07-$inspect.md b/documentation/docs/02-runes/07-$inspect.md
index 1afe25deca81..ff3d64757b6b 100644
--- a/documentation/docs/02-runes/07-$inspect.md
+++ b/documentation/docs/02-runes/07-$inspect.md
@@ -42,3 +42,20 @@ A convenient way to find the origin of some change is to pass `console.trace` to
// @errors: 2304
$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
+
+```
+
+`$inspect.trace` takes an optional first argument which will be used as the label.
diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md
index d726d25fa188..298363f78d38 100644
--- a/documentation/docs/98-reference/.generated/compile-errors.md
+++ b/documentation/docs/98-reference/.generated/compile-errors.md
@@ -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
```
+### 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
```
diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md
index 69007bfb5919..fa851cec89ea 100644
--- a/packages/svelte/messages/compile-errors/script.md
+++ b/packages/svelte/messages/compile-errors/script.md
@@ -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
+## 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
> The arguments keyword cannot be used within the template or at the top level of a component
diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts
index fe87e446d602..4e98eb82eb9b 100644
--- a/packages/svelte/src/ambient.d.ts
+++ b/packages/svelte/src/ambient.d.ts
@@ -371,6 +371,25 @@ declare function $inspect(
...values: T
): { 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
+ *
+ */
+ export function trace(name: string): void;
+}
+
/**
* Retrieves the `this` reference of the custom element that contains this component. Example:
*
diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js
index fd509eb3ab75..9ea13e811e5f 100644
--- a/packages/svelte/src/compiler/errors.js
+++ b/packages/svelte/src/compiler/errors.js
@@ -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`);
}
+/**
+ * `$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
* @param {null | number | NodeLike} node
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
index 99f18ace646b..6a301726b106 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
@@ -1,11 +1,12 @@
-/** @import { CallExpression, VariableDeclarator } from 'estree' */
+/** @import { ArrowFunctionExpression, CallExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, VariableDeclarator } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { get_rune } from '../../scope.js';
import * as e from '../../../errors.js';
import { get_parent, unwrap_optional } from '../../../utils/ast.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
@@ -136,6 +137,45 @@ export function CallExpression(node, context) {
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':
if (node.arguments.length !== 1) {
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;
+ }
+}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js
index 5ac33e629e82..3d8b9aaa968e 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js
@@ -8,7 +8,7 @@ import {
get_attribute_expression,
is_event_attribute
} 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 { visit_assignment_expression } from '../../shared/assignments.js';
@@ -183,9 +183,6 @@ function build_assignment(operator, left, right, context) {
if (left.type === 'MemberExpression' && should_transform) {
const callee = callees[operator];
- const loc = /** @type {Location} */ (locator(/** @type {number} */ (left.start)));
- const location = `${filename}:${loc.line}:${loc.column}`;
-
return /** @type {Expression} */ (
context.visit(
b.call(
@@ -197,7 +194,7 @@ function build_assignment(operator, left, right, context) {
: b.literal(/** @type {Identifier} */ (left.property).name)
),
right,
- b.literal(location)
+ b.literal(locate_node(left))
)
)
);
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BlockStatement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BlockStatement.js
index 502fbd471e6a..5bfc8a3ef999 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/BlockStatement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/BlockStatement.js
@@ -1,6 +1,7 @@
-/** @import { BlockStatement } from 'estree' */
+/** @import { ArrowFunctionExpression, BlockStatement, CallExpression, Expression, FunctionDeclaration, FunctionExpression, Statement } from 'estree' */
/** @import { ComponentContext } from '../types' */
import { add_state_transformers } from './shared/declarations.js';
+import * as b from '../../../../utils/builders.js';
/**
* @param {BlockStatement} node
@@ -8,5 +9,24 @@ import { add_state_transformers } from './shared/declarations.js';
*/
export function BlockStatement(node, 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();
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ExpressionStatement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ExpressionStatement.js
index 22e509eb7246..0424e595be1e 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ExpressionStatement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ExpressionStatement.js
@@ -20,6 +20,10 @@ export function ExpressionStatement(node, context) {
return b.stmt(expr);
}
+
+ if (rune === '$inspect.trace') {
+ return b.empty;
+ }
}
context.next();
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
index 04685b66bd0c..afb90bbec7f9 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
@@ -27,6 +27,7 @@ export function VariableDeclaration(node, context) {
rune === '$effect.tracking' ||
rune === '$effect.root' ||
rune === '$inspect' ||
+ rune === '$inspect.trace' ||
rune === '$state.snapshot' ||
rune === '$host'
) {
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ExpressionStatement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ExpressionStatement.js
index 9db887aada55..00d0dba5dafd 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ExpressionStatement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ExpressionStatement.js
@@ -10,7 +10,12 @@ import { get_rune } from '../../../scope.js';
export function ExpressionStatement(node, context) {
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;
}
diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js
index 93be67b56cb5..3536dd6a1865 100644
--- a/packages/svelte/src/compiler/phases/scope.js
+++ b/packages/svelte/src/compiler/phases/scope.js
@@ -58,6 +58,12 @@ export class Scope {
*/
function_depth = 0;
+ /**
+ * If tracing of reactive dependencies is enabled for this scope
+ * @type {null | Expression}
+ */
+ tracing = null;
+
/**
*
* @param {ScopeRoot} root
diff --git a/packages/svelte/src/compiler/state.js b/packages/svelte/src/compiler/state.js
index 2840c6601bdd..1db3db917f9d 100644
--- a/packages/svelte/src/compiler/state.js
+++ b/packages/svelte/src/compiler/state.js
@@ -1,6 +1,8 @@
+/** @import { Location } from 'locate-character' */
/** @import { CompileOptions } from './types' */
/** @import { AST, Warning } from '#compiler' */
import { getLocator } from 'locate-character';
+import { sanitize_location } from '../utils.js';
/** @typedef {{ start?: number, end?: number }} NodeLike */
@@ -28,6 +30,14 @@ export let dev;
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} */
export let warning_filter;
diff --git a/packages/svelte/src/internal/client/dev/assign.js b/packages/svelte/src/internal/client/dev/assign.js
index fc1b647f78ca..d9ef7497d5d8 100644
--- a/packages/svelte/src/internal/client/dev/assign.js
+++ b/packages/svelte/src/internal/client/dev/assign.js
@@ -1,6 +1,6 @@
+import { sanitize_location } from '../../../utils.js';
import { untrack } from '../runtime.js';
import * as w from '../warnings.js';
-import { sanitize_location } from './location.js';
/**
*
diff --git a/packages/svelte/src/internal/client/dev/location.js b/packages/svelte/src/internal/client/dev/location.js
deleted file mode 100644
index b2e16c371e66..000000000000
--- a/packages/svelte/src/internal/client/dev/location.js
+++ /dev/null
@@ -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');
-}
diff --git a/packages/svelte/src/internal/client/dev/tracing.js b/packages/svelte/src/internal/client/dev/tracing.js
new file mode 100644
index 000000000000..1426e9efc9ed
--- /dev/null
+++ b/packages/svelte/src/internal/client/dev/tracing.js
@@ -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;
+}
diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js
index d62c575391bc..9e6405594059 100644
--- a/packages/svelte/src/internal/client/dom/blocks/each.js
+++ b/packages/svelte/src/internal/client/dom/blocks/each.js
@@ -36,6 +36,7 @@ import { array_from, is_array } from '../../../shared/utils.js';
import { INERT } from '../../constants.js';
import { queue_micro_task } from '../task.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
@@ -191,7 +192,18 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var value = array[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);
prev = item;
@@ -205,7 +217,16 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
if (!hydrating) {
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) {
@@ -251,9 +272,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
* @param {number} flags
* @param {boolean} is_inert
* @param {(value: V, index: number) => any} get_key
+ * @param {() => V[]} get_collection
* @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 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,
i,
render_fn,
- flags
+ flags,
+ get_collection
);
items.set(key, prev);
@@ -486,9 +509,21 @@ function update_item(item, value, index, type) {
* @param {number} index
* @param {(anchor: Node, item: V | Source, index: number | Value) => void} render_fn
* @param {number} flags
+ * @param {() => V[]} get_collection
* @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 reactive = (flags & EACH_ITEM_REACTIVE) !== 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 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} */
var item = {
i,
diff --git a/packages/svelte/src/internal/client/dom/blocks/html.js b/packages/svelte/src/internal/client/dom/blocks/html.js
index 2f815b454e05..04ab0aee87f5 100644
--- a/packages/svelte/src/internal/client/dom/blocks/html.js
+++ b/packages/svelte/src/internal/client/dom/blocks/html.js
@@ -5,11 +5,10 @@ import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from '../hydr
import { create_fragment_from_html } from '../reconciler.js';
import { assign_nodes } from '../template.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_current_component_function } from '../../runtime.js';
import { get_first_child, get_next_sibling } from '../operations.js';
-import { sanitize_location } from '../../dev/location.js';
/**
* @param {Element} element
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index b706e52a5378..f22c33babc52 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -12,6 +12,7 @@ export {
skip_ownership_validation
} from './dev/ownership.js';
export { check_target, legacy_api } from './dev/legacy.js';
+export { trace } from './dev/tracing.js';
export { inspect } from './dev/inspect.js';
export { await_block as await } from './dom/blocks/await.js';
export { if_block as if } from './dom/blocks/if.js';
diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js
index a8fd18c0dd9e..d74f55866f20 100644
--- a/packages/svelte/src/internal/client/proxy.js
+++ b/packages/svelte/src/internal/client/proxy.js
@@ -13,6 +13,7 @@ import { source, set } from './reactivity/sources.js';
import { STATE_SYMBOL, STATE_SYMBOL_METADATA } from './constants.js';
import { UNINITIALIZED } from '../../constants.js';
import * as e from './errors.js';
+import { get_stack } from './dev/tracing.js';
/**
* @template T
@@ -22,6 +23,11 @@ import * as e from './errors.js';
* @returns {T}
*/
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 (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {
return value;
@@ -41,7 +47,7 @@ export function proxy(value, parent = null, prev) {
if (is_proxied_array) {
// We need to create the length source eagerly to ensure that
// 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} */
@@ -87,7 +93,7 @@ export function proxy(value, parent = null, prev) {
var s = sources.get(prop);
if (s === undefined) {
- s = source(descriptor.value);
+ s = source(descriptor.value, stack);
sources.set(prop, s);
} else {
set(s, proxy(descriptor.value, metadata));
@@ -101,7 +107,7 @@ export function proxy(value, parent = null, prev) {
if (s === undefined) {
if (prop in target) {
- sources.set(prop, source(UNINITIALIZED));
+ sources.set(prop, source(UNINITIALIZED, stack));
}
} else {
// 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
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);
}
@@ -203,7 +209,7 @@ export function proxy(value, parent = null, prev) {
(active_effect !== null && (!has || get_descriptor(target, prop)?.writable))
) {
if (s === undefined) {
- s = source(has ? proxy(target[prop], metadata) : UNINITIALIZED);
+ s = source(has ? proxy(target[prop], metadata) : UNINITIALIZED, stack);
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,
// else a later read of the property would result in a source being created with
// the value of the original item at that index.
- other_s = source(UNINITIALIZED);
+ other_s = source(UNINITIALIZED, stack);
sources.set(i + '', other_s);
}
}
@@ -242,7 +248,7 @@ export function proxy(value, parent = null, prev) {
// object property before writing to that property.
if (s === undefined) {
if (!has || get_descriptor(target, prop)?.writable) {
- s = source(undefined);
+ s = source(undefined, stack);
set(s, proxy(value, metadata));
sources.set(prop, s);
}
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index a679d307c40d..8c1a3a652b5b 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -24,6 +24,7 @@ import { equals, safe_equals } from './equality.js';
import * as e from '../errors.js';
import { destroy_effect } from './effects.js';
import { inspect_effects, set_inspect_effects } from './sources.js';
+import { get_stack } from '../dev/tracing.js';
/**
* @template V
@@ -61,6 +62,10 @@ export function derived(fn) {
parent: parent_derived ?? active_effect
};
+ if (DEV) {
+ signal.created = get_stack('CreatedAt');
+ }
+
if (parent_derived !== null) {
(parent_derived.children ??= []).push(signal);
}
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 05c210978d86..bf890627f7e0 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -1,4 +1,4 @@
-/** @import { ComponentContext, ComponentContextLegacy, Derived, Effect, Reaction, TemplateNode, TransitionManager } from '#client' */
+/** @import { ComponentContext, ComponentContextLegacy, Derived, Effect, TemplateNode, TransitionManager } from '#client' */
import {
check_dirtiness,
component_context,
@@ -16,8 +16,7 @@ import {
set_is_flushing_effect,
set_signal_status,
untrack,
- skip_reaction,
- capture_signals
+ skip_reaction
} from '../runtime.js';
import {
DIRTY,
@@ -40,13 +39,10 @@ import {
} from '../constants.js';
import { set } from './sources.js';
import * as e from '../errors.js';
-import * as w from '../warnings.js';
import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.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
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 4bbd470d08c8..24dd837772b6 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -33,6 +33,7 @@ import {
} from '../constants.js';
import * as e from '../errors.js';
import { legacy_mode_flag } from '../../flags/index.js';
+import { get_stack } from '../dev/tracing.js';
export let inspect_effects = new Set();
@@ -46,16 +47,25 @@ export function set_inspect_effects(v) {
/**
* @template V
* @param {V} v
+ * @param {Error | null} [stack]
* @returns {Source}
*/
-export function source(v) {
- return {
+export function source(v, stack) {
+ /** @type {Value} */
+ var signal = {
f: 0, // TODO ideally we could skip this altogether, but it causes type errors
v,
reactions: null,
equals,
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.version = increment_version();
+ if (DEV) {
+ source.updated = get_stack('UpdatedAt');
+ }
+
mark_reactions(source, DIRTY);
// If the current signal is running for the first time, it won't have any
diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts
index 395070fedd1b..13e14414b519 100644
--- a/packages/svelte/src/internal/client/reactivity/types.d.ts
+++ b/packages/svelte/src/internal/client/reactivity/types.d.ts
@@ -14,6 +14,10 @@ export interface Value extends Signal {
equals: Equals;
/** The latest value for this signal */
v: V;
+ /** Dev only */
+ created?: Error | null;
+ updated?: Error | null;
+ debug?: null | (() => void);
}
export interface Reaction extends Signal {
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 5d53ca336079..ff8eaa8ef9bc 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -35,6 +35,7 @@ import * as e from './errors.js';
import { lifecycle_outside_component } from '../shared/errors.js';
import { FILENAME } from '../../constants.js';
import { legacy_mode_flag } from '../flags/index.js';
+import { tracing_expressions, get_stack } from './dev/tracing.js';
const FLUSH_MICROTASK = 0;
const FLUSH_SYNC = 1;
@@ -136,6 +137,11 @@ export let skip_reaction = false;
/** @type {Set | null} */
export let captured_signals = null;
+/** @param {Set | null} value */
+export function set_captured_signals(value) {
+ captured_signals = value;
+}
+
// Handling runtime component context
/** @type {ComponentContext | null} */
export let component_context = null;
@@ -356,7 +362,7 @@ export function handle_error(error, effect, previous_effect, component_context)
new_lines.push(line);
}
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;
}
diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js
index 932440800795..e42721b4f4b4 100644
--- a/packages/svelte/src/utils.js
+++ b/packages/svelte/src/utils.js
@@ -431,6 +431,7 @@ const RUNES = /** @type {const} */ ([
'$effect.root',
'$inspect',
'$inspect().with',
+ '$inspect.trace',
'$host'
]);
@@ -449,3 +450,11 @@ const RAW_TEXT_ELEMENTS = /** @type {const} */ (['textarea', 'script', 'style',
export function is_raw_text_element(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');
+}
diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-console-trace/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-console-trace/_config.js
new file mode 100644
index 000000000000..7bb71adafb57
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/inspect-console-trace/_config.js
@@ -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);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-console-trace/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-console-trace/main.svelte
new file mode 100644
index 000000000000..d42742c5b5d5
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/inspect-console-trace/main.svelte
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-trace/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-trace/_config.js
index 7bb71adafb57..517e11d74e06 100644
--- a/packages/svelte/tests/runtime-runes/samples/inspect-trace/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/inspect-trace/_config.js
@@ -1,19 +1,41 @@
+import { flushSync } from 'svelte';
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({
compileOptions: {
dev: true
},
- async test({ assert, target, logs }) {
- assert.deepEqual(logs, []);
+ test({ assert, target, logs }) {
+ assert.deepEqual(normalise_trace_logs(logs), ['effect', '$derived', 0, '$state', 0]);
+
+ logs.length = 0;
- const [b1, b2] = target.querySelectorAll('button');
- b1.click();
- b2.click();
- await Promise.resolve();
+ const button = target.querySelector('button');
+ button?.click();
+ flushSync();
- assert.ok(logs[0].stack.startsWith('Error:') && logs[0].stack.includes('HTMLButtonElement.'));
- assert.deepEqual(logs[1], 1);
+ assert.deepEqual(normalise_trace_logs(logs), ['effect', '$derived', 2, '$state', 1]);
}
});
diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-trace/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-trace/main.svelte
index d42742c5b5d5..99c30a07bf2f 100644
--- a/packages/svelte/tests/runtime-runes/samples/inspect-trace/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/inspect-trace/main.svelte
@@ -1,11 +1,12 @@
-
-
+
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index dca0fd99f0b7..671f68bff72a 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -3029,6 +3029,25 @@ declare function $inspect(
...values: T
): { 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
+ *
+ */
+ export function trace(name: string): void;
+}
+
/**
* Retrieves the `this` reference of the custom element that contains this component. Example:
*