diff --git a/.changeset/large-clouds-carry.md b/.changeset/large-clouds-carry.md new file mode 100644 index 0000000000..f9738794f8 --- /dev/null +++ b/.changeset/large-clouds-carry.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: $inspect rune diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 40d4978451..aa0bba702f 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -265,7 +265,7 @@ export function analyze_component(root, options) { // is referencing a rune and not a global store. if ( options.runes === false || - !Runes.includes(name) || + !Runes.includes(/** @type {any} */ (name)) || (declaration !== null && // const state = $state(0) is valid get_rune(declaration.initial, instance.scope) === null && @@ -279,7 +279,7 @@ export function analyze_component(root, options) { if (options.runes !== false) { if (declaration === null && /[a-z]/.test(store_name[0])) { error(references[0].node, 'illegal-global', name); - } else if (declaration !== null && Runes.includes(name)) { + } else if (declaration !== null && Runes.includes(/** @type {any} */ (name))) { for (const { node, path } of references) { if (path.at(-1)?.type === 'CallExpression') { warn(warnings, node, [], 'store-with-rune-name', store_name); @@ -326,7 +326,10 @@ export function analyze_component(root, options) { get_css_hash: options.cssHash }), runes: - options.runes ?? Array.from(module.scope.references).some(([name]) => Runes.includes(name)), + options.runes ?? + Array.from(module.scope.references).some(([name]) => + Runes.includes(/** @type {any} */ (name)) + ), exports: [], uses_props: false, uses_rest_props: false, @@ -660,6 +663,14 @@ const runes_scope_js_tweaker = { /** @type {import('./types').Visitors} */ const runes_scope_tweaker = { + CallExpression(node, { state, next }) { + const rune = get_rune(node, state.scope); + + // `$inspect(foo)` should not trigger the `static-state-reference` warning + if (rune === '$inspect') { + next({ ...state, function_depth: state.function_depth + 1 }); + } + }, VariableDeclarator(node, { state }) { const init = unwrap_ts_expression(node.init); if (!init || init.type !== 'CallExpression') return; @@ -880,6 +891,7 @@ const common_visitors = { Identifier(node, context) { const parent = /** @type {import('estree').Node} */ (context.path.at(-1)); if (!is_reference(node, parent)) return; + const binding = context.state.scope.get(node.name); // if no binding, means some global variable diff --git a/packages/svelte/src/compiler/phases/2-analyze/validation.js b/packages/svelte/src/compiler/phases/2-analyze/validation.js index c044c36023..0b97284110 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/validation.js +++ b/packages/svelte/src/compiler/phases/2-analyze/validation.js @@ -521,13 +521,19 @@ function validate_call_expression(node, scope, path) { if (rune === '$effect.active') { if (node.arguments.length !== 0) { - error(node, 'invalid-rune-args-length', '$effect.active', [0]); + error(node, 'invalid-rune-args-length', rune, [0]); } } if (rune === '$effect.root') { if (node.arguments.length !== 1) { - error(node, 'invalid-rune-args-length', '$effect.root', [1]); + error(node, 'invalid-rune-args-length', rune, [1]); + } + } + + if (rune === '$inspect') { + if (node.arguments.length < 1 || node.arguments.length > 2) { + error(node, 'invalid-rune-args-length', rune, [1, 2]); } } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js index 1194cffcde..05633632c1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -288,7 +288,8 @@ function get_hoistable_params(node, context) { params.push(b.id(binding.node.name.slice(1))); params.push(b.id(binding.node.name)); } else { - params.push(binding.node); + // create a copy to remove start/end tags which would mess up source maps + params.push(b.id(binding.node.name)); } } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js index 1f027dfe02..5bb5216be6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js @@ -136,7 +136,7 @@ export const javascript_visitors_runes = { for (const declarator of node.declarations) { const init = unwrap_ts_expression(declarator.init); const rune = get_rune(init, state.scope); - if (!rune || rune === '$effect.active' || rune === '$effect.root') { + if (!rune || rune === '$effect.active' || rune === '$effect.root' || rune === '$inspect') { if (init != null && is_hoistable_function(init)) { const hoistable_function = visit(init); state.hoisted.push( @@ -307,6 +307,19 @@ export const javascript_visitors_runes = { return b.call('$.user_root_effect', ...args); } + if (rune === '$inspect') { + if (state.options.dev) { + const arg = /** @type {import('estree').Expression} */ (visit(node.arguments[0])); + const fn = + node.arguments[1] && + /** @type {import('estree').Expression} */ (visit(node.arguments[1])); + + return b.call('$.inspect', b.thunk(arg), fn); + } + + return b.unary('void', b.literal(0)); + } + next(); } }; diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index cdb5f70069..4f8e510709 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -575,7 +575,7 @@ const javascript_visitors_runes = { for (const declarator of node.declarations) { const init = unwrap_ts_expression(declarator.init); const rune = get_rune(init, state.scope); - if (!rune || rune === '$effect.active') { + if (!rune || rune === '$effect.active' || rune === '$inspect') { declarations.push(/** @type {import('estree').VariableDeclarator} */ (visit(declarator))); continue; } @@ -630,13 +630,25 @@ const javascript_visitors_runes = { } context.next(); }, - CallExpression(node, { state, next }) { + CallExpression(node, { state, next, visit }) { const rune = get_rune(node, state.scope); if (rune === '$effect.active') { return b.literal(false); } + if (rune === '$inspect') { + if (state.options.dev) { + const args = /** @type {import('estree').Expression[]} */ ( + node.arguments.map((arg) => visit(arg)) + ); + + return b.call('console.log', ...args); + } + + return b.unary('void', b.literal(0)); + } + next(); } }; diff --git a/packages/svelte/src/compiler/phases/constants.js b/packages/svelte/src/compiler/phases/constants.js index c646016f8d..9c4af55145 100644 --- a/packages/svelte/src/compiler/phases/constants.js +++ b/packages/svelte/src/compiler/phases/constants.js @@ -70,15 +70,16 @@ export const ElementBindings = [ 'indeterminate' ]; -export const Runes = [ +export const Runes = /** @type {const} */ ([ '$state', '$props', '$derived', '$effect', '$effect.pre', '$effect.active', - '$effect.root' -]; + '$effect.root', + '$inspect' +]); /** * Whitespace inside one of these elements will not result in diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index e958df604e..4f836f8f7a 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -672,6 +672,7 @@ export function set_scope(scopes) { * Returns the name of the rune if the given expression is a `CallExpression` using a rune. * @param {import('estree').Node | null | undefined} node * @param {Scope} scope + * @returns {Runes[number] | null} */ export function get_rune(node, scope) { if (!node) return null; @@ -691,10 +692,10 @@ export function get_rune(node, scope) { if (n.type !== 'Identifier') return null; joined = n.name + joined; - if (!Runes.includes(joined)) return null; + if (!Runes.includes(/** @type {any} */ (joined))) return null; const binding = scope.get(n.name); if (binding !== null) return null; // rune name, but references a variable or store - return joined; + return /** @type {Runes[number] | null} */ (joined); } diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 8971532182..dd6cff6f91 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -72,7 +72,7 @@ export function labeled(name, body) { /** * @param {string | import('estree').Expression} callee - * @param {...import('estree').Expression} args + * @param {...(import('estree').Expression | import('estree').SpreadElement)} args * @returns {import('estree').CallExpression} */ export function call(callee, ...args) { diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 9610515563..00e6ee9911 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -2,7 +2,7 @@ import { DEV } from 'esm-env'; import { subscribe_to_store } from '../../store/utils.js'; import { EMPTY_FUNC, run_all } from '../common.js'; import { unwrap } from './render.js'; -import { is_array } from './utils.js'; +import { get_descriptors, is_array } from './utils.js'; export const SOURCE = 1; export const DERIVED = 1 << 1; @@ -69,8 +69,14 @@ let current_skip_consumer = false; // Handle collecting all signals which are read during a specific time frame let is_signals_recorded = false; let captured_signals = new Set(); -// Handle rendering tree blocks and anchors +/** @type {Function | null} */ +let inspect_fn = null; + +/** @type {Array} */ +let inspect_captured_signals = []; + +// Handle rendering tree blocks and anchors /** @type {null | import('./types.js').Block} */ export let current_block = null; // Handling runtime component context @@ -145,10 +151,26 @@ function default_equals(a, b) { * @template V * @param {import('./types.js').SignalFlags} flags * @param {V} value - * @returns {import('./types.js').SourceSignal} + * @returns {import('./types.js').SourceSignal | import('./types.js').SourceSignal & import('./types.js').SourceSignalDebug} */ function create_source_signal(flags, value) { - const source = { + if (DEV) { + return { + // consumers + c: null, + // equals + e: null, + // flags + f: flags, + // value + v: value, + // context: We can remove this if we get rid of beforeUpdate/afterUpdate + x: null, + // this is for DEV only + inspect: new Set() + }; + } + return { // consumers c: null, // equals @@ -160,7 +182,6 @@ function create_source_signal(flags, value) { // context: We can remove this if we get rid of beforeUpdate/afterUpdate x: null }; - return source; } /** @@ -688,7 +709,7 @@ export function store_get(store, store_name, stores) { /** * @template V * @param {import('./types.js').Store | null | undefined} store - * @param {import('./types.js').Signal} source + * @param {import('./types.js').SourceSignal} source */ function connect_store_to_signal(store, source) { if (store == null) { @@ -756,6 +777,14 @@ export function exposable(fn) { * @returns {V} */ export function get(signal) { + // @ts-expect-error + if (DEV && signal.inspect && inspect_fn) { + // @ts-expect-error + signal.inspect.add(inspect_fn); + // @ts-expect-error + inspect_captured_signals.push(signal); + } + const flags = signal.f; if ((flags & DESTROYED) !== 0) { return signal.v; @@ -811,7 +840,7 @@ export function set(signal, value) { * @returns {void} */ export function set_sync(signal, value) { - flushSync(() => set_signal_value(signal, value)); + flushSync(() => set(signal, value)); } /** @@ -1016,6 +1045,12 @@ export function set_signal_value(signal, value) { }); } } + + // @ts-expect-error + if (DEV && signal.inspect) { + // @ts-expect-error + for (const fn of signal.inspect) fn(); + } } } @@ -1727,3 +1762,69 @@ export function pop(accessors) { context_stack_item.m = true; } } + +/** + * @param {any} value + * @param {Set} visited + * @returns {void} + */ +function deep_read(value, visited = new Set()) { + if (typeof value === 'object' && value !== null && !visited.has(value)) { + visited.add(value); + for (let key in value) { + deep_read(value[key], visited); + } + const proto = Object.getPrototypeOf(value); + if ( + proto !== Object.prototype && + proto !== Array.prototype && + proto !== Map.prototype && + proto !== Set.prototype && + proto !== Date.prototype + ) { + const descriptors = get_descriptors(proto); + for (let key in descriptors) { + const get = descriptors[key].get; + if (get) { + get.call(value); + } + } + } + } +} + +/** + * @param {() => import('./types.js').MaybeSignal<>} get_value + * @param {Function} inspect + * @returns {void} + */ +// eslint-disable-next-line no-console +export function inspect(get_value, inspect = console.log) { + let initial = true; + + pre_effect(() => { + const fn = () => { + const value = get_value(); + inspect(value, initial ? 'init' : 'update'); + }; + + inspect_fn = fn; + const value = get_value(); + deep_read(value); + inspect_fn = null; + + const signals = inspect_captured_signals.slice(); + inspect_captured_signals = []; + + if (initial) { + fn(); + initial = false; + } + + return () => { + for (const s of signals) { + s.inspect.delete(fn); + } + }; + }); +} diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 1b41f6e0a4..ee7bc6eea1 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -80,6 +80,11 @@ export type SourceSignal = { v: V; }; +export type SourceSignalDebug = { + /** This is DEV only */ + inspect: Set; +}; + export type ComputationSignal = { /** block: The block associated with this effect/computed */ b: null | Block; diff --git a/packages/svelte/src/internal/index.js b/packages/svelte/src/internal/index.js index d179a3ec9e..3cdfbeaf4a 100644 --- a/packages/svelte/src/internal/index.js +++ b/packages/svelte/src/internal/index.js @@ -37,7 +37,8 @@ export { push, reactive_import, effect_active, - user_root_effect + user_root_effect, + inspect } from './client/runtime.js'; export * from './client/validate.js'; diff --git a/packages/svelte/src/main/ambient.d.ts b/packages/svelte/src/main/ambient.d.ts index 214d89e4f3..0e81faeacb 100644 --- a/packages/svelte/src/main/ambient.d.ts +++ b/packages/svelte/src/main/ambient.d.ts @@ -130,3 +130,14 @@ declare namespace $effect { * https://svelte-5-preview.vercel.app/docs/runes#$props */ declare function $props(): T; + +/** + * Logs the arguments whenever they, or the properties they contain, change. Example: + * + * ```ts + * $inspect(someValue, someOtherValue) + * ``` + * + * https://svelte-5-preview.vercel.app/docs/runes#$inspect + */ +declare function $inspect(): void; diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-trace/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-trace/_config.js new file mode 100644 index 0000000000..943b829895 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-trace/_config.js @@ -0,0 +1,39 @@ +import { test } from '../../test'; + +/** + * @type {any[]} + */ +let log; +/** + * @type {typeof console.log}} + */ +let original_log; + +export default test({ + compileOptions: { + dev: true + }, + before_test() { + log = []; + original_log = console.log; + console.log = (...v) => { + log.push(...v); + }; + }, + after_test() { + console.log = original_log; + }, + async test({ assert, target }) { + assert.deepEqual(log, []); + + const [b1, b2] = target.querySelectorAll('button'); + b1.click(); + b2.click(); + await Promise.resolve(); + + assert.ok( + log[0].stack.startsWith('Error:') && log[0].stack.includes('HTMLButtonElement.on_click') + ); + assert.deepEqual(log[1], 1); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-trace/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-trace/main.svelte new file mode 100644 index 0000000000..ca4627350d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-trace/main.svelte @@ -0,0 +1,11 @@ + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/inspect/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect/_config.js new file mode 100644 index 0000000000..00a43e404e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect/_config.js @@ -0,0 +1,34 @@ +import { test } from '../../test'; + +/** + * @type {any[]} + */ +let log; +/** + * @type {typeof console.log}} + */ +let original_log; + +export default test({ + compileOptions: { + dev: true + }, + before_test() { + log = []; + original_log = console.log; + console.log = (...v) => { + log.push(...v); + }; + }, + after_test() { + console.log = original_log; + }, + async test({ assert, target, component }) { + const [b1, b2] = target.querySelectorAll('button'); + b1.click(); + b2.click(); + await Promise.resolve(); + + assert.deepEqual(log, [0, 'init', 1, 'update']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect/main.svelte new file mode 100644 index 0000000000..739e3f8a29 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect/main.svelte @@ -0,0 +1,9 @@ + + + + diff --git a/playgrounds/sandbox/run.js b/playgrounds/sandbox/run.js index e387bd44e7..7cfe4eff63 100644 --- a/playgrounds/sandbox/run.js +++ b/playgrounds/sandbox/run.js @@ -35,6 +35,7 @@ for (const generate of ['client', 'server']) { const source = fs.readFileSync(input, 'utf-8'); const output_js = `${cwd}/output/${generate}/${file}.js`; + const output_map = `${cwd}/output/${generate}/${file}.js.map`; const output_css = `${cwd}/output/${generate}/${file}.css`; mkdirp(path.dirname(output_js)); @@ -48,12 +49,17 @@ for (const generate of ['client', 'server']) { } const compiled = compile(source, { + dev: true, filename: input, generate, runes: argv.runes }); - fs.writeFileSync(output_js, compiled.js.code); + fs.writeFileSync( + output_js, + compiled.js.code + '\n//# sourceMappingURL=' + path.basename(output_map) + ); + fs.writeFileSync(output_map, compiled.js.map.toString()); if (compiled.css) { fs.writeFileSync(output_css, compiled.css.code); } diff --git a/sites/svelte-5-preview/src/lib/CodeMirror.svelte b/sites/svelte-5-preview/src/lib/CodeMirror.svelte index 6ec16356e5..ebeeef29dd 100644 --- a/sites/svelte-5-preview/src/lib/CodeMirror.svelte +++ b/sites/svelte-5-preview/src/lib/CodeMirror.svelte @@ -205,15 +205,17 @@ return { from: word.from - 1, options: [ - { label: '$state', type: 'keyword', boost: 5 }, - { label: '$props', type: 'keyword', boost: 4 }, - { label: '$derived', type: 'keyword', boost: 3 }, - snip('$effect(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 2 }), + { label: '$state', type: 'keyword', boost: 10 }, + { label: '$props', type: 'keyword', boost: 9 }, + { label: '$derived', type: 'keyword', boost: 8 }, + snip('$effect(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 7 }), snip('$effect.pre(() => {\n\t${}\n});', { label: '$effect.pre', type: 'keyword', - boost: 1 - }) + boost: 6 + }), + { label: '$effect.active', type: 'keyword', boost: 5 }, + { label: '$inspect', type: 'keyword', boost: 4 } ] }; } diff --git a/sites/svelte-5-preview/src/lib/workers/bundler/index.js b/sites/svelte-5-preview/src/lib/workers/bundler/index.js index 15c43529cf..924f5becfc 100644 --- a/sites/svelte-5-preview/src/lib/workers/bundler/index.js +++ b/sites/svelte-5-preview/src/lib/workers/bundler/index.js @@ -182,7 +182,7 @@ async function resolve_from_pkg(pkg, subpath, uid, pkg_url_base) { const [resolved] = resolve.exports(pkg, subpath, { browser: true, - conditions: ['svelte', 'production'] + conditions: ['svelte', 'development'] }) ?? []; return resolved; diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md index 058a480d1a..e763dbecd2 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md @@ -241,6 +241,50 @@ let { a, b, c, ...everythingElse } = $props(); Note that you can still use `export const` and `export function` to expose things to users of your component (if they're using `bind:this`, for example). +## `$inspect` + +The `$inspect` rune is roughly equivalent to `console.log`, with the exception that it will re-run whenever its +argument changes. `$inspect` tracks reactive state deeply, meaning that updating something inside an object +or array using [fine-grained reactivity](/docs/fine-grained-reactivity) will cause it to re-fire. ([Demo:](/#H4sIAAAAAAAAE0WQ0W6DMAxFf8WKKhXUquyZAtIe9w1lEjS4ENU4EXFaTRH_Plq69fH6nutrOaqLIfQqP0XF7YgqV5_Oqb2SH_cQ_oYkuGhvw6Qfk8LryTipaq6FUEDbwAIlbLy0gslHevxzRvS-7fHtbQckstsnsTAbw96hliSuS_b_iTk9QpbB3RAtFntLeCDbw31AhuYJN2AnaF6BBvTQco81F9n7PC7OQcQyWNZk9LWMSQpltZbtdnP1xXrCEVmKbCWXVGHYBYGz4S6_tRSwjK-SGbJqecRoO3Mx2KlcpoDz9_wLBx9LikMBAAA=)) + +```svelte + + + + +``` + +If a callback is also provided, it will be invoked instead of `console.log`. The first argument to the callback +is the current value. The second is either `"init"` or `"update"`. [Demo:](/#H4sIAAAAAAAAE0VP24qDMBD9lSEUqlTqPlsj7ON-w1qojWM3rE5CMmkpkn_fxFL26XBuw5lVTHpGL5rvVdCwoGjEp7WiEvy0mfg7zoyJexOcykrrldOWu556npFBmUAMEnaeB8biozwlJ3k7Td6i4mILVPDGfLgE2cGaUz3rCYqsgZQS9sGO6cq-fLs9j3gNtxu6E9Q1GAcXZcibGY_sBoWXKmuPn1S6o4OnCfAYiF_lmCHmQW39v5raa2A2BIbUrNWvXIttz7bvcIjdFymHCxK39SvZpf8XM-pJ4ygadgHjOf4B8TXIiDoBAAA=) + +```svelte + + + +``` + +A convenient way to find the origin of some change is to pass `console.trace` as the second argument: + +```js +// @errors: 2304 +$inspect(stuff, console.trace); +``` + +> `$inspect` only works during development. + ## How to opt in Current Svelte code will continue to work without any adjustments. Components using the Svelte 4 syntax can use components using runes and vice versa.