pull/15844/head
Rich Harris 3 months ago
commit 3156a24161

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: untrack `$inspect.with` and add check for unsafe mutation

@ -131,7 +131,7 @@ Cannot set prototype of `$state` object
### state_unsafe_mutation
```
Updating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without `$state`
Updating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state`
```
This error occurs when state is updated while evaluating a `$derived`. You might encounter it while trying to 'derive' two pieces of state in one go:

@ -1,5 +1,17 @@
# svelte
## 5.34.7
### Patch Changes
- fix: address css class matching regression ([#16204](https://github.com/sveltejs/svelte/pull/16204))
## 5.34.6
### Patch Changes
- fix: match class and style directives against attribute selector ([#16179](https://github.com/sveltejs/svelte/pull/16179))
## 5.34.5
### Patch Changes

@ -86,7 +86,7 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long
## state_unsafe_mutation
> Updating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without `$state`
> Updating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state`
This error occurs when state is updated while evaluating a `$derived`. You might encounter it while trying to 'derive' two pieces of state in one go:

@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
"version": "5.34.5",
"version": "5.34.7",
"type": "module",
"types": "./types/index.d.ts",
"engines": {

@ -628,10 +628,11 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
if (attribute.type === 'SpreadAttribute') return true;
if (attribute.type === 'BindDirective' && attribute.name === name) return true;
const name_lower = name.toLowerCase();
// match attributes against the corresponding directive but bail out on exact matching
if (attribute.type === 'StyleDirective' && name.toLowerCase() === 'style') return true;
if (attribute.type === 'ClassDirective' && name.toLowerCase() === 'class') {
if (operator == '~=') {
if (attribute.type === 'StyleDirective' && name_lower === 'style') return true;
if (attribute.type === 'ClassDirective' && name_lower === 'class') {
if (operator === '~=') {
if (attribute.name === expected_value) return true;
} else {
return true;
@ -639,13 +640,21 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
}
if (attribute.type !== 'Attribute') continue;
if (attribute.name.toLowerCase() !== name.toLowerCase()) continue;
if (attribute.name.toLowerCase() !== name_lower) continue;
if (attribute.value === true) return operator === null;
if (expected_value === null) return true;
if (is_text_attribute(attribute)) {
return test_attribute(operator, expected_value, case_insensitive, attribute.value[0].data);
const matches = test_attribute(
operator,
expected_value,
case_insensitive,
attribute.value[0].data
);
// continue if we still may match against a class/style directive
if (!matches && (name_lower === 'class' || name_lower === 'style')) continue;
return matches;
}
const chunks = get_attribute_chunks(attribute.value);
@ -654,7 +663,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
/** @type {string[]} */
let prev_values = [];
for (const chunk of chunks) {
const current_possible_values = get_possible_values(chunk, name === 'class');
const current_possible_values = get_possible_values(chunk, name_lower === 'class');
// impossible to find out all combinations
if (!current_possible_values) return true;

@ -35,3 +35,8 @@ export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
export const STALE_REACTION = Symbol('stale reaction');
export const ELEMENT_NODE = 1;
export const TEXT_NODE = 3;
export const COMMENT_NODE = 8;
export const DOCUMENT_FRAGMENT_NODE = 11;

@ -1,4 +1,5 @@
/** @import { SourceLocation } from '#client' */
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';
@ -12,7 +13,7 @@ export function add_locations(fn, filename, locations) {
return (/** @type {any[]} */ ...args) => {
const dom = fn(...args);
var node = hydrating ? dom : dom.nodeType === 11 ? dom.firstChild : dom;
var node = hydrating ? dom : dom.nodeType === DOCUMENT_FRAGMENT_NODE ? dom.firstChild : dom;
assign_locations(node, filename, locations);
return dom;
@ -45,13 +46,13 @@ function assign_locations(node, filename, locations) {
var depth = 0;
while (node && i < locations.length) {
if (hydrating && node.nodeType === 8) {
if (hydrating && node.nodeType === COMMENT_NODE) {
var comment = /** @type {Comment} */ (node);
if (comment.data === HYDRATION_START || comment.data === HYDRATION_START_ELSE) depth += 1;
else if (comment.data[0] === HYDRATION_END) depth -= 1;
}
if (depth === 0 && node.nodeType === 1) {
if (depth === 0 && node.nodeType === ELEMENT_NODE) {
assign_location(/** @type {Element} */ (node), filename, locations[i++]);
}

@ -1,6 +1,7 @@
import { UNINITIALIZED } from '../../../constants.js';
import { snapshot } from '../../shared/clone.js';
import { inspect_effect, validate_effect } from '../reactivity/effects.js';
import { untrack } from '../runtime.js';
/**
* @param {() => any[]} get_value
@ -28,7 +29,10 @@ export function inspect(get_value, inspector = console.log) {
}
if (value !== UNINITIALIZED) {
inspector(initial ? 'init' : 'update', ...snapshot(value, true));
var snap = snapshot(value, true);
untrack(() => {
inspector(initial ? 'init' : 'update', ...snap);
});
}
initial = false;

@ -36,7 +36,7 @@ import {
} from '../../reactivity/effects.js';
import { source, mutable_source, internal_set } from '../../reactivity/sources.js';
import { array_from, is_array } from '../../../shared/utils.js';
import { INERT } from '#client/constants';
import { COMMENT_NODE, INERT } from '#client/constants';
import { queue_micro_task } from '../task.js';
import { active_effect, get } from '../../runtime.js';
import { DEV } from 'esm-env';
@ -227,7 +227,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
for (var i = 0; i < length; i++) {
if (
hydrate_node.nodeType === 8 &&
hydrate_node.nodeType === COMMENT_NODE &&
/** @type {Comment} */ (hydrate_node).data === HYDRATION_END
) {
// The server rendered fewer items than expected,

@ -10,6 +10,7 @@ import { DEV } from 'esm-env';
import { dev_current_component_function } from '../../context.js';
import { get_first_child, get_next_sibling } from '../operations.js';
import { active_effect } from '../../runtime.js';
import { COMMENT_NODE } from '#client/constants';
/**
* @param {Element} element
@ -67,7 +68,10 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning
var next = hydrate_next();
var last = next;
while (next !== null && (next.nodeType !== 8 || /** @type {Comment} */ (next).data !== '')) {
while (
next !== null &&
(next.nodeType !== COMMENT_NODE || /** @type {Comment} */ (next).data !== '')
) {
last = next;
next = /** @type {TemplateNode} */ (get_next_sibling(next));
}

@ -1,7 +1,7 @@
/** @import { Snippet } from 'svelte' */
/** @import { Effect, TemplateNode } from '#client' */
/** @import { Getters } from '#shared' */
import { EFFECT_TRANSPARENT } from '#client/constants';
import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants';
import { branch, block, destroy_effect, teardown } from '../../reactivity/effects.js';
import {
dev_current_component_function,
@ -102,7 +102,7 @@ export function createRawSnippet(fn) {
var fragment = create_fragment_from_html(html);
element = /** @type {Element} */ (get_first_child(fragment));
if (DEV && (get_next_sibling(element) !== null || element.nodeType !== 1)) {
if (DEV && (get_next_sibling(element) !== null || element.nodeType !== ELEMENT_NODE)) {
w.invalid_raw_snippet_render();
}

@ -20,7 +20,7 @@ import { current_each_item, set_current_each_item } from './each.js';
import { active_effect } from '../../runtime.js';
import { component_context } from '../../context.js';
import { DEV } from 'esm-env';
import { EFFECT_TRANSPARENT } from '#client/constants';
import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants';
import { assign_nodes } from '../template.js';
import { is_raw_text_element } from '../../../../utils.js';
@ -51,7 +51,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
/** @type {null | Element} */
var element = null;
if (hydrating && hydrate_node.nodeType === 1) {
if (hydrating && hydrate_node.nodeType === ELEMENT_NODE) {
element = /** @type {Element} */ (hydrate_node);
hydrate_next();
}

@ -2,7 +2,7 @@
import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js';
import { create_text, get_first_child, get_next_sibling } from '../operations.js';
import { block } from '../../reactivity/effects.js';
import { HEAD_EFFECT } from '#client/constants';
import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants';
import { HYDRATION_START } from '../../../../constants.js';
/**
@ -37,7 +37,8 @@ export function head(render_fn) {
while (
head_anchor !== null &&
(head_anchor.nodeType !== 8 || /** @type {Comment} */ (head_anchor).data !== HYDRATION_START)
(head_anchor.nodeType !== COMMENT_NODE ||
/** @type {Comment} */ (head_anchor).data !== HYDRATION_START)
) {
head_anchor = /** @type {TemplateNode} */ (get_next_sibling(head_anchor));
}

@ -1,5 +1,6 @@
/** @import { TemplateNode } from '#client' */
import { COMMENT_NODE } from '#client/constants';
import {
HYDRATION_END,
HYDRATION_ERROR,
@ -87,7 +88,7 @@ export function remove_nodes() {
var node = hydrate_node;
while (true) {
if (node.nodeType === 8) {
if (node.nodeType === COMMENT_NODE) {
var data = /** @type {Comment} */ (node).data;
if (data === HYDRATION_END) {
@ -109,7 +110,7 @@ export function remove_nodes() {
* @param {TemplateNode} node
*/
export function read_hydration_instruction(node) {
if (!node || node.nodeType !== 8) {
if (!node || node.nodeType !== COMMENT_NODE) {
w.hydration_mismatch();
throw HYDRATION_ERROR;
}

@ -4,8 +4,8 @@ import { DEV } from 'esm-env';
import { init_array_prototype_warnings } from '../dev/equality.js';
import { get_descriptor, is_extensible } from '../../shared/utils.js';
import { active_effect } from '../runtime.js';
import { EFFECT_RAN } from '../constants.js';
import { async_mode_flag } from '../../flags/index.js';
import { TEXT_NODE, EFFECT_RAN } from '#client/constants';
// export these for reference in the compiled code, making global name deduplication unnecessary
/** @type {Window} */
@ -116,7 +116,7 @@ export function child(node, is_text) {
// Child can be null if we have an element with a single child, like `<p>{text}</p>`, where `text` is empty
if (child === null) {
child = hydrate_node.appendChild(create_text());
} else if (is_text && child.nodeType !== 3) {
} else if (is_text && child.nodeType !== TEXT_NODE) {
var text = create_text();
child?.before(text);
set_hydrate_node(text);
@ -146,7 +146,7 @@ export function first_child(fragment, is_text) {
// if an {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (is_text && hydrate_node?.nodeType !== 3) {
if (is_text && hydrate_node?.nodeType !== TEXT_NODE) {
var text = create_text();
hydrate_node?.before(text);
@ -177,11 +177,9 @@ export function sibling(node, count = 1, is_text = false) {
return next_sibling;
}
var type = next_sibling?.nodeType;
// if a sibling {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (is_text && type !== 3) {
if (is_text && next_sibling?.nodeType !== TEXT_NODE) {
var text = create_text();
// If the next sibling is `null` and we're handling text then it's because
// the SSR content was empty for the text, so we need to generate a new text

@ -20,6 +20,7 @@ import {
TEMPLATE_USE_MATHML,
TEMPLATE_USE_SVG
} from '../../../constants.js';
import { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, TEXT_NODE } from '#client/constants';
/**
* @param {TemplateNode} start
@ -264,7 +265,7 @@ function run_scripts(node) {
// scripts were SSR'd, in which case they will run
if (hydrating) return node;
const is_fragment = node.nodeType === 11;
const is_fragment = node.nodeType === DOCUMENT_FRAGMENT_NODE;
const scripts =
/** @type {HTMLElement} */ (node).tagName === 'SCRIPT'
? [/** @type {HTMLScriptElement} */ (node)]
@ -305,7 +306,7 @@ export function text(value = '') {
var node = hydrate_node;
if (node.nodeType !== 3) {
if (node.nodeType !== TEXT_NODE) {
// if an {expression} is empty during SSR, we need to insert an empty text node
node.before((node = create_text()));
set_hydrate_node(node);
@ -360,7 +361,7 @@ export function props_id() {
if (
hydrating &&
hydrate_node &&
hydrate_node.nodeType === 8 &&
hydrate_node.nodeType === COMMENT_NODE &&
hydrate_node.textContent?.startsWith(`#`)
) {
const id = hydrate_node.textContent.substring(1);

@ -322,12 +322,12 @@ export function state_prototype_fixed() {
}
/**
* Updating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without `$state`
* Updating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state`
* @returns {never}
*/
export function state_unsafe_mutation() {
if (DEV) {
const error = new Error(`state_unsafe_mutation\nUpdating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without \`$state\`\nhttps://svelte.dev/e/state_unsafe_mutation`);
const error = new Error(`state_unsafe_mutation\nUpdating state inside \`$derived(...)\`, \`$inspect(...)\` or a template expression is forbidden. If the value should not be reactive, declare it without \`$state\`\nhttps://svelte.dev/e/state_unsafe_mutation`);
error.name = 'Svelte error';
throw error;

@ -140,9 +140,11 @@ export function mutate(source, value) {
export function set(source, value, should_proxy = false) {
if (
active_reaction !== null &&
!untracking &&
// since we are untracking the function inside `$inspect.with` we need to add this check
// to ensure we error if state is set inside an inspect effect
(!untracking || (active_reaction.f & INSPECT_EFFECT) !== 0) &&
is_runes() &&
(active_reaction.f & (DERIVED | BLOCK_EFFECT | EFFECT_ASYNC)) !== 0 &&
(active_reaction.f & (DERIVED | BLOCK_EFFECT | EFFECT_ASYNC | INSPECT_EFFECT)) !== 0 &&
!(reaction_sources?.[1].includes(source) && reaction_sources[0] === active_reaction)
) {
e.state_unsafe_mutation();

@ -31,6 +31,7 @@ import * as e from './errors.js';
import { assign_nodes } from './dom/template.js';
import { is_passive_event } from '../../utils.js';
import { async_mode_flag } from '../flags/index.js';
import { COMMENT_NODE } from './constants.js';
/**
* This is normally true block effects should run their intro transitions
@ -108,7 +109,7 @@ export function hydrate(component, options) {
var anchor = /** @type {TemplateNode} */ (get_first_child(target));
while (
anchor &&
(anchor.nodeType !== 8 || /** @type {Comment} */ (anchor).data !== HYDRATION_START)
(anchor.nodeType !== COMMENT_NODE || /** @type {Comment} */ (anchor).data !== HYDRATION_START)
) {
anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor));
}
@ -125,7 +126,7 @@ export function hydrate(component, options) {
if (
hydrate_node === null ||
hydrate_node.nodeType !== 8 ||
hydrate_node.nodeType !== COMMENT_NODE ||
/** @type {Comment} */ (hydrate_node).data !== HYDRATION_END
) {
w.hydration_mismatch();

@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
export const VERSION = '5.34.5';
export const VERSION = '5.34.7';
export const PUBLIC_VERSION = '5';

@ -4,16 +4,16 @@ export default test({
warnings: [
{
code: 'css_unused_selector',
message: 'Unused CSS selector ".third"\nhttps://svelte.dev/e/css_unused_selector',
message: 'Unused CSS selector ".forth"\nhttps://svelte.dev/e/css_unused_selector',
start: {
line: 6,
line: 8,
column: 2,
character: 115
character: 190
},
end: {
line: 6,
line: 8,
column: 8,
character: 121
character: 196
}
}
]

@ -1,3 +1,5 @@
.first.svelte-xyz { color: green }
.zero.first.svelte-xyz { color: green }
.second.svelte-xyz { color: green }
/* (unused) .third { color: red }*/
.third.svelte-xyz { color: green }
/* (unused) .forth { color: red }*/

@ -1,7 +1,9 @@
<div class:first={true} class:second={true}></div>
<div class="zero" class:first={true}></div>
<div class:second={true} class:third={true}></div>
<style>
.first { color: green }
.zero.first { color: green }
.second { color: green }
.third { color: red }
</style>
.third { color: green }
.forth { color: red }
</style>

@ -1,3 +1,4 @@
import { COMMENT_NODE, ELEMENT_NODE, TEXT_NODE } from '#client/constants';
import { assert } from 'vitest';
/**
@ -35,7 +36,7 @@ function clean_children(node, opts) {
});
for (let child of [...node.childNodes]) {
if (child.nodeType === 3) {
if (child.nodeType === TEXT_NODE) {
let text = /** @type {Text} */ (child);
if (
@ -49,7 +50,7 @@ function clean_children(node, opts) {
text.data = text.data.replace(/[^\S]+/g, ' ');
if (previous && previous.nodeType === 3) {
if (previous && previous.nodeType === TEXT_NODE) {
const prev = /** @type {Text} */ (previous);
prev.data += text.data;
@ -62,22 +63,22 @@ function clean_children(node, opts) {
}
}
if (child.nodeType === 8 && !opts.preserveComments) {
if (child.nodeType === COMMENT_NODE && !opts.preserveComments) {
// comment
child.remove();
continue;
}
// add newlines for better readability and potentially recurse into children
if (child.nodeType === 1 || child.nodeType === 8) {
if (previous?.nodeType === 3) {
if (child.nodeType === ELEMENT_NODE || child.nodeType === COMMENT_NODE) {
if (previous?.nodeType === TEXT_NODE) {
const prev = /** @type {Text} */ (previous);
prev.data = prev.data.replace(/^[^\S]+$/, '\n');
} else if (previous?.nodeType === 1 || previous?.nodeType === 8) {
} else if (previous?.nodeType === ELEMENT_NODE || previous?.nodeType === COMMENT_NODE) {
node.insertBefore(document.createTextNode('\n'), child);
}
if (child.nodeType === 1) {
if (child.nodeType === ELEMENT_NODE) {
has_element_children = true;
clean_children(/** @type {Element} */ (child), opts);
}
@ -87,12 +88,12 @@ function clean_children(node, opts) {
}
// collapse whitespace
if (node.firstChild && node.firstChild.nodeType === 3) {
if (node.firstChild && node.firstChild.nodeType === TEXT_NODE) {
const text = /** @type {Text} */ (node.firstChild);
text.data = text.data.trimStart();
}
if (node.lastChild && node.lastChild.nodeType === 3) {
if (node.lastChild && node.lastChild.nodeType === TEXT_NODE) {
const text = /** @type {Text} */ (node.lastChild);
text.data = text.data.trimEnd();
}

@ -1,5 +1,8 @@
/** @import { assert } from 'vitest' */
/** @import { CompileOptions, Warning } from '#compiler' */
import { ELEMENT_NODE } from '#client/constants';
/**
* @param {any} a
* @param {any} b
@ -102,7 +105,7 @@ function normalize_children(node) {
}
for (let child of [...node.childNodes]) {
if (child.nodeType === 1 /* Element */) {
if (child.nodeType === ELEMENT_NODE) {
normalize_children(child);
}
}

@ -1,3 +1,4 @@
import { COMMENT_NODE } from '#client/constants';
import { ok, test } from '../../test';
export default test({
@ -41,7 +42,7 @@ export default test({
// get all childNodes of template3 except comments
let childNodes = [];
for (const node of template3.content.childNodes) {
if (node.nodeType !== 8) {
if (node.nodeType !== COMMENT_NODE) {
childNodes.push(/** @type {Element} */ (node));
}
}

@ -0,0 +1,9 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
error: 'state_unsafe_mutation'
});

@ -0,0 +1,10 @@
<script>
let a = $state(0);
let b = $state(0);
$inspect(a).with((...args)=>{
console.log(...args);
b++;
});
</script>

@ -0,0 +1,20 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
async test({ assert, target, logs }) {
const [a, b] = target.querySelectorAll('button');
assert.deepEqual(logs, ['init', 0]);
flushSync(() => {
b?.click();
});
assert.deepEqual(logs, ['init', 0]);
flushSync(() => {
a?.click();
});
assert.deepEqual(logs, ['init', 0, 'update', 1]);
}
});

@ -0,0 +1,13 @@
<script>
let a = $state(0);
let b = $state(0);
$inspect(a).with((...args)=>{
console.log(...args);
b;
});
</script>
<button onclick={()=>a++}></button>
<button onclick={()=>b++}></button>

@ -1076,6 +1076,41 @@ describe('signals', () => {
};
});
test('nested effects depend on state of upper effects', () => {
const logs: number[] = [];
user_effect(() => {
const raw = state(0);
const proxied = proxy({ current: 0 });
// We need those separate, else one working and rerunning the effect
// could mask the other one not rerunning
user_effect(() => {
logs.push($.get(raw));
});
user_effect(() => {
logs.push(proxied.current);
});
// Important so that the updating effect is not running
// together with the reading effects
flushSync();
user_effect(() => {
$.untrack(() => {
set(raw, $.get(raw) + 1);
proxied.current += 1;
});
});
});
return () => {
flushSync();
assert.deepEqual(logs, [0, 0, 1, 1]);
};
});
test('proxy version state does not trigger self-dependency guard', () => {
return () => {
const s = proxy({ a: { b: 1 } });

Loading…
Cancel
Save