fix: make `$inspect` logs come from the callsite (#17001)

* fix: make `$inspect` logs come from the callsite

* default to showing stack trace

* reuse id

* DRY out, improve server-side inspect

* update docs

* ugh

* fix the dang tests

* ugh windows is there no punch bowl you won't poop in?

* argh

* how about this

* alright finally
pull/16992/head
Rich Harris 2 weeks ago committed by GitHub
parent 5bb28c4567
commit 4b32d6d8b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: make `$inspect` logs come from the callsite

@ -18,6 +18,8 @@ The `$inspect` rune is roughly equivalent to `console.log`, with the exception t
<input bind:value={message} /> <input bind:value={message} />
``` ```
On updates, a stack trace will be printed, making it easy to find the origin of a state change (unless you're in the playground, due to technical limitations).
## $inspect(...).with ## $inspect(...).with
`$inspect` returns a property `with`, which you can invoke with a callback, which will then be invoked instead of `console.log`. The first argument to the callback is either `"init"` or `"update"`; subsequent arguments are the values passed to `$inspect` ([demo](/playground/untitled#H4sIAAAAAAAACkVQ24qDMBD9lSEUqlTqPlsj7ON-w7pQG8c2VCchmVSK-O-bKMs-DefKYRYx6BG9qL4XQd2EohKf1opC8Nsm4F84MkbsTXAqMbVXTltuWmp5RAZlAjFIOHjuGLOP_BKVqB00eYuKs82Qn2fNjyxLtcWeyUE2sCRry3qATQIpJRyD7WPVMf9TW-7xFu53dBcoSzAOrsqQNyOe2XUKr0Xi5kcMvdDB2wSYO-I9vKazplV1-T-d6ltgNgSG1KjVUy7ZtmdbdjqtzRcphxMS1-XubOITJtPrQWMvKnYB15_1F7KKadA_AQAA)): `$inspect` returns a property `with`, which you can invoke with a callback, which will then be invoked instead of `console.log`. The first argument to the callback is either `"init"` or `"update"`; subsequent arguments are the values passed to `$inspect` ([demo](/playground/untitled#H4sIAAAAAAAACkVQ24qDMBD9lSEUqlTqPlsj7ON-w7pQG8c2VCchmVSK-O-bKMs-DefKYRYx6BG9qL4XQd2EohKf1opC8Nsm4F84MkbsTXAqMbVXTltuWmp5RAZlAjFIOHjuGLOP_BKVqB00eYuKs82Qn2fNjyxLtcWeyUE2sCRry3qATQIpJRyD7WPVMf9TW-7xFu53dBcoSzAOrsqQNyOe2XUKr0Xi5kcMvdDB2wSYO-I9vKazplV1-T-d6ltgNgSG1KjVUy7ZtmdbdjqtzRcphxMS1-XubOITJtPrQWMvKnYB15_1F7KKadA_AQAA)):
@ -36,13 +38,6 @@ The `$inspect` rune is roughly equivalent to `console.log`, with the exception t
<button onclick={() => count++}>Increment</button> <button onclick={() => count++}>Increment</button>
``` ```
A convenient way to find the origin of some change is to pass `console.trace` to `with`:
```js
// @errors: 2304
$inspect(stuff).with(console.trace);
```
## $inspect.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. 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.

@ -1,10 +1,10 @@
/** @import { CallExpression, Expression } from 'estree' */ /** @import { CallExpression, Expression, MemberExpression } from 'estree' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
import { dev, is_ignored } from '../../../../state.js'; import { dev, is_ignored } from '../../../../state.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js'; import { get_rune } from '../../../scope.js';
import { transform_inspect_rune } from '../../utils.js';
import { should_proxy } from '../utils.js'; import { should_proxy } from '../utils.js';
import { get_inspect_args } from '../../utils.js';
/** /**
* @param {CallExpression} node * @param {CallExpression} node
@ -73,7 +73,7 @@ export function CallExpression(node, context) {
case '$inspect': case '$inspect':
case '$inspect().with': case '$inspect().with':
return transform_inspect_rune(node, context); return transform_inspect_rune(rune, node, context);
} }
if ( if (
@ -104,3 +104,21 @@ export function CallExpression(node, context) {
context.next(); context.next();
} }
/**
* @param {'$inspect' | '$inspect().with'} rune
* @param {CallExpression} node
* @param {Context} context
*/
function transform_inspect_rune(rune, node, context) {
if (!dev) return b.empty;
const { args, inspector } = get_inspect_args(rune, node, context.visit);
// by passing an arrow function, the log appears to come from the `$inspect` callsite
// rather than the `inspect.js` file containing the utility
const id = b.id('$$args');
const fn = b.arrow([b.rest(id)], b.call(inspector, b.spread(id)));
return b.call('$.inspect', b.thunk(b.array(args)), fn, rune === '$inspect' && b.true);
}

@ -1,9 +1,9 @@
/** @import { CallExpression, Expression } from 'estree' */ /** @import { CallExpression, Expression, MemberExpression } from 'estree' */
/** @import { Context } from '../types.js' */ /** @import { Context } from '../types.js' */
import { is_ignored } from '../../../../state.js'; import { dev, is_ignored } from '../../../../state.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { get_rune } from '../../../scope.js'; import { get_rune } from '../../../scope.js';
import { transform_inspect_rune } from '../../utils.js'; import { get_inspect_args } from '../../utils.js';
/** /**
* @param {CallExpression} node * @param {CallExpression} node
@ -51,7 +51,13 @@ export function CallExpression(node, context) {
} }
if (rune === '$inspect' || rune === '$inspect().with') { if (rune === '$inspect' || rune === '$inspect().with') {
return transform_inspect_rune(node, context); if (!dev) return b.empty;
const { args, inspector } = get_inspect_args(rune, node, context.visit);
return rune === '$inspect'
? b.call(inspector, b.literal('$inspect('), ...args, b.literal(')'))
: b.call(inspector, b.literal('init'), ...args);
} }
context.next(); context.next();

@ -1,7 +1,7 @@
/** @import { Context } from 'zimmerframe' */ /** @import { Context } from 'zimmerframe' */
/** @import { TransformState } from './types.js' */ /** @import { TransformState } from './types.js' */
/** @import { AST, Binding, Namespace, ValidatedCompileOptions } from '#compiler' */ /** @import { AST, Binding, Namespace, ValidatedCompileOptions } from '#compiler' */
/** @import { Node, Expression, CallExpression } from 'estree' */ /** @import { Node, Expression, CallExpression, MemberExpression } from 'estree' */
import { import {
regex_ends_with_whitespaces, regex_ends_with_whitespaces,
regex_not_whitespace, regex_not_whitespace,
@ -452,30 +452,19 @@ export function determine_namespace_for_children(node, namespace) {
} }
/** /**
* @template {TransformState} T * @param {'$inspect' | '$inspect().with'} rune
* @param {CallExpression} node * @param {CallExpression} node
* @param {Context<any, T>} context * @param {(node: AST.SvelteNode) => AST.SvelteNode} visit
*/ */
export function transform_inspect_rune(node, context) { export function get_inspect_args(rune, node, visit) {
const { state, visit } = context; const call =
const as_fn = state.options.generate === 'client'; rune === '$inspect'
? node
if (!dev) return b.empty; : /** @type {CallExpression} */ (/** @type {MemberExpression} */ (node.callee).object);
if (node.callee.type === 'MemberExpression') { return {
const raw_inspect_args = /** @type {CallExpression} */ (node.callee.object).arguments; args: call.arguments.map((arg) => /** @type {Expression} */ (visit(arg))),
const inspect_args = inspector:
/** @type {Array<Expression>} */ rune === '$inspect' ? 'console.log' : /** @type {Expression} */ (visit(node.arguments[0]))
(raw_inspect_args.map((arg) => visit(arg))); };
const with_arg = /** @type {Expression} */ (visit(node.arguments[0]));
return b.call(
'$.inspect',
as_fn ? b.thunk(b.array(inspect_args)) : b.array(inspect_args),
with_arg
);
} else {
const arg = node.arguments.map((arg) => /** @type {Expression} */ (visit(arg)));
return b.call('$.inspect', as_fn ? b.thunk(b.array(arg)) : b.array(arg));
}
} }

@ -2,13 +2,14 @@ import { UNINITIALIZED } from '../../../constants.js';
import { snapshot } from '../../shared/clone.js'; import { snapshot } from '../../shared/clone.js';
import { inspect_effect, render_effect, validate_effect } from '../reactivity/effects.js'; import { inspect_effect, render_effect, validate_effect } from '../reactivity/effects.js';
import { untrack } from '../runtime.js'; import { untrack } from '../runtime.js';
import { get_stack } from './tracing.js';
/** /**
* @param {() => any[]} get_value * @param {() => any[]} get_value
* @param {Function} [inspector] * @param {Function} inspector
* @param {boolean} show_stack
*/ */
// eslint-disable-next-line no-console export function inspect(get_value, inspector, show_stack = false) {
export function inspect(get_value, inspector = console.log) {
validate_effect('$inspect'); validate_effect('$inspect');
let initial = true; let initial = true;
@ -28,7 +29,16 @@ export function inspect(get_value, inspector = console.log) {
var snap = snapshot(value, true, true); var snap = snapshot(value, true, true);
untrack(() => { untrack(() => {
inspector(initial ? 'init' : 'update', ...snap); if (show_stack) {
inspector(...snap);
if (!initial) {
// eslint-disable-next-line no-console
console.log(get_stack('UpdatedAt'));
}
} else {
inspector(initial ? 'init' : 'update', ...snap);
}
}); });
initial = false; initial = false;

@ -134,7 +134,16 @@ export function trace(label, fn) {
* @returns {Error & { stack: string } | null} * @returns {Error & { stack: string } | null}
*/ */
export function get_stack(label) { export function get_stack(label) {
// @ts-ignore stackTraceLimit doesn't exist everywhere
const limit = Error.stackTraceLimit;
// @ts-ignore
Error.stackTraceLimit = Infinity;
let error = Error(); let error = Error();
// @ts-ignore
Error.stackTraceLimit = limit;
const stack = error.stack; const stack = error.stack;
if (!stack) return null; if (!stack) return null;
@ -151,7 +160,7 @@ export function get_stack(label) {
if (line.includes('validate_each_keys')) { if (line.includes('validate_each_keys')) {
return null; return null;
} }
if (line.includes('svelte/src/internal')) { if (line.includes('svelte/src/internal') || line.includes('svelte\\src\\internal')) {
continue; continue;
} }
new_lines.push(line); new_lines.push(line);

@ -418,15 +418,6 @@ export function ensure_array_like(array_like_or_iterator) {
return []; return [];
} }
/**
* @param {any[]} args
* @param {Function} [inspect]
*/
// eslint-disable-next-line no-console
export function inspect(args, inspect = console.log) {
inspect('init', ...args);
}
/** /**
* @template V * @template V
* @param {() => V} get_value * @param {() => V} get_value

@ -197,6 +197,26 @@ export const fragments = /** @type {'html' | 'tree'} */ (process.env.FRAGMENTS)
export const async_mode = process.env.SVELTE_NO_ASYNC !== 'true'; export const async_mode = process.env.SVELTE_NO_ASYNC !== 'true';
/**
* @param {any[]} logs
*/
export function normalise_inspect_logs(logs) {
return logs.map((log) => {
if (log instanceof Error) {
const last_line = log.stack
?.trim()
.split('\n')
.filter((line) => !line.includes('at Module.get_stack'))[1];
const match = last_line && /(at .+) /.exec(last_line);
return match && match[1];
}
return log;
});
}
/** /**
* @param {any[]} logs * @param {any[]} logs
*/ */

@ -5,6 +5,6 @@ export default test({
dev: true dev: true
}, },
async test({ assert, logs }) { async test({ assert, logs }) {
assert.deepEqual(logs, ['init', 1, 'init', 1]); assert.deepEqual(logs, [1, 1]);
} }
}); });

@ -1,5 +1,6 @@
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
import { normalise_inspect_logs } from '../../../helpers.js';
export default test({ export default test({
compileOptions: { compileOptions: {
@ -13,6 +14,6 @@ export default test({
button?.click(); button?.click();
}); });
assert.deepEqual(logs, ['init', [1, 2, 3, 7], 'update', [2, 3, 7]]); assert.deepEqual(normalise_inspect_logs(logs), [[1, 2, 3, 7], [2, 3, 7], 'at Object.doSplice']);
} }
}); });

@ -1,3 +1,4 @@
import { normalise_inspect_logs } from '../../../helpers.js';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
@ -6,6 +7,6 @@ export default test({
}, },
async test({ assert, logs }) { async test({ assert, logs }) {
assert.deepEqual(logs, ['init', undefined, 'update', [{}]]); assert.deepEqual(normalise_inspect_logs(logs), [undefined, [{}], 'at $effect']);
} }
}); });

