diff --git a/.changeset/early-taxis-allow.md b/.changeset/early-taxis-allow.md new file mode 100644 index 0000000000..6539b0cb6b --- /dev/null +++ b/.changeset/early-taxis-allow.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: provide guidance in browser console when logging $state objects diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index 4266f75184..24355cae54 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -4,6 +4,14 @@ > `%binding%` (%location%) is binding to a non-reactive property +## console_log_state + +> Your `console.%method%` contained `$state` proxies. Consider using `$inspect(...)` or `$state.snapshot(...)` instead + +When logging a [proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy), browser devtools will log the proxy itself rather than the value it represents. In the case of Svelte, the 'target' of a `$state` proxy might not resemble its current value, which can be confusing. + +The easiest way to log a value as it changes over time is to use the [`$inspect`](https://svelte-5-preview.vercel.app/docs/runes#$inspect) rune. Alternatively, to log things on a one-off basis (for example, inside an event handler) you can use [`$state.snapshot`](https://svelte-5-preview.vercel.app/docs/runes#$state-snapshot) to take a snapshot of the current value. + ## event_handler_invalid > %handler% should be a function. Did you mean to %suggestion%? diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js index 124b5cd269..7a3057451a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js @@ -1,6 +1,6 @@ /** @import { CallExpression, Expression } from 'estree' */ /** @import { Context } from '../types' */ -import { is_ignored } from '../../../../state.js'; +import { dev, is_ignored } from '../../../../state.js'; import * as b from '../../../../utils/builders.js'; import { get_rune } from '../../../scope.js'; import { transform_inspect_rune } from '../../utils.js'; @@ -35,5 +35,28 @@ export function CallExpression(node, context) { return transform_inspect_rune(node, context); } + if ( + dev && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'console' && + context.state.scope.get('console') === null && + node.callee.property.type === 'Identifier' && + ['debug', 'dir', 'error', 'group', 'groupCollapsed', 'info', 'log', 'trace', 'warn'].includes( + node.callee.property.name + ) + ) { + return b.call( + node.callee, + b.spread( + b.call( + '$.log_if_contains_state', + b.literal(node.callee.property.name), + .../** @type {Expression[]} */ (node.arguments.map((arg) => context.visit(arg))) + ) + ) + ); + } + context.next(); } diff --git a/packages/svelte/src/internal/client/dev/console-log.js b/packages/svelte/src/internal/client/dev/console-log.js new file mode 100644 index 0000000000..52a282f6d4 --- /dev/null +++ b/packages/svelte/src/internal/client/dev/console-log.js @@ -0,0 +1,30 @@ +import { STATE_SYMBOL } from '../constants.js'; +import { snapshot } from '../../shared/clone.js'; +import * as w from '../warnings.js'; + +/** + * @param {string} method + * @param {...any} objects + */ +export function log_if_contains_state(method, ...objects) { + let has_state = false; + const transformed = []; + + for (const obj of objects) { + if (obj && typeof obj === 'object' && STATE_SYMBOL in obj) { + transformed.push(snapshot(obj, true)); + has_state = true; + } else { + transformed.push(obj); + } + } + + if (has_state) { + w.console_log_state(method); + + // eslint-disable-next-line no-console + console.log('%c[snapshot]', 'color: grey', ...transformed); + } + + return objects; +} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 519a412486..0fa623322e 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -164,3 +164,4 @@ export { validate_void_dynamic_element } from '../shared/validate.js'; export { strict_equals, equals } from './dev/equality.js'; +export { log_if_contains_state } from './dev/console-log.js'; diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js index 36d7345b86..c45a92cdde 100644 --- a/packages/svelte/src/internal/client/warnings.js +++ b/packages/svelte/src/internal/client/warnings.js @@ -19,6 +19,19 @@ export function binding_property_non_reactive(binding, location) { } } +/** + * Your `console.%method%` contained `$state` proxies. Consider using `$inspect(...)` or `$state.snapshot(...)` instead + * @param {string} method + */ +export function console_log_state(method) { + if (DEV) { + console.warn(`%c[svelte] console_log_state\n%cYour \`console.${method}\` contained \`$state\` proxies. Consider using \`$inspect(...)\` or \`$state.snapshot(...)\` instead`, bold, normal); + } else { + // TODO print a link to the documentation + console.warn("console_log_state"); + } +} + /** * %handler% should be a function. Did you mean to %suggestion%? * @param {string} handler diff --git a/packages/svelte/src/internal/shared/clone.js b/packages/svelte/src/internal/shared/clone.js index e00f3e0926..bfdc9af263 100644 --- a/packages/svelte/src/internal/shared/clone.js +++ b/packages/svelte/src/internal/shared/clone.js @@ -18,15 +18,15 @@ const empty = []; * @returns {Snapshot} */ export function snapshot(value, skip_warning = false) { - if (DEV) { + if (DEV && !skip_warning) { /** @type {string[]} */ const paths = []; const copy = clone(value, new Map(), '', paths); - if (paths.length === 1 && paths[0] === '' && !skip_warning) { + if (paths.length === 1 && paths[0] === '') { // value could not be cloned w.state_snapshot_uncloneable(); - } else if (paths.length > 0 && !skip_warning) { + } else if (paths.length > 0) { // some properties could not be cloned const slice = paths.length > 10 ? paths.slice(0, 7) : paths.slice(0, 10); const excess = paths.length - slice.length;