feat: add support for svelte inspector (alternative approach) (#11514)

* mostly working

* fix

* fix

* handle dynamic elements too

* add __svelte_meta to prototype

* changeset

* cheeky fix
pull/11503/head
Rich Harris 8 months ago committed by GitHub
parent 3c756cf14c
commit b1b2dddc3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: add support for svelte inspector

@ -8,6 +8,7 @@ import { javascript_visitors_runes } from './visitors/javascript-runes.js';
import { javascript_visitors_legacy } from './visitors/javascript-legacy.js';
import { serialize_get_binding } from './utils.js';
import { render_stylesheet } from '../css/index.js';
import { getLocator } from 'locate-character';
/**
* This function ensures visitor sets don't accidentally clobber each other
@ -47,6 +48,7 @@ export function client_component(source, analysis, options) {
scopes: analysis.template.scopes,
hoisted: [b.import_all('$', 'svelte/internal/client')],
node: /** @type {any} */ (null), // populated by the root node
source_locator: getLocator(source, { offsetLine: 1 }),
// these should be set by create_block - if they're called outside, it's a bug
get before_init() {
/** @type {any[]} */
@ -88,6 +90,14 @@ export function client_component(source, analysis, options) {
};
return a;
},
get locations() {
/** @type {any[]} */
const a = [];
a.push = () => {
throw new Error('locations.push should not be called outside create_block');
};
return a;
},
legacy_reactive_statements: new Map(),
metadata: {
context: {
@ -466,7 +476,7 @@ export function client_component(source, analysis, options) {
}
// add `App.filename = 'App.svelte'` so that we can print useful messages later
body.push(
body.unshift(
b.stmt(
b.assignment('=', b.member(b.id(analysis.name), b.id('filename')), b.literal(filename))
)

@ -8,6 +8,7 @@ import type {
import type { Namespace, SvelteNode, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js';
import type { ComponentAnalysis } from '../../types.js';
import type { Location } from 'locate-character';
export interface ClientTransformState extends TransformState {
readonly private_state: Map<string, StateField>;
@ -23,11 +24,19 @@ export interface ClientTransformState extends TransformState {
readonly legacy_reactive_statements: Map<LabeledStatement, Statement>;
}
export type SourceLocation =
| [line: number, column: number]
| [line: number, column: number, SourceLocation[]];
export interface ComponentClientTransformState extends ClientTransformState {
readonly analysis: ComponentAnalysis;
readonly options: ValidatedCompileOptions;
readonly hoisted: Array<Statement | ModuleDeclaration>;
readonly events: Set<string>;
readonly source_locator: (
search: string | number,
index?: number | undefined
) => Location | undefined;
/** Stuff that happens before the render effect(s) */
readonly before_init: Statement[];
@ -39,6 +48,7 @@ export interface ComponentClientTransformState extends ClientTransformState {
readonly after_update: Statement[];
/** The HTML template string */
readonly template: string[];
readonly locations: SourceLocation[];
readonly metadata: {
namespace: Namespace;
bound_contenteditable: boolean;

@ -959,6 +959,23 @@ function serialize_bind_this(bind_this, context, node) {
return b.call('$.bind_this', ...args);
}
/**
* @param {import('../types.js').SourceLocation[]} locations
*/
function serialize_locations(locations) {
return b.array(
locations.map((loc) => {
const expression = b.array([b.literal(loc[0]), b.literal(loc[1])]);
if (loc.length === 3) {
expression.elements.push(serialize_locations(loc[2]));
}
return expression;
})
);
}
/**
* Creates a new block which looks roughly like this:
* ```js
@ -1014,6 +1031,7 @@ function create_block(parent, name, nodes, context) {
update: [],
after_update: [],
template: [],
locations: [],
metadata: {
context: {
template_needs_import_node: false,
@ -1028,6 +1046,24 @@ function create_block(parent, name, nodes, context) {
context.visit(node, state);
}
/**
* @param {import('estree').Identifier} template_name
* @param {import('estree').Expression[]} args
*/
const add_template = (template_name, args) => {
let call = b.call(get_template_function(namespace, state), ...args);
if (context.state.options.dev) {
call = b.call(
'$.add_locations',
call,
b.member(b.id(context.state.analysis.name), b.id('filename')),
serialize_locations(state.locations)
);
}
context.state.hoisted.push(b.var(template_name, call));
};
if (is_single_element) {
const element = /** @type {import('#compiler').RegularElement} */ (trimmed[0]);
@ -1045,9 +1081,7 @@ function create_block(parent, name, nodes, context) {
args.push(b.literal(TEMPLATE_USE_IMPORT_NODE));
}
context.state.hoisted.push(
b.var(template_name, b.call(get_template_function(namespace, state), ...args))
);
add_template(template_name, args);
body.push(b.var(id, b.call(template_name)), ...state.before_init, ...state.init);
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
@ -1091,16 +1125,10 @@ function create_block(parent, name, nodes, context) {
flags |= TEMPLATE_USE_IMPORT_NODE;
}
state.hoisted.push(
b.var(
template_name,
b.call(
get_template_function(namespace, state),
b.template([b.quasi(state.template.join(''), true)], []),
b.literal(flags)
)
)
);
add_template(template_name, [
b.template([b.quasi(state.template.join(''), true)], []),
b.literal(flags)
]);
body.push(b.var(id, b.call(template_name)));
}
@ -1809,6 +1837,18 @@ export const template_visitors = {
state.init.push(b.stmt(b.call('$.transition', ...args)));
},
RegularElement(node, context) {
/** @type {import('../types.js').SourceLocation} */
let location = [-1, -1];
if (context.state.options.dev) {
const loc = context.state.source_locator(node.start);
if (loc) {
location[0] = loc.line;
location[1] = loc.column;
context.state.locations.push(location);
}
}
if (node.name === 'noscript') {
context.state.template.push('<!>');
return;
@ -1993,10 +2033,14 @@ export const template_visitors = {
context.state.template.push('>');
/** @type {import('../types.js').SourceLocation[]} */
const child_locations = [];
/** @type {import('../types').ComponentClientTransformState} */
const state = {
...context.state,
metadata: child_metadata,
locations: child_locations,
scope: /** @type {import('../../../scope').Scope} */ (
context.state.scopes.get(node.fragment)
),
@ -2032,6 +2076,11 @@ export const template_visitors = {
{ ...context, state }
);
if (child_locations.length > 0) {
// @ts-expect-error
location.push(child_locations);
}
if (!VoidElements.includes(node.name)) {
context.state.template.push(`</${node.name}>`);
}
@ -2131,19 +2180,21 @@ export const template_visitors = {
})
);
const args = [
context.state.node,
get_tag,
node.metadata.svg || node.metadata.mathml ? b.true : b.false
];
if (inner.length > 0) {
args.push(b.arrow([element_id, b.id('$$anchor')], b.block(inner)));
}
if (dynamic_namespace) {
if (inner.length === 0) args.push(b.id('undefined'));
args.push(b.thunk(serialize_attribute_value(dynamic_namespace, context)[1]));
}
context.state.init.push(b.stmt(b.call('$.element', ...args)));
const location = context.state.options.dev && context.state.source_locator(node.start);
context.state.init.push(
b.stmt(
b.call(
'$.element',
context.state.node,
get_tag,
node.metadata.svg || node.metadata.mathml ? b.true : b.false,
inner.length > 0 && b.arrow([element_id, b.id('$$anchor')], b.block(inner)),
dynamic_namespace && b.thunk(serialize_attribute_value(dynamic_namespace, context)[1]),
location && b.array([b.literal(location.line), b.literal(location.column)])
)
)
);
},
EachBlock(node, context) {
const each_node_meta = node.metadata;

@ -99,19 +99,34 @@ export function labeled(name, body) {
/**
* @param {string | import('estree').Expression} callee
* @param {...(import('estree').Expression | import('estree').SpreadElement)} args
* @param {...(import('estree').Expression | import('estree').SpreadElement | false | undefined)} args
* @returns {import('estree').CallExpression}
*/
export function call(callee, ...args) {
if (typeof callee === 'string') callee = id(callee);
args = args.slice();
while (args.length > 0 && !args.at(-1)) args.pop();
// replacing missing arguments with `undefined`, unless they're at the end in which case remove them
let i = args.length;
let popping = true;
while (i--) {
if (!args[i]) {
if (popping) {
args.pop();
} else {
args[i] = id('undefined');
}
} else {
popping = false;
}
}
return {
type: 'CallExpression',
callee,
arguments: args,
arguments: /** @type {Array<import('estree').Expression | import('estree').SpreadElement>} */ (
args
),
optional: false
};
}

@ -0,0 +1,71 @@
import { HYDRATION_END, HYDRATION_START } from '../../../constants.js';
import { hydrating } from '../dom/hydration.js';
import { is_array } from '../utils.js';
/**
* @param {any} fn
* @param {string} filename
* @param {import('../../../compiler/phases/3-transform/client/types.js').SourceLocation[]} locations
* @returns {any}
*/
export function add_locations(fn, filename, locations) {
return (/** @type {any[]} */ ...args) => {
const dom = fn(...args);
const nodes = hydrating
? is_array(dom)
? dom
: [dom]
: dom.nodeType === 11
? Array.from(dom.childNodes)
: [dom];
assign_locations(nodes, filename, locations);
return dom;
};
}
/**
* @param {Element} element
* @param {string} filename
* @param {import('../../../compiler/phases/3-transform/client/types.js').SourceLocation} location
*/
function assign_location(element, filename, location) {
// @ts-expect-error
element.__svelte_meta = {
loc: { filename, line: location[0], column: location[1] }
};
if (location[2]) {
assign_locations(
/** @type {import('#client').TemplateNode[]} */ (Array.from(element.childNodes)),
filename,
location[2]
);
}
}
/**
* @param {import('#client').TemplateNode[]} nodes
* @param {string} filename
* @param {import('../../../compiler/phases/3-transform/client/types.js').SourceLocation[]} locations
*/
function assign_locations(nodes, filename, locations) {
var j = 0;
var depth = 0;
for (var i = 0; i < nodes.length; i += 1) {
var node = nodes[i];
if (hydrating && node.nodeType === 8) {
var comment = /** @type {Comment} */ (node);
if (comment.data === HYDRATION_START) depth += 1;
if (comment.data.startsWith(HYDRATION_END)) depth -= 1;
}
if (depth === 0 && node.nodeType === 1) {
assign_location(/** @type {Element} */ (node), filename, locations[j++]);
}
}
}

@ -1,5 +1,5 @@
import { namespace_svg } from '../../../../constants.js';
import { hydrate_anchor, hydrate_nodes, hydrating } from '../hydration.js';
import { hydrate_anchor, hydrate_nodes, hydrating, set_hydrate_nodes } from '../hydration.js';
import { empty } from '../operations.js';
import {
block,
@ -12,8 +12,9 @@ import {
import { is_array } from '../../utils.js';
import { set_should_intro } from '../../render.js';
import { current_each_item, set_current_each_item } from './each.js';
import { current_effect } from '../../runtime.js';
import { current_component_context, current_effect } from '../../runtime.js';
import { push_template_node } from '../template.js';
import { DEV } from 'esm-env';
/**
* @param {import('#client').Effect} effect
@ -42,11 +43,14 @@ function swap_block_dom(effect, from, to) {
* @param {boolean} is_svg
* @param {undefined | ((element: Element, anchor: Node | null) => void)} render_fn,
* @param {undefined | (() => string)} get_namespace
* @param {undefined | [number, number]} location
* @returns {void}
*/
export function element(anchor, get_tag, is_svg, render_fn, get_namespace) {
export function element(anchor, get_tag, is_svg, render_fn, get_namespace, location) {
const parent_effect = /** @type {import('#client').Effect} */ (current_effect);
const filename = DEV && location && current_component_context?.function.filename;
render_effect(() => {
/** @type {string | null} */
let tag;
@ -108,6 +112,17 @@ export function element(anchor, get_tag, is_svg, render_fn, get_namespace) {
? document.createElementNS(ns, next_tag)
: document.createElement(next_tag);
if (DEV && location) {
// @ts-expect-error
element.__svelte_meta = {
loc: {
filename,
line: location[0],
column: location[1]
}
};
}
if (render_fn) {
// If hydrating, use the existing ssr comment as the anchor so that the
// inner open and close methods can pick up the existing nodes correctly
@ -115,6 +130,12 @@ export function element(anchor, get_tag, is_svg, render_fn, get_namespace) {
? element.firstChild && hydrate_anchor(/** @type {Comment} */ (element.firstChild))
: element.appendChild(empty());
if (hydrating && !element.firstChild) {
// if the element is a void element with content, add an empty
// node to avoid breaking assumptions elsewhere
set_hydrate_nodes([empty()]);
}
// `child_anchor` is undefined if this is a void element, but we still
// need to call `render_fn` in order to run actions etc. If the element
// contains children, it's a user error (which is warned on elsewhere)

@ -1,5 +1,6 @@
import { hydrate_anchor, hydrate_nodes, hydrating } from './hydration.js';
import { get_descriptor } from '../utils.js';
import { DEV } from 'esm-env';
// We cache the Node and Element prototype methods, so that we can avoid doing
// expensive prototype chain lookups.
@ -70,6 +71,11 @@ export function init_operations() {
// @ts-expect-error
element_prototype.__attributes = null;
if (DEV) {
// @ts-expect-error
element_prototype.__svelte_meta = null;
}
first_child_get = /** @type {(this: Node) => ChildNode | null} */ (
// @ts-ignore
get_descriptor(node_prototype, 'firstChild').get

@ -1,3 +1,4 @@
export { add_locations } from './dev/elements.js';
export { hmr } from './dev/hmr.js';
export {
ADD_OWNER,

@ -0,0 +1,40 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
html: `<button>toggle</button><p>before</p><p>after</p>`,
async test({ target, assert }) {
const btn = target.querySelector('button');
const ps = target.querySelectorAll('p');
// @ts-expect-error
assert.deepEqual(ps[0].__svelte_meta.loc, {
filename: '.../samples/svelte-meta-dynamic/main.svelte',
line: 7,
column: 0
});
// @ts-expect-error
assert.deepEqual(ps[1].__svelte_meta.loc, {
filename: '.../samples/svelte-meta-dynamic/main.svelte',
line: 13,
column: 0
});
flushSync(() => btn?.click());
const strong = target.querySelector('strong');
// @ts-expect-error
assert.deepEqual(strong.__svelte_meta.loc, {
filename: '.../samples/svelte-meta-dynamic/main.svelte',
line: 10,
column: 1
});
}
});

@ -0,0 +1,13 @@
<script>
let condition = $state(false);
</script>
<button onclick={() => condition = !condition}>toggle</button>
<svelte:element this={'p'}>before</svelte:element>
{#if condition}
<svelte:element this={'strong'}>during</svelte:element>
{/if}
<svelte:element this={'p'}>after</svelte:element>

@ -0,0 +1,40 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
html: `<button>toggle</button><p>before</p><p>after</p>`,
async test({ target, assert }) {
const btn = target.querySelector('button');
const ps = target.querySelectorAll('p');
// @ts-expect-error
assert.deepEqual(ps[0].__svelte_meta.loc, {
filename: '.../samples/svelte-meta/main.svelte',
line: 7,
column: 0
});
// @ts-expect-error
assert.deepEqual(ps[1].__svelte_meta.loc, {
filename: '.../samples/svelte-meta/main.svelte',
line: 13,
column: 0
});
flushSync(() => btn?.click());
const strong = target.querySelector('strong');
// @ts-expect-error
assert.deepEqual(strong.__svelte_meta.loc, {
filename: '.../samples/svelte-meta/main.svelte',
line: 10,
column: 1
});
}
});

@ -0,0 +1,13 @@
<script>
let condition = $state(false);
</script>
<button onclick={() => condition = !condition}>toggle</button>
<p>before</p>
{#if condition}
<strong>during</strong>
{/if}
<p>after</p>
Loading…
Cancel
Save