@ -1,5 +1,6 @@
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
import { normalise_inspect_logs } from '../../../helpers.js';
export default test({ export default test({
compileOptions: { compileOptions: {
@ -14,8 +15,7 @@ export default test({
}); });
assert.htmlEqual(target.innerHTML, `<button>update</button>\n1`); assert.htmlEqual(target.innerHTML, `<button>update</button>\n1`);
assert.deepEqual(logs, [ assert.deepEqual(normalise_inspect_logs(logs), [
'init',
{ {
data: { data: {
derived: 0, derived: 0,
@ -23,14 +23,14 @@ export default test({
}, },
derived: [] derived: []
}, },
'update',
{ {
data: { data: {
derived: 0, derived: 0,
list: [1] list: [1]
}, },
derived: [1] derived: [1]
} },
'at HTMLButtonElement.Main.button.__click'
]); ]);
} }
}); });

@ -1,5 +1,6 @@
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
import { normalise_inspect_logs } from '../../../helpers.js';
export default test({ export default test({
compileOptions: { compileOptions: {
@ -13,22 +14,19 @@ export default test({
button?.click(); button?.click();
}); });
assert.deepEqual(logs, [ assert.deepEqual(normalise_inspect_logs(logs), [
'init',
'0', '0',
true, true,
'init',
'1', '1',
false, false,
'init',
'2', '2',
false, false,
'update',
'0', '0',
false, false,
'update', 'at $effect',
'1', '1',
true true,
'at $effect'
]); ]);
} }
}); });

