feat: add parent hierarchy to `__svelte_meta` objects at dev time (#16255)

* feat: add parent hierarchy to `__svelte_meta` objects at dev time

This adds a `parent` property to the `__svelte_meta` properties that are added to elements at dev time. This property represents the closest non-element parent the element is related to. For example for `{#if ...}<div>foo</div>{/if}` the `parent` of the div would be the line/column of the if block.
The parent is recursive and goes upwards (through component boundaries) until the root component is reached, which has no parent.

part of #11389

* oops

* Apply suggestions from code review

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

* tweak

* original component tag

* make render appear in tree, keep tree in sync when rerenders occur

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/16283/head
Simon H 2 months ago committed by GitHub
parent 9dddb31b4a
commit 32882a956b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: add parent hierarchy to `__svelte_meta` objects

@ -5,7 +5,7 @@ import { extract_identifiers } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { create_derived } from '../utils.js';
import { get_value } from './shared/declarations.js';
import { build_expression } from './shared/utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
/**
* @param {AST.AwaitBlock} node
@ -54,7 +54,7 @@ export function AwaitBlock(node, context) {
}
context.state.init.push(
b.stmt(
add_svelte_meta(
b.call(
'$.await',
context.state.node,
@ -64,7 +64,9 @@ export function AwaitBlock(node, context) {
: b.null,
then_block,
catch_block
)
),
node,
'await'
)
);
}

@ -13,7 +13,7 @@ import { dev } from '../../../../state.js';
import { extract_paths, object } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { get_value } from './shared/declarations.js';
import { build_expression } from './shared/utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
/**
* @param {AST.EachBlock} node
@ -337,7 +337,7 @@ export function EachBlock(node, context) {
);
}
context.state.init.push(b.stmt(b.call('$.each', ...args)));
context.state.init.push(add_svelte_meta(b.call('$.each', ...args), node, 'each'));
}
/**

@ -2,7 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
import { build_expression } from './shared/utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
/**
* @param {AST.IfBlock} node
@ -74,7 +74,7 @@ export function IfBlock(node, context) {
args.push(b.id('$$elseif'));
}
statements.push(b.stmt(b.call('$.if', ...args)));
statements.push(add_svelte_meta(b.call('$.if', ...args), node, 'if'));
context.state.init.push(b.block(statements));
}

@ -2,7 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
import { build_expression } from './shared/utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
/**
* @param {AST.KeyBlock} node
@ -15,6 +15,10 @@ export function KeyBlock(node, context) {
const body = /** @type {Expression} */ (context.visit(node.fragment));
context.state.init.push(
b.stmt(b.call('$.key', context.state.node, b.thunk(key), b.arrow([b.id('$$anchor')], body)))
add_svelte_meta(
b.call('$.key', context.state.node, b.thunk(key), b.arrow([b.id('$$anchor')], body)),
node,
'key'
)
);
}

