Merge branch 'main' into print

print
Rich Harris 2 months ago
commit 1377c40dba

@ -7,6 +7,7 @@ packages/**/config/*.js
# packages/svelte
packages/svelte/messages/**/*.md
packages/svelte/scripts/_bundle.js
packages/svelte/src/compiler/errors.js
packages/svelte/src/compiler/warnings.js
packages/svelte/src/internal/client/errors.js
@ -25,8 +26,7 @@ packages/svelte/tests/hydration/samples/*/_expected.html
packages/svelte/tests/hydration/samples/*/_override.html
packages/svelte/types
packages/svelte/compiler/index.js
playgrounds/sandbox/input/**.svelte
playgrounds/sandbox/output
playgrounds/sandbox/src/*
# sites/svelte.dev
sites/svelte.dev/static/svelte-app.json

@ -83,7 +83,7 @@ If you're using tools like Rollup or Webpack instead, install their respective S
When using TypeScript, make sure your `tsconfig.json` is setup correctly.
- Use a [`target`](https://www.typescriptlang.org/tsconfig/#target) of at least `ES2022`, or a `target` of at least `ES2015` alongside [`useDefineForClassFields`](https://www.typescriptlang.org/tsconfig/#useDefineForClassFields). This ensures that rune declarations on class fields are not messed with, which would break the Svelte compiler
- Use a [`target`](https://www.typescriptlang.org/tsconfig/#target) of at least `ES2015` so classes are not compiled to functions
- Set [`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax) to `true` so that imports are left as-is
- Set [`isolatedModules`](https://www.typescriptlang.org/tsconfig/#isolatedModules) to `true` so that each file is looked at in isolation. TypeScript has a few features which require cross-file analysis and compilation, which the Svelte compiler and tooling like Vite don't do.

@ -87,6 +87,7 @@ export default [
'**/*.d.ts',
'**/tests',
'packages/svelte/scripts/process-messages/templates/*.js',
'packages/svelte/scripts/_bundle.js',
'packages/svelte/src/compiler/errors.js',
'packages/svelte/src/internal/client/errors.js',
'packages/svelte/src/internal/client/warnings.js',

