feat: add $trace rune

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP

WIP
pull/14290/head
Dominic Gannaway 2 years ago
parent ac9b7de058
commit 8e320fe178

@ -399,3 +399,5 @@ declare function $inspect<T extends any[]>(
* https://svelte.dev/docs/svelte/$host
*/
declare function $host<El extends HTMLElement = HTMLElement>(): El;
declare function $trace(name: string): void;

@ -5,6 +5,7 @@ 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 { dev } from '../../../state.js';
/**
* @param {CallExpression} node
@ -135,6 +136,28 @@ export function CallExpression(node, context) {
break;
case '$trace':
if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
}
if (node.arguments[0].type !== 'Literal' || typeof node.arguments[0].value !== 'string') {
throw new Error('TODO: $track requires a string argument');
}
if (parent.type !== 'ExpressionStatement' || context.path.at(-2)?.type !== 'BlockStatement') {
throw new Error('TODO: $track must be inside a block statement');
}
if (context.state.scope.tracing) {
throw new Error('TODO: $track must only be used once within the same block statement');
}
if (dev) {
// TODO should we validate if tracing is already enabled in this or a parent scope?
context.state.scope.tracing = node.arguments[0].value;
}
break;
case '$state.snapshot':
if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');

@ -17,6 +17,7 @@ import { BindDirective } from './visitors/BindDirective.js';
import { BlockStatement } from './visitors/BlockStatement.js';
import { BreakStatement } from './visitors/BreakStatement.js';
import { CallExpression } from './visitors/CallExpression.js';
import { NewExpression } from './visitors/NewExpression.js';
import { ClassBody } from './visitors/ClassBody.js';
import { Comment } from './visitors/Comment.js';
import { Component } from './visitors/Component.js';
@ -91,6 +92,7 @@ const visitors = {
BlockStatement,
BreakStatement,
CallExpression,
NewExpression,
ClassBody,
Comment,
Component,
@ -134,14 +136,16 @@ const visitors = {
/**
* @param {ComponentAnalysis} analysis
* @param {string} source
* @param {ValidatedCompileOptions} options
* @returns {ESTree.Program}
*/
export function client_component(analysis, options) {
export function client_component(analysis, source, options) {
/** @type {ComponentClientTransformState} */
const state = {
analysis,
options,
source: source.split('\n'),
scope: analysis.module.scope,
scopes: analysis.module.scopes,
is_instance: false,
@ -163,6 +167,7 @@ export function client_component(analysis, options) {
private_state: new Map(),
transform: {},
in_constructor: false,
trace_dependencies: false,
// these are set inside the `Fragment` visitor, and cannot be used until then
before_init: /** @type {any} */ (null),
@ -643,20 +648,23 @@ export function client_component(analysis, options) {
/**
* @param {Analysis} analysis
* @param {string} source
* @param {ValidatedModuleCompileOptions} options
* @returns {ESTree.Program}
*/
export function client_module(analysis, options) {
export function client_module(analysis, source, options) {
/** @type {ClientTransformState} */
const state = {
analysis,
options,
source: source.split('\n'),
scope: analysis.module.scope,
scopes: analysis.module.scopes,
public_state: new Map(),
private_state: new Map(),
transform: {},
in_constructor: false
in_constructor: false,
trace_dependencies: false
};
const module = /** @type {ESTree.Program} */ (

@ -23,6 +23,10 @@ export interface ClientTransformState extends TransformState {
*/
readonly in_constructor: boolean;
readonly source: string[];
readonly trace_dependencies: boolean;
readonly transform: Record<
string,
{

@ -351,3 +351,50 @@ export function is_inlinable_expression(node_or_nodes, state) {
}
return has_expression_tag;
}
/**
* @param {Expression} node
* @param {Expression} expression
* @param {ClientTransformState} state
*/
export function trace(node, expression, state) {
const loc = node.loc;
const source = state.source;
let code = '';
if (loc) {
const start = loc.start;
const end = loc.end;
if (start.line === end.line) {
code = source[start.line - 1].slice(start.column, end.column);
} else {
for (let i = start.line; i < end.line + 1; i++) {
const loc = source[i - 1];
if (i === start.line) {
code += loc.slice(start.column) + '\n';
} else if (i === end.line) {
code += loc.slice(0, end.column);
} else {
code += loc + '\n';
}
}
}
} else if (node.start !== undefined && node.end !== undefined) {
code = source.join('\n').slice(node.start, node.end);
} else {
return expression;
}
return b.call(
'$.trace',
b.thunk(expression),
b.literal(code),
node.type === 'CallExpression' ||
node.type === 'MemberExpression' ||
node.type === 'NewExpression'
? b.literal(true)
: undefined
);
}

@ -1,6 +1,7 @@
/** @import { BlockStatement } from 'estree' */
/** @import { BlockStatement, 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,28 @@ 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) {
return b.block([
b.return(
b.call(
'$.log_trace',
b.thunk(
b.block(
node.body.map(
(n) =>
/** @type {Statement} */ (
context.visit(n, { ...context.state, trace_dependencies: true })
)
)
)
),
b.literal(tracing)
)
)
]);
}
context.next();
}

@ -4,6 +4,7 @@ import { dev, is_ignored } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
import { get_rune } from '../../../scope.js';
import { transform_inspect_rune } from '../../utils.js';
import { trace } from '../utils.js';
/**
* @param {CallExpression} node
@ -33,6 +34,9 @@ export function CallExpression(node, context) {
case '$inspect':
case '$inspect().with':
return transform_inspect_rune(node, context);
case '$trace':
return b.empty;
}
if (
@ -58,5 +62,17 @@ export function CallExpression(node, context) {
);
}
if (dev) {
return trace(
node,
{
...node,
callee: /** @type {Expression} */ (context.visit(node.callee)),
arguments: node.arguments.map((arg) => /** @type {Expression} */ (context.visit(arg)))
},
context.state
);
}
context.next();
}