@ -1,5 +1,6 @@
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
import { normalise_inspect_logs } from '../../../helpers.js';
export default test({ export default test({
compileOptions: { compileOptions: {
@ -12,15 +13,13 @@ export default test({
btn2.click(); btn2.click();
flushSync(); flushSync();
assert.deepEqual(logs, [ assert.deepEqual(normalise_inspect_logs(logs), [
'init',
new Map(), new Map(),
'init',
new Set(), new Set(),
'update',
new Map([['a', 'a']]), new Map([['a', 'a']]),
'update', 'at SvelteMap.set',
new Set(['a']) new Set(['a']),
'at SvelteSet.add'
]); ]);
} }
}); });

@ -1,3 +1,4 @@
import { normalise_inspect_logs } from '../../../helpers.js';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
@ -11,6 +12,15 @@ export default test({
b2.click(); b2.click();
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(logs, ['init', 0, 0, 'update', 1, 0, 'update', 1, 1]); assert.deepEqual(normalise_inspect_logs(logs), [
0,
0,
1,
0,
'at HTMLButtonElement.<anonymous>',
1,
1,
'at HTMLButtonElement.<anonymous>'
]);
} }
}); });

@ -1,3 +1,4 @@
import { normalise_inspect_logs } from '../../../helpers.js';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
@ -6,6 +7,6 @@ export default test({
}, },
async test({ assert, logs }) { async test({ assert, logs }) {
assert.deepEqual(logs, ['init', 0, 'update', 1]); assert.deepEqual(normalise_inspect_logs(logs), [0, 1, 'at $effect']);
} }
}); });