@ -3,7 +3,7 @@
/** @import { ComponentContext } from '../types' */
import { unwrap_optional } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { build_expression } from './shared/utils.js';
import { add_svelte_meta, build_expression } from './shared/utils.js';
/**
* @param {AST.RenderTag} node
@ -48,16 +48,22 @@ export function RenderTag(node, context) {
}
context.state.init.push(
b.stmt(b.call('$.snippet', context.state.node, b.thunk(snippet_function), ...args))
add_svelte_meta(
b.call('$.snippet', context.state.node, b.thunk(snippet_function), ...args),
node,
'render'
)
);
} else {
context.state.init.push(
b.stmt(
add_svelte_meta(
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
snippet_function,
context.state.node,
...args
)
),
node,
'render'
)
);
}

@ -4,7 +4,12 @@
import { dev, is_ignored } from '../../../../../state.js';
import { get_attribute_chunks, object } from '../../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { build_bind_this, memoize_expression, validate_binding } from '../shared/utils.js';
import {
build_bind_this,
memoize_expression,
validate_binding,
add_svelte_meta
} from '../shared/utils.js';
import { build_attribute_value } from '../shared/element.js';
import { build_event_handler } from './events.js';
import { determine_slot } from '../../../../../utils/slot.js';
@ -483,7 +488,8 @@ export function build_component(node, component_name, context) {
);
} else {
context.state.template.push_comment();
statements.push(b.stmt(fn(anchor)));
statements.push(add_svelte_meta(fn(anchor), node, 'component', { componentTag: node.name }));
}
return statements.length > 1 ? b.block(statements) : statements[0];

@ -1,4 +1,4 @@
/** @import { AssignmentExpression, Expression, ExpressionStatement, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression, Pattern } from 'estree' */
/** @import { AssignmentExpression, Expression, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression, ExpressionStatement } from 'estree' */
/** @import { AST, ExpressionMetadata } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext, Context } from '../../types' */
import { walk } from 'zimmerframe';
@ -7,7 +7,7 @@ import * as b from '#compiler/builders';
import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js';
import { regex_is_valid_identifier } from '../../../../patterns.js';
import is_reference from 'is-reference';
import { dev, is_ignored, locator } from '../../../../../state.js';
import { dev, is_ignored, locator, component_name } from '../../../../../state.js';
import { build_getter, create_derived } from '../../utils.js';
/**
@ -424,3 +424,34 @@ export function build_expression(context, expression, metadata, state = context.
return sequence;
}
/**
* Wraps a statement/expression with dev stack tracking in dev mode
* @param {Expression} expression - The function call to wrap (e.g., $.if, $.each, etc.)
* @param {{ start?: number }} node - AST node for location info
* @param {'component' | 'if' | 'each' | 'await' | 'key' | 'render'} type - Type of block/component
* @param {Record<string, number | string>} [additional] - Any additional properties to add to the dev stack entry
* @returns {ExpressionStatement} - Statement with or without dev stack wrapping
*/
export function add_svelte_meta(expression, node, type, additional) {
if (!dev) {
return b.stmt(expression);
}
const location = node.start !== undefined && locator(node.start);
if (!location) {
return b.stmt(expression);
}
return b.stmt(
b.call(
'$.add_svelte_meta',
b.arrow([], expression),
b.literal(type),
b.id(component_name),
b.literal(location.line),
b.literal(location.column),
additional && b.object(Object.entries(additional).map(([k, v]) => b.init(k, b.literal(v))))
)
);
}