@ -2,7 +2,8 @@
/** @import { Context } from '../types' */
import is_reference from 'is-reference';
import * as b from '../../../../utils/builders.js';
import { build_getter } from '../utils.js';
import { build_getter, trace } from '../utils.js';
import { dev } from '../../../../state.js';
/**
* @param {Identifier} node
@ -10,6 +11,7 @@ import { build_getter } from '../utils.js';
*/
export function Identifier(node, context) {
const parent = /** @type {Node} */ (context.path.at(-1));
let transformed;
if (is_reference(node, parent)) {
if (node.name === '$$props') {
@ -32,10 +34,18 @@ export function Identifier(node, context) {
grand_parent?.type !== 'AssignmentExpression' &&
grand_parent?.type !== 'UpdateExpression'
) {
return b.id('$$props');
transformed = b.id('$$props');
}
}
return build_getter(node, context.state);
if (!transformed) {
transformed = build_getter(node, context.state);
}
}
if (transformed && transformed !== node && dev) {
return trace(node, transformed, context.state);
}
return transformed;
}

@ -1,19 +1,46 @@
/** @import { MemberExpression } from 'estree' */
/** @import { MemberExpression, Expression, Super, PrivateIdentifier } from 'estree' */
/** @import { Context } from '../types' */
import { dev } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
import { trace } from '../utils.js';
/**
* @param {MemberExpression} node
* @param {Context} context
*/
export function MemberExpression(node, context) {
let transformed;
// rewrite `this.#foo` as `this.#foo.v` inside a constructor
if (node.property.type === 'PrivateIdentifier') {
const field = context.state.private_state.get(node.property.name);
if (field) {
return context.state.in_constructor ? b.member(node, 'v') : b.call('$.get', node);
transformed = context.state.in_constructor ? b.member(node, 'v') : b.call('$.get', node);
}
}
const parent = context.path.at(-1);
if (
dev &&
// Bail out of tracing members if they're used as calees to avoid context issues
(parent?.type !== 'CallExpression' || parent.callee !== node) &&
parent?.type !== 'BindDirective' &&
parent?.type !== 'AssignmentExpression' &&
parent?.type !== 'UpdateExpression' &&
parent?.type !== 'Component'
) {
return trace(
node,
transformed || {
...node,
object: /** @type {Expression | Super} */ (context.visit(node.object)),
property: /** @type {Expression | PrivateIdentifier} */ (context.visit(node.property))
},
context.state
);
} else if (transformed) {
return transformed;
}
context.next();
}

@ -0,0 +1,24 @@
/** @import { NewExpression, Expression } from 'estree' */
/** @import { Context } from '../types' */
import { dev } from '../../../../state.js';
import { trace } from '../utils.js';
/**
* @param {NewExpression} node
* @param {Context} context
*/
export function NewExpression(node, context) {
if (dev) {
return trace(
node,
{
...node,
callee: /** @type {Expression} */ (context.visit(node.callee)),
arguments: node.arguments.map((arg) => /** @type {Expression} */ (context.visit(arg)))
},
context.state
);
}
context.next();
}