@ -1,5 +1,11 @@
# svelte
## 5.34.9
### Patch Changes
- fix: ensure unowned deriveds can add themselves as reactions while connected ([#16249](https://github.com/sveltejs/svelte/pull/16249))
## 5.34.8
### Patch Changes

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

@ -118,36 +118,40 @@ const bundle = await bundle_code(
).js.code
);
if (!bundle.includes('hydrate_node') && !bundle.includes('hydrate_next')) {
// eslint-disable-next-line no-console
console.error(`✅ Hydration code treeshakeable`);
} else {
failed = true;
// eslint-disable-next-line no-console
console.error(`❌ Hydration code not treeshakeable`);
}
/**
* @param {string} case_name
* @param {string[]} strings
*/
function check_bundle(case_name, ...strings) {
for (const string of strings) {
const index = bundle.indexOf(string);
if (index >= 0) {
// eslint-disable-next-line no-console
console.error(`${case_name} not treeshakeable`);
failed = true;
if (!bundle.includes('component_context.l')) {
// eslint-disable-next-line no-console
console.error(`✅ Legacy code treeshakeable`);
} else {
failed = true;
let lines = bundle.slice(index - 500, index + 500).split('\n');
const target_line = lines.findIndex((line) => line.includes(string));
// mark the failed line
lines = lines
.map((line, i) => (i === target_line ? `> ${line}` : `| ${line}`))
.slice(target_line - 5, target_line + 6);
// eslint-disable-next-line no-console
console.error('The first failed line:\n' + lines.join('\n'));
return;
}
}
// eslint-disable-next-line no-console
console.error(`❌ Legacy code not treeshakeable`);
console.error(`${case_name} treeshakeable`);
}
if (!bundle.includes(`'CreatedAt'`)) {
// eslint-disable-next-line no-console
console.error(`$inspect.trace code treeshakeable`);
} else {
failed = true;
// eslint-disable-next-line no-console
console.error(`$inspect.trace code not treeshakeable`);
}
check_bundle('Hydration code', 'hydrate_node', 'hydrate_next');
check_bundle('Legacy code', 'component_context.l');
check_bundle('$inspect.trace', `'CreatedAt'`);
if (failed) {
// eslint-disable-next-line no-console
console.error(bundle);
console.error('Full bundle at', path.resolve('scripts/_bundle.js'));
fs.writeFileSync('scripts/_bundle.js', bundle);
}

@ -3,7 +3,6 @@
/** @import { AST } from './public.js' */
import { walk as zimmerframe_walk } from 'zimmerframe';
import { convert } from './legacy.js';
import { parse as parse_acorn } from './phases/1-parse/acorn.js';
import { parse as _parse } from './phases/1-parse/index.js';
import { remove_typescript_nodes } from './phases/1-parse/remove_typescript_nodes.js';
import { analyze_component, analyze_module } from './phases/2-analyze/index.js';

@ -22,13 +22,7 @@ import {
build_set_style
} from './shared/element.js';
import { process_children } from './shared/fragment.js';
import {
build_render_statement,
build_template_chunk,
build_update_assignment,
get_expression_id,
memoize_expression
} from './shared/utils.js';
import { build_render_statement, build_template_chunk, get_expression_id } from './shared/utils.js';
import { visit_event_attribute } from './shared/events.js';
/**
@ -200,16 +194,16 @@ export function RegularElement(node, context) {
const node_id = context.state.node;
/** If true, needs `__value` for inputs */
const needs_special_value_handling =
node.name === 'option' ||
node.name === 'select' ||
bindings.has('group') ||
bindings.has('checked');
if (has_spread) {
build_attribute_effect(attributes, class_directives, style_directives, context, node, node_id);
} else {
/** If true, needs `__value` for inputs */
const needs_special_value_handling =
node.name === 'option' ||
node.name === 'select' ||
bindings.has('group') ||
bindings.has('checked');
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (is_event_attribute(attribute)) {
visit_event_attribute(attribute, context);
@ -217,7 +211,6 @@ export function RegularElement(node, context) {
}
if (needs_special_value_handling && attribute.name === 'value') {
build_element_special_value_attribute(node.name, node_id, attribute, context);
continue;
}
@ -392,6 +385,15 @@ export function RegularElement(node, context) {
context.state.update.push(b.stmt(b.assignment('=', dir, dir)));
}
if (!has_spread && needs_special_value_handling) {
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (attribute.name === 'value') {
build_element_special_value_attribute(node.name, node_id, attribute, context);
break;
}
}
}
context.state.template.pop_element();
}
@ -622,12 +624,7 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
element === 'select' && attribute.value !== true && !is_text_attribute(attribute);
const { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) =>
metadata.has_call
? // if is a select with value we will also invoke `init_select` which need a reference before the template effect so we memoize separately
is_select_with_value
? memoize_expression(state, value)
: get_expression_id(state.expressions, value)
: value
metadata.has_call ? get_expression_id(state.expressions, value) : value
);
const evaluated = context.state.scope.evaluate(value);
@ -652,23 +649,21 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
: inner_assignment
);
if (is_select_with_value) {
state.init.push(b.stmt(b.call('$.init_select', node_id, b.thunk(value))));
}
if (has_state) {
const id = state.scope.generate(`${node_id.name}_value`);
build_update_assignment(
state,
id,
// `<option>` is a special case: The value property reflects to the DOM. If the value is set to undefined,
// that means the value should be set to the empty string. To be able to do that when the value is
// initially undefined, we need to set a value that is guaranteed to be different.
element === 'option' ? b.object([]) : undefined,
value,
update
);
const id = b.id(state.scope.generate(`${node_id.name}_value`));
// `<option>` is a special case: The value property reflects to the DOM. If the value is set to undefined,
// that means the value should be set to the empty string. To be able to do that when the value is
// initially undefined, we need to set a value that is guaranteed to be different.
const init = element === 'option' ? b.object([]) : undefined;
state.init.push(b.var(id, init));
state.update.push(b.if(b.binary('!==', id, b.assignment('=', id, value)), b.block([update])));
} else {
state.init.push(update);
}
if (is_select_with_value) {
state.init.push(b.stmt(b.call('$.init_select', node_id)));
}
}

@ -165,20 +165,6 @@ export function parse_directive_name(name) {
return expression;
}
/**
* @param {ComponentClientTransformState} state
* @param {string} id
* @param {Expression | undefined} init
* @param {Expression} value
* @param {ExpressionStatement} update
*/
export function build_update_assignment(state, id, init, value, update) {
state.init.push(b.var(id, init));
state.update.push(
b.if(b.binary('!==', b.id(id), b.assignment('=', b.id(id), value)), b.block([update]))
);
}
/**
* Serializes `bind:this` for components and elements.
* @param {Identifier | MemberExpression | SequenceExpression} expression

@ -20,7 +20,7 @@ import { clsx } from '../../../shared/attributes.js';
import { set_class } from './class.js';
import { set_style } from './style.js';
import { ATTACHMENT_KEY, NAMESPACE_HTML } from '../../../../constants.js';
import { block, branch, destroy_effect } from '../../reactivity/effects.js';
import { block, branch, destroy_effect, effect } from '../../reactivity/effects.js';
import { derived } from '../../reactivity/deriveds.js';
import { init_select, select_option } from './bindings/select.js';
@ -513,10 +513,12 @@ export function attribute_effect(
});
if (is_select) {
init_select(
/** @type {HTMLSelectElement} */ (element),
() => /** @type {Record<string | symbol, any>} */ (prev).value
);
var select = /** @type {HTMLSelectElement} */ (element);
effect(() => {
select_option(select, /** @type {Record<string | symbol, any>} */ (prev).value);
init_select(select);
});
}
inited = true;

