diff --git a/.changeset/cool-jobs-scream.md b/.changeset/cool-jobs-scream.md new file mode 100644 index 0000000000..7ca46a06c5 --- /dev/null +++ b/.changeset/cool-jobs-scream.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: add support for svelte inspector diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 5495f3f6be..b668dc53bc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -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)) ) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index c9287554a9..13775bb8f4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -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; @@ -23,11 +24,19 @@ export interface ClientTransformState extends TransformState { readonly legacy_reactive_statements: Map; } +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; readonly events: Set; + 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; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index b6fe9649eb..f7435612c2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -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(``); } @@ -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; diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index c31a0bb5c5..ec85e41a41 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -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} */ ( + args + ), optional: false }; } diff --git a/packages/svelte/src/internal/client/dev/elements.js b/packages/svelte/src/internal/client/dev/elements.js new file mode 100644 index 0000000000..b39b51b1c7 --- /dev/null +++ b/packages/svelte/src/internal/client/dev/elements.js @@ -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++]); + } + } +} diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js index 1972ee4cb7..3b39c91710 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js @@ -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) diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 5861e26e14..3c3ca4f7fd 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -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 diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index b2b68fcf44..eddcfc4b48 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -1,3 +1,4 @@ +export { add_locations } from './dev/elements.js'; export { hmr } from './dev/hmr.js'; export { ADD_OWNER, diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-meta-dynamic/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-meta-dynamic/_config.js new file mode 100644 index 0000000000..6490ddb2f4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-meta-dynamic/_config.js @@ -0,0 +1,40 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + + html: `

before

after

`, + + 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 + }); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-meta-dynamic/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-meta-dynamic/main.svelte new file mode 100644 index 0000000000..374de8df9b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-meta-dynamic/main.svelte @@ -0,0 +1,13 @@ + + + + +before + +{#if condition} + during +{/if} + +after diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-meta/_config.js b/packages/svelte/tests/runtime-runes/samples/svelte-meta/_config.js new file mode 100644 index 0000000000..300e030c4e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-meta/_config.js @@ -0,0 +1,40 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + + html: `

before

after

`, + + 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 + }); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/svelte-meta/main.svelte b/packages/svelte/tests/runtime-runes/samples/svelte-meta/main.svelte new file mode 100644 index 0000000000..be87fdc33b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/svelte-meta/main.svelte @@ -0,0 +1,13 @@ + + + + +

before

+ +{#if condition} + during +{/if} + +

after