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: *