@ -1,6 +1,5 @@
import { effect } from '../../../reactivity/effects.js';
import { effect, teardown } from '../../../reactivity/effects.js';
import { listen_to_event_and_reset_event } from './shared.js';
import { untrack } from '../../../runtime.js';
import { is } from '../../../proxy.js';
import { is_array } from '../../../../shared/utils.js';
import * as w from '../../../warnings.js';
@ -51,40 +50,29 @@ export function select_option(select, value, mounting) {
* current selection to the dom when it changes. Such
* changes could for example occur when options are
* inside an `#each` block.
* @template V
* @param {HTMLSelectElement} select
* @param {() => V} [get_value]
*/
export function init_select(select, get_value) {
let mounting = true;
effect(() => {
if (get_value) {
select_option(select, untrack(get_value), mounting);
}
mounting = false;
export function init_select(select) {
var observer = new MutationObserver(() => {
// @ts-ignore
select_option(select, select.__value);
// Deliberately don't update the potential binding value,
// the model should be preserved unless explicitly changed
});
observer.observe(select, {
// Listen to option element changes
childList: true,
subtree: true, // because of <optgroup>
// Listen to option element value attribute changes
// (doesn't get notified of select value changes,
// because that property is not reflected as an attribute)
attributes: true,
attributeFilter: ['value']
});
var observer = new MutationObserver(() => {
// @ts-ignore
var value = select.__value;
select_option(select, value);
// Deliberately don't update the potential binding value,
// the model should be preserved unless explicitly changed
});
observer.observe(select, {
// Listen to option element changes
childList: true,
subtree: true, // because of <optgroup>
// Listen to option element value attribute changes
// (doesn't get notified of select value changes,
// because that property is not reflected as an attribute)
attributes: true,
attributeFilter: ['value']
});
return () => {
observer.disconnect();
};
teardown(() => {
observer.disconnect();
});
}
@ -136,7 +124,6 @@ export function bind_select_value(select, get, set = get) {
mounting = false;
});
// don't pass get_value, we already initialize it in the effect above
init_select(select);
}

@ -294,7 +294,12 @@ export function update_reaction(reaction) {
reaction.deps = deps = new_deps;
}
if (!skip_reaction) {
if (
!skip_reaction ||
// Deriveds that already have reactions can cleanup, so we still add them as reactions
((flags & DERIVED) !== 0 &&
/** @type {import('#client').Derived} */ (reaction).reactions !== null)
) {
for (i = skipped_deps; i < deps.length; i++) {
(deps[i].reactions ??= []).push(reaction);
}

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

@ -5,5 +5,9 @@ export default test({
compileOptions: {
dev: true
},
error: 'state_unsafe_mutation'
error: 'state_unsafe_mutation',
// silence the logs
test({ logs }) {}
});

@ -6,7 +6,7 @@ export default test({
compileOptions: {
runes: undefined
},
async test({ assert, target }) {
async test({ assert, target, logs }) {
const p = target.querySelector('p');
const btn = target.querySelector('button');
flushSync(() => {

@ -112,6 +112,45 @@ describe('signals', () => {
};
});
test('unowned deriveds are not added as reactions but trigger effects', () => {
var obj = state<any>(undefined);
class C1 {
#v = state(0);
get v() {
return $.get(this.#v);
}
set v(v: number) {
set(this.#v, v);
}
}
return () => {
let d = derived(() => $.get(obj)?.v || '-');
const log: number[] = [];
assert.equal($.get(d), '-');
let destroy = effect_root(() => {
render_effect(() => {
log.push($.get(d));
});
});
set(obj, new C1());
flushSync();
assert.equal($.get(d), '-');
$.get(obj).v = 1;
flushSync();
assert.equal($.get(d), 1);
assert.deepEqual(log, ['-', 1]);
destroy();
// ensure we're not leaking reactions
assert.equal(obj.reactions, null);
assert.equal(d.reactions, null);
};
});
test('derived from state', () => {
const log: number[] = [];

Loading…
Cancel
Save