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
pull/16255/head
Simon Holthausen 3 months ago
parent 32481bece6
commit 338500085a

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: add parent hierarchy to \_\_svelte_meta objects at dev time

@ -453,6 +453,9 @@ export function client_component(analysis, options) {
if (analysis.custom_element) {
to_remove.push(b.literal('$$host'));
}
if (dev) {
to_remove.push(b.literal('$$dev'));
}
component_block.body.unshift(
b.const(

@ -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, with_dev_stack } from './shared/utils.js';
/**
* @param {AST.AwaitBlock} node
@ -54,7 +54,7 @@ export function AwaitBlock(node, context) {
}
context.state.init.push(
b.stmt(
with_dev_stack(
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, with_dev_stack } 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(with_dev_stack(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, with_dev_stack } 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(with_dev_stack(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, with_dev_stack } 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)))
with_dev_stack(
b.call('$.key', context.state.node, b.thunk(key), b.arrow([b.id('$$anchor')], body)),
node,
'key'
)
);
}

@ -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,
with_dev_stack
} 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,10 @@ export function build_component(node, component_name, context) {
);
} else {
context.state.template.push_comment();
statements.push(b.stmt(fn(anchor)));
statements.push(
with_dev_stack(fn(anchor), node, 'component', { componentTag: component_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, Pattern, CallExpression, Statement } 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, filename } from '../../../../../state.js';
import { build_getter, create_derived } from '../../utils.js';
/**
@ -394,3 +394,37 @@ export function build_expression(context, expression, metadata, state = context.
return sequence;
}
/**
* Wraps a statement/expression with dev stack tracking in dev mode
* @param {CallExpression} call_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'} type - Type of block/component
* @param {Record<string, number | string>} [additional] - Any additional properties to add to the dev stack entry
* @returns {Statement} - Statement with or without dev stack wrapping
*/
export function with_dev_stack(call_expression, node, type, additional) {
if (!dev) {
return b.stmt(call_expression);
}
const location = node.start && locator(node.start);
if (!location) {
return b.stmt(call_expression);
}
// Wrap with dev stack tracking
return b.stmt(
b.call(
'$.with_dev_stack',
b.arrow([], b.block([b.stmt(call_expression)])),
b.literal(type),
b.literal(filename),
b.literal(location.line),
b.literal(location.column),
additional
? b.object(Object.entries(additional).map(([key, value]) => b.init(key, b.literal(value))))
: b.null
)
);
}

@ -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';
@ -20,6 +20,44 @@ 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 {string} file - Source file
* @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 with_dev_stack(callback, type, file, line, column, additional) {
/** @type {DevStackEntry} */
const new_entry = {
type,
file,
line,
column,
parent: dev_stack,
...additional
};
const previous_stack = dev_stack;
dev_stack = new_entry;
try {
return callback();
} finally {
dev_stack = previous_stack;
}
}
/**
* 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);

@ -5,7 +5,9 @@ import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants';
import { branch, block, destroy_effect, teardown } from '../../reactivity/effects.js';
import {
dev_current_component_function,
set_dev_current_component_function
dev_stack,
set_dev_current_component_function,
set_dev_stack
} from '../../context.js';
import { hydrate_next, hydrate_node, hydrating } from '../hydration.js';
import { create_fragment_from_html } from '../reconciler.js';
@ -61,14 +63,18 @@ export function snippet(node, get_snippet, ...args) {
* @param {(node: TemplateNode, ...args: any[]) => void} fn
*/
export function wrap_snippet(component, fn) {
var original_stack = dev_stack;
const snippet = (/** @type {TemplateNode} */ node, /** @type {any[]} */ ...args) => {
var previous_component_function = dev_current_component_function;
var previous_stack = dev_stack;
set_dev_current_component_function(component);
set_dev_stack(original_stack);
try {
return fn(node, ...args);
} finally {
set_dev_current_component_function(previous_component_function);
set_dev_stack(previous_stack);
}
};

@ -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, with_dev_stack } 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';

@ -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';
line: number;
column: number;
parent: DevStackEntry | null;
componentTag?: string;
}
export * from './reactivity/types';

@ -0,0 +1,82 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client', 'hydrate'],
compileOptions: {
dev: true
},
html: `<p>no parent</p><p>if</p><p>each</p><p>loading</p><p>key</p><p>hi</p><p>hi</p>`,
async test({ target, assert }) {
await tick();
const [main, if_, each, await_, key, child1, child2] = 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: 10,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(each.__svelte_meta.parent, {
file: 'main.svelte',
type: 'each',
line: 14,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(await_.__svelte_meta.parent, {
file: 'main.svelte',
type: 'await',
line: 18,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(key.__svelte_meta.parent, {
file: 'main.svelte',
type: 'key',
line: 24,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(child1.__svelte_meta.parent, {
file: 'main.svelte',
type: 'component',
componentTag: 'Child',
line: 28,
column: 0,
parent: null
});
// @ts-expect-error
assert.deepEqual(child2.__svelte_meta.parent, {
file: 'main.svelte',
type: 'component',
componentTag: 'Child',
line: 31,
column: 1,
parent: {
file: 'main.svelte',
type: 'component',
componentTag: 'Passthrough',
line: 30,
column: 0,
parent: null
}
});
}
});

@ -0,0 +1,32 @@
<script>
import Child from "./child.svelte";
import Passthrough from "./passthrough.svelte";
let key = 'test';
</script>
<p>no parent</p>
{#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>

@ -0,0 +1,5 @@
<script>
let { children } = $props();
</script>
{@render children()}
Loading…
Cancel
Save