@ -1,3 +1,4 @@
import { normalise_inspect_logs } from '../../../helpers.js';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
@ -10,13 +11,12 @@ export default test({
b1.click(); b1.click();
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(logs, [ assert.deepEqual(normalise_inspect_logs(logs), [
'init',
{ x: { count: 0 } }, { x: { count: 0 } },
[{ count: 0 }], [{ count: 0 }],
'update',
{ x: { count: 1 } }, { x: { count: 1 } },
[{ count: 1 }] [{ count: 1 }],
'at HTMLButtonElement.<anonymous>'
]); ]);
} }
}); });

@ -1,3 +1,4 @@
import { normalise_inspect_logs } from '../../../helpers.js';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
@ -10,6 +11,13 @@ export default test({
btn.click(); btn.click();
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(logs, ['init', {}, 'init', [], 'update', { x: 'hello' }, 'update', ['hello']]); assert.deepEqual(normalise_inspect_logs(logs), [
{},
[],
{ x: 'hello' },
'at HTMLButtonElement.on_click',
['hello'],
'at HTMLButtonElement.on_click'
]);
} }
}); });

@ -18,6 +18,6 @@ export default test({
}; };
b.a.b = b; b.a.b = b;
assert.deepEqual(logs, ['init', a, 'init', b]); assert.deepEqual(logs, [a, b]);
} }
}); });

@ -1,3 +1,4 @@
import { normalise_inspect_logs } from '../../../helpers.js';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
@ -11,6 +12,12 @@ export default test({
btn.click(); btn.click();
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(logs, ['init', [], 'update', [{}], 'update', [{}, {}]]); assert.deepEqual(normalise_inspect_logs(logs), [
[],
[{}],
'at HTMLButtonElement.on_click',
[{}, {}],
'at HTMLButtonElement.on_click'
]);
} }
}); });

@ -1,3 +1,4 @@
import { normalise_inspect_logs } from '../../../helpers.js';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
@ -11,6 +12,6 @@ export default test({
b2.click(); b2.click();
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(logs, ['init', 0, 'update', 1]); assert.deepEqual(normalise_inspect_logs(logs), [0, 1, 'at HTMLButtonElement.<anonymous>']);
} }
}); });

Loading…
Cancel
Save