@ -28,7 +28,8 @@ export function VariableDeclaration(node, context) {
rune === '$effect.root' ||
rune === '$inspect' ||
rune === '$state.snapshot' ||
rune === '$host'
rune === '$host' ||
rune === '$trace'
) {
if (init != null && is_hoisted_function(init)) {
context.state.hoisted.push(

@ -30,7 +30,7 @@ export function transform_component(analysis, source, options) {
const program =
options.generate === 'server'
? server_component(analysis, options)
: client_component(analysis, options);
: client_component(analysis, source, options);
const js_source_name = get_source_name(options.filename, options.outputFilename, 'input.svelte');
const js = print(program, {
@ -79,7 +79,7 @@ export function transform_module(analysis, source, options) {
const program =
options.generate === 'server'
? server_module(analysis, options)
: client_module(analysis, options);
: client_module(analysis, source, options);
const basename = options.filename.split(/[/\\]/).at(-1);
if (program.body.length > 0) {

@ -37,5 +37,9 @@ export function CallExpression(node, context) {
return transform_inspect_rune(node, context);
}
if (rune === '$trace') {
return b.empty;
}
context.next();
}

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

@ -0,0 +1,197 @@
import { snapshot } from '../../shared/clone.js';
import { define_property } from '../../shared/utils.js';
import { STATE_SYMBOL } from '../constants.js';
import { captured_signals, set_captured_signals } from '../runtime.js';
export const NOT_REACTIVE = 0;
export const REACTIVE_UNCHANGED = 1;
export const REACTIVE_CHANGED = 2;
/** @type { { changed: boolean, label: string, time: number, sub: any, stacks: any[], value: any }[] | null } */
export let tracing_expressions = null;
/** @type { 0 | 1 | 2 } */
export let tracing_expression_reactive = NOT_REACTIVE;
/**
* @param {any} expressions
*/
function log_expressions(expressions) {
for (let expression of expressions) {
const val = expression.value;
const label = expression.label;
const time = expression.time;
const changed = expression.changed;
if (time) {
// eslint-disable-next-line no-console
console.groupCollapsed(
`%c${label} %c(${time.toFixed(2)}ms)`,
changed ? 'color: CornflowerBlue; font-weight: bold' : 'color: grey; font-weight: bold',
'color: grey',
val && typeof val === 'object' && STATE_SYMBOL in val ? snapshot(val, true) : val
);
} else {
// eslint-disable-next-line no-console
console.groupCollapsed(
`%c${label}`,
changed ? 'color: CornflowerBlue; font-weight: bold' : 'color: grey; font-weight: bold',
val && typeof val === 'object' && STATE_SYMBOL in val ? snapshot(val, true) : val
);
}
if (expression.sub) {
log_expressions(expression.sub);
}
for (var [name, stack] of expression.stacks) {
// eslint-disable-next-line no-console
console.groupCollapsed('%c' + name + ' stack', 'color: white; font-weight: normal;');
// eslint-disable-next-line no-console
console.log(stack);
// eslint-disable-next-line no-console
console.groupEnd();
}
// eslint-disable-next-line no-console
console.groupEnd();
}
}
/**
* @template T
* @param {() => T} fn
* @param {string} label
*/
export function log_trace(fn, label) {
var previously_tracing_expressions = tracing_expressions;
try {
tracing_expressions = [];
var start = performance.now();
var value = fn();
var time = (performance.now() - start).toFixed(2);
if (tracing_expressions.length > 0) {
// eslint-disable-next-line no-console
console.group(`${label} %c(${time}ms)`, 'color: grey');
log_expressions(tracing_expressions);
// eslint-disable-next-line no-console
console.groupEnd();
} else {
// eslint-disable-next-line no-console
console.log(`${label} %cno reactive dependencies (${time}ms)`, 'color: grey');
}
if (previously_tracing_expressions !== null) {
previously_tracing_expressions.push(...tracing_expressions);
}
return value;
} finally {
tracing_expressions = previously_tracing_expressions;
}
}
/**
* @template T
* @param {() => T} fn
* @param {boolean} [computed]
* @param {string} label
*/
export function trace(fn, label, computed) {
// If we aren't capturing the trace, just return the value
if (tracing_expressions === null) {
return fn();
}
var previously_tracing_expressions = tracing_expressions;
var previously_tracing_expression_reactive = tracing_expression_reactive;
var previous_captured_signals = captured_signals;
var signals = new Set();
set_captured_signals(signals);
try {
tracing_expression_reactive = NOT_REACTIVE;
tracing_expressions = [];
var value,
time = 0;
if (computed) {
var start = performance.now();
value = fn();
time = performance.now() - start;
} else {
value = fn();
}
if (tracing_expressions !== null) {
var read_stack = ['read', get_stack()];
if (tracing_expression_reactive !== NOT_REACTIVE) {
var write_stack;
if (signals.size === 1) {
write_stack = Array.from(signals)[0].stack;
}
tracing_expressions.push({
changed: tracing_expression_reactive === REACTIVE_CHANGED,
label,
value,
time,
stacks: [read_stack, write_stack],
sub: null
});
if (previously_tracing_expressions !== null) {
previously_tracing_expressions.push(...tracing_expressions);
}
} else if (tracing_expressions.length !== 0) {
previously_tracing_expressions.push({
changed: tracing_expressions.some((e) => e.changed),
label,
value,
time,
stacks: [read_stack],
sub: tracing_expressions
});
}
}
return value;
} finally {
tracing_expressions = previously_tracing_expressions;
tracing_expression_reactive = previously_tracing_expression_reactive;
set_captured_signals(previous_captured_signals);
}
}
/**
* @param {0 | 1 | 2} value
*/
export function set_tracing_expression_reactive(value) {
tracing_expression_reactive = value;
}
export function get_stack() {
let error = Error()
const stack = error.stack;
if (stack) {
const lines = stack.split('\n');
const new_lines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('svelte/src/internal')) {
continue;
}
new_lines.push(line);
}
define_property(error, 'stack', {
value: new_lines.join('\n')
});
define_property(error, 'name', {
value: 'TraceInvokedError'
});
}
return error;
}

@ -11,6 +11,7 @@ export {
skip_ownership_validation
} from './dev/ownership.js';
export { check_target, legacy_api } from './dev/legacy.js';
export { log_trace, 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';

@ -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();
@ -49,13 +50,20 @@ export function set_inspect_effects(v) {
* @returns {Source<V>}
*/
export function source(v) {
return {
/** @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.stack = ['created', get_stack()];
}
return signal;
}
/**
@ -160,6 +168,10 @@ export function internal_set(source, value) {
source.v = value;
source.version = increment_version();
if (DEV) {
source.stack = ['updated', get_stack()];
}
mark_reactions(source, DIRTY);
// If the current signal is running for the first time, it won't have any

@ -14,6 +14,8 @@ export interface Value<V = unknown> extends Signal {
equals: Equals;
/** The latest value for this signal */
v: V;
/** Dev only */
stack?: [string, Error];
}
export interface Reaction extends Signal {

@ -34,6 +34,13 @@ 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,
set_tracing_expression_reactive,
REACTIVE_UNCHANGED,
REACTIVE_CHANGED,
tracing_expression_reactive
} from './dev/tracing.js';
const FLUSH_MICROTASK = 0;
const FLUSH_SYNC = 1;
@ -131,6 +138,11 @@ export let skip_reaction = false;
/** @type {Set<Value> | null} */
export let captured_signals = null;
/** @param {Set<Value> | null} value */
export function set_captured_signals(value) {
captured_signals = value;
}
// Handling runtime component context
/** @type {ComponentContext | null} */
export let component_context = null;
@ -283,7 +295,7 @@ function handle_error(error, effect, component_context) {
new_lines.push(line);
}
define_property(error, 'stack', {
value: error.stack + new_lines.join('\n')
value: new_lines.join('\n')
});
}
@ -781,6 +793,19 @@ export function get(signal) {
}
}
if (
DEV &&
active_reaction !== null &&
tracing_expressions !== null &&
tracing_expression_reactive !== REACTIVE_UNCHANGED
) {
set_tracing_expression_reactive(
signal.version > active_reaction.version || active_reaction.version === current_version
? REACTIVE_CHANGED
: REACTIVE_UNCHANGED
);
}
return signal.v;
}

@ -414,7 +414,8 @@ const RUNES = /** @type {const} */ ([
'$effect.root',
'$inspect',
'$inspect().with',
'$host'
'$host',
'$trace'
]);
/**

@ -2707,4 +2707,6 @@ declare function $inspect<T extends any[]>(
*/
declare function $host<El extends HTMLElement = HTMLElement>(): El;
declare function $trace(name: string): void;
//# sourceMappingURL=index.d.ts.map

@ -48,12 +48,12 @@ for (const generate of /** @type {const} */ (['client', 'server'])) {
fs.writeFileSync(`${cwd}/output/${file}.json`, JSON.stringify(ast, null, '\t'));
try {
const migrated = migrate(source);
fs.writeFileSync(`${cwd}/output/${file}.migrated.svelte`, migrated.code);
} catch (e) {
console.warn(`Error migrating ${file}`, e);
}
// try {
// const migrated = migrate(source);
// fs.writeFileSync(`${cwd}/output/${file}.migrated.svelte`, migrated.code);
// } catch (e) {
// console.warn(`Error migrating ${file}`, e);
// }
}
const compiled = compile(source, {

Loading…
Cancel
Save