@ -16,6 +16,9 @@ export let warnings = [];
*/
export let filename;
/**
* The name of the component that is used in the `export default function ...` statement.
*/
export let component_name = '<unknown>';
/**

@ -1,4 +1,4 @@
/** @import { ComponentContext } from '#client' */
/** @import { ComponentContext, DevStackEntry } from '#client' */
import { DEV } from 'esm-env';
import { lifecycle_outside_component } from '../shared/errors.js';
@ -11,6 +11,7 @@ import {
} from './runtime.js';
import { effect, teardown } from './reactivity/effects.js';
import { legacy_mode_flag } from '../flags/index.js';
import { FILENAME } from '../../constants.js';
/** @type {ComponentContext | null} */
export let component_context = null;
@ -20,6 +21,43 @@ export function set_component_context(context) {
component_context = context;
}
/** @type {DevStackEntry | null} */
export let dev_stack = null;
/** @param {DevStackEntry | null} stack */
export function set_dev_stack(stack) {
dev_stack = stack;
}
/**
* Execute a callback with a new dev stack entry
* @param {() => any} callback - Function to execute
* @param {DevStackEntry['type']} type - Type of block/component
* @param {any} component - Component function
* @param {number} line - Line number
* @param {number} column - Column number
* @param {Record<string, any>} [additional] - Any additional properties to add to the dev stack entry
* @returns {any}
*/
export function add_svelte_meta(callback, type, component, line, column, additional) {
const parent = dev_stack;
dev_stack = {
type,
file: component[FILENAME],
line,
column,
parent,
...additional
};
try {
return callback();
} finally {
dev_stack = parent;
}
}
/**
* The current component function. Different from current component context:
* ```html

@ -2,6 +2,7 @@
import { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE } from '#client/constants';
import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../constants.js';
import { hydrating } from '../dom/hydration.js';
import { dev_stack } from '../context.js';
/**
* @param {any} fn
@ -28,6 +29,7 @@ export function add_locations(fn, filename, locations) {
function assign_location(element, filename, location) {
// @ts-expect-error
element.__svelte_meta = {
parent: dev_stack,
loc: { file: filename, line: location[0], column: location[1] }
};

@ -16,9 +16,11 @@ import { queue_micro_task } from '../task.js';
import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js';
import {
component_context,
dev_stack,
is_runes,
set_component_context,
set_dev_current_component_function
set_dev_current_component_function,
set_dev_stack
} from '../../context.js';
const PENDING = 0;
@ -45,6 +47,7 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
/** @type {any} */
var component_function = DEV ? component_context?.function : null;
var dev_original_stack = DEV ? dev_stack : null;
/** @type {V | Promise<V> | typeof UNINITIALIZED} */
var input = UNINITIALIZED;
@ -75,7 +78,10 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
set_active_effect(effect);
set_active_reaction(effect); // TODO do we need both?
set_component_context(active_component_context);
if (DEV) set_dev_current_component_function(component_function);
if (DEV) {
set_dev_current_component_function(component_function);
set_dev_stack(dev_original_stack);
}
}
try {
@ -107,7 +113,11 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
}
} finally {
if (restore) {
if (DEV) set_dev_current_component_function(null);
if (DEV) {
set_dev_current_component_function(null);
set_dev_stack(null);
}
set_component_context(null);
set_active_reaction(null);
set_active_effect(null);

@ -18,7 +18,7 @@ import {
import { set_should_intro } from '../../render.js';
import { current_each_item, set_current_each_item } from './each.js';
import { active_effect } from '../../runtime.js';
import { component_context } from '../../context.js';
import { component_context, dev_stack } from '../../context.js';
import { DEV } from 'esm-env';
import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants';
import { assign_nodes } from '../template.js';
@ -107,6 +107,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
if (DEV && location) {
// @ts-expect-error
element.__svelte_meta = {
parent: dev_stack,
loc: {
file: filename,
line: location[0],

@ -1,6 +1,6 @@
export { createAttachmentKey as attachment } from '../../attachments/index.js';
export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js';
export { push, pop } from './context.js';
export { push, pop, add_svelte_meta } from './context.js';
export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js';
export { cleanup_styles } from './dev/css.js';
export { add_locations } from './dev/elements.js';

@ -41,7 +41,7 @@ import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js';
import { derived } from './deriveds.js';
import { component_context, dev_current_component_function } from '../context.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js';
/**
* @param {'$effect' | '$effect.pre' | '$inspect'} rune
@ -359,7 +359,11 @@ export function template_effect(fn, thunks = [], d = derived) {
* @param {number} flags
*/
export function block(fn, flags = 0) {
return create_effect(RENDER_EFFECT | BLOCK_EFFECT | flags, fn, true);
var effect = create_effect(RENDER_EFFECT | BLOCK_EFFECT | flags, fn, true);
if (DEV) {
effect.dev_stack = dev_stack;
}
return effect;
}
/**

@ -1,4 +1,10 @@
import type { ComponentContext, Dom, Equals, TemplateNode, TransitionManager } from '#client';
import type {
ComponentContext,
DevStackEntry,
Equals,
TemplateNode,
TransitionManager
} from '#client';
export interface Signal {
/** Flags bitmask */
@ -80,6 +86,8 @@ export interface Effect extends Reaction {
parent: Effect | null;
/** Dev only */
component_function?: any;
/** Dev only. Only set for certain block effects. Contains a reference to the stack that represents the render tree */
dev_stack?: DevStackEntry | null;
}
export type Source<V = unknown> = Value<V>;

@ -34,12 +34,13 @@ import { tracing_expressions, get_stack } from './dev/tracing.js';
import {
component_context,
dev_current_component_function,
dev_stack,
is_runes,
set_component_context,
set_dev_current_component_function
set_dev_current_component_function,
set_dev_stack
} from './context.js';
import { handle_error, invoke_error_boundary } from './error-handling.js';
import { snapshot } from '../shared/clone.js';
let is_flushing = false;
@ -444,6 +445,9 @@ export function update_effect(effect) {
if (DEV) {
var previous_component_fn = dev_current_component_function;
set_dev_current_component_function(effect.component_function);
var previous_stack = /** @type {any} */ (dev_stack);
// only block effects have a dev stack, keep the current one otherwise
set_dev_stack(effect.dev_stack ?? dev_stack);
}
try {
@ -478,6 +482,7 @@ export function update_effect(effect) {
if (DEV) {
set_dev_current_component_function(previous_component_fn);
set_dev_stack(previous_stack);
}
}
}

@ -187,4 +187,13 @@ export type SourceLocation =
| [line: number, column: number]
| [line: number, column: number, SourceLocation[]];
export interface DevStackEntry {
file: string;
type: 'component' | 'if' | 'each' | 'await' | 'key' | 'render';
line: number;
column: number;
parent: DevStackEntry | null;
componentTag?: string;
}
export * from './reactivity/types';

@ -0,0 +1,186 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client', 'hydrate'],
compileOptions: {
dev: true
},
html: `
<p>no parent</p>
<button>toggle</button>
<p>if</p>
<p>each</p>
<p>loading</p>
<p>key</p>
<p>hi</p>
<p>hi</p>
<p>hi</p>
<p>hi</p>
<p>hi</p>
`,
async test({ target, assert }) {
function check() {
const [main, if_, each, await_, key, child1, child2, child3, child4, dynamic] =
target.querySelectorAll('p');
// @ts-expect-error
assert.deepEqual(main.__svelte_meta.parent, null);
// @ts-expect-error
assert.deepEqual(if_.__svelte_meta.parent, {
file: 'main.svelte',
type: 'if',
line: 12,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(each.__svelte_meta.parent, {
file: 'main.svelte',
type: 'each',
line: 16,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(await_.__svelte_meta.parent, {
file: 'main.svelte',
type: 'await',
line: 20,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(key.__svelte_meta.parent, {
file: 'main.svelte',
type: 'key',
line: 26,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(child1.__svelte_meta.parent, {
file: 'main.svelte',
type: 'component',
componentTag: 'Child',
line: 30,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(child2.__svelte_meta.parent, {
file: 'main.svelte',
type: 'component',
componentTag: 'Child',
line: 33,
column: 1,
parent: {
file: 'passthrough.svelte',
type: 'render',
line: 5,
column: 0,
parent: {
file: 'main.svelte',
type: 'component',
componentTag: 'Passthrough',
line: 32,
column: 0,
parent: null
}
}
});
// @ts-expect-error
assert.deepEqual(child3.__svelte_meta.parent, {
file: 'main.svelte',
type: 'component',
componentTag: 'Child',
line: 38,
column: 2,
parent: {
file: 'passthrough.svelte',
type: 'render',
line: 5,
column: 0,
parent: {
file: 'main.svelte',
type: 'component',
componentTag: 'Passthrough',
line: 37,
column: 1,
parent: {
file: 'passthrough.svelte',
type: 'render',
line: 5,
column: 0,
parent: {
file: 'main.svelte',
type: 'component',
componentTag: 'Passthrough',
line: 36,
column: 0,
parent: null
}
}
}
}
});
// @ts-expect-error
assert.deepEqual(child4.__svelte_meta.parent, {
file: 'passthrough.svelte',
type: 'render',
line: 8,
column: 1,
parent: {
file: 'passthrough.svelte',
type: 'if',
line: 7,
column: 0,
parent: {
file: 'main.svelte',
type: 'component',
componentTag: 'Passthrough',
line: 43,
column: 1,
parent: {
file: 'main.svelte',
type: 'if',
line: 42,
column: 0,
parent: null
}
}
}
});
// @ts-expect-error
assert.deepEqual(dynamic.__svelte_meta.parent, {
file: 'main.svelte',
type: 'component',
componentTag: 'x.y',
line: 50,
column: 0,
parent: null
});
}
await tick();
check();
// Test that stack is kept when re-rendering
const button = target.querySelector('button');
button?.click();
await tick();
button?.click();
await tick();
check();
}
});

@ -0,0 +1,50 @@
<script>
import Child from "./child.svelte";
import Passthrough from "./passthrough.svelte";
let x = { y: Child }
let key = 'test';
let show = $state(true);
</script>
<p>no parent</p>
<button onclick={() => show = !show}>toggle</button>
{#if true}
<p>if</p>
{/if}
{#each [1]}
<p>each</p>
{/each}
{#await Promise.resolve()}
<p>loading</p>
{:then}
<p>await</p>
{/await}
{#key key}
<p>key</p>
{/key}
<Child />
<Passthrough>
<Child />
</Passthrough>
<Passthrough>
<Passthrough>
<Child />
</Passthrough>
</Passthrough>
{#if show}
<Passthrough>
{#snippet named()}
<p>hi</p>
{/snippet}
</Passthrough>
{/if}
<x.y />

@ -0,0 +1,9 @@
<script>
let { children, named } = $props();
</script>
{@render children?.()}
{#if true}
{@render named?.()}
{/if}
Loading…
Cancel
Save