svelte 5.46.3 → 5.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "svelte",
3
3
  "description": "Cybernetically enhanced web apps",
4
4
  "license": "MIT",
5
- "version": "5.46.3",
5
+ "version": "5.47.0",
6
6
  "type": "module",
7
7
  "types": "./types/index.d.ts",
8
8
  "engines": {
@@ -163,7 +163,7 @@
163
163
  "aria-query": "^5.3.1",
164
164
  "axobject-query": "^4.1.0",
165
165
  "clsx": "^2.1.1",
166
- "devalue": "^5.5.0",
166
+ "devalue": "^5.6.2",
167
167
  "esm-env": "^1.2.1",
168
168
  "esrap": "^2.2.1",
169
169
  "is-reference": "^3.0.3",
@@ -770,7 +770,39 @@ function get_ancestor_elements(node, adjacent_only, seen = new Set()) {
770
770
  }
771
771
 
772
772
  if (parent.type === 'RegularElement' || parent.type === 'SvelteElement') {
773
+ // Special handling for <option> inside <select>: elements inside <option> should
774
+ // also be considered descendants of <selectedcontent>, which clones the selected option's content
775
+ if (parent.type === 'RegularElement' && parent.name === 'option') {
776
+ const is_direct_child = ancestors.length === 0;
777
+
778
+ const select_element = path.findLast(
779
+ (element, j) => element.type === 'RegularElement' && element.name === 'select' && j < i
780
+ );
781
+
782
+ if (select_element && (!adjacent_only || is_direct_child)) {
783
+ /** @type {Compiler.AST.RegularElement | null} */
784
+ let selectedcontent_element = null;
785
+ walk(select_element, null, {
786
+ RegularElement(child, context) {
787
+ if (child.name === 'selectedcontent') {
788
+ selectedcontent_element = child;
789
+ context.stop();
790
+ return;
791
+ }
792
+ context.next();
793
+ }
794
+ });
795
+
796
+ if (adjacent_only && is_direct_child && selectedcontent_element) {
797
+ return [selectedcontent_element, parent];
798
+ } else if (selectedcontent_element) {
799
+ ancestors.push(selectedcontent_element);
800
+ }
801
+ }
802
+ }
803
+
773
804
  ancestors.push(parent);
805
+
774
806
  if (adjacent_only) {
775
807
  break;
776
808
  }
@@ -817,6 +849,34 @@ function get_descendant_elements(node, adjacent_only, seen = new Set()) {
817
849
 
818
850
  walk_children(node.type === 'RenderTag' ? node : node.fragment);
819
851
 
852
+ // Special handling for <selectedcontent>: it clones the content of the selected <option>,
853
+ // so descendants of <option> elements in the same <select> should also be considered descendants
854
+ if (node.type === 'RegularElement' && node.name === 'selectedcontent') {
855
+ const path = node.metadata.path;
856
+ const select_element = path.findLast(
857
+ (/** @type {Compiler.AST.SvelteNode} */ element) =>
858
+ element.type === 'RegularElement' && element.name === 'select'
859
+ );
860
+
861
+ if (select_element) {
862
+ walk(
863
+ select_element,
864
+ { inside_option: false },
865
+ {
866
+ _(child, context) {
867
+ if (child.type === 'RegularElement' && child.name === 'option') {
868
+ context.next({ inside_option: true });
869
+ } else if (context.state.inside_option) {
870
+ walk_children(child);
871
+ } else {
872
+ context.next();
873
+ }
874
+ }
875
+ }
876
+ );
877
+ }
878
+ }
879
+
820
880
  return descendants;
821
881
  }
822
882
 
@@ -7,7 +7,11 @@ import {
7
7
  } from '../../../../html-tree-validation.js';
8
8
  import * as e from '../../../errors.js';
9
9
  import * as w from '../../../warnings.js';
10
- import { create_attribute, is_custom_element_node } from '../../nodes.js';
10
+ import {
11
+ create_attribute,
12
+ is_custom_element_node,
13
+ is_customizable_select_element
14
+ } from '../../nodes.js';
11
15
  import { regex_starts_with_newline } from '../../patterns.js';
12
16
  import { check_element } from './shared/a11y/index.js';
13
17
  import { validate_element } from './shared/element.js';
@@ -74,6 +78,15 @@ export function RegularElement(node, context) {
74
78
  node.metadata.synthetic_value_node = child;
75
79
  }
76
80
 
81
+ // Special case: <select>, <option> or <optgroup> with rich content needs special hydration handling
82
+ // We mark the subtree as dynamic so parent elements properly include the child init code
83
+ if (is_customizable_select_element(node)) {
84
+ // Mark the element's own fragment as dynamic so it's not treated as static
85
+ node.fragment.metadata.dynamic = true;
86
+ // Also mark ancestor fragments so parents properly include the child init code
87
+ mark_subtree_dynamic(context.path);
88
+ }
89
+
77
90
  const binding = context.state.scope.get(node.name);
78
91
  if (
79
92
  binding !== null &&
@@ -1,6 +1,7 @@
1
1
  /** @import { AST } from '#compiler' */
2
2
  /** @import { Context } from '../types' */
3
3
  import * as e from '../../../errors.js';
4
+ import { mark_subtree_dynamic } from './shared/fragment.js';
4
5
 
5
6
  const valid = ['onerror', 'failed', 'pending'];
6
7
 
@@ -23,5 +24,7 @@ export function SvelteBoundary(node, context) {
23
24
  }
24
25
  }
25
26
 
27
+ mark_subtree_dynamic(context.path);
28
+
26
29
  context.next();
27
30
  }
@@ -831,6 +831,10 @@ function has_content(element) {
831
831
  return true;
832
832
  }
833
833
 
834
+ if (node.name === 'selectedcontent') {
835
+ return true;
836
+ }
837
+
834
838
  if (!has_content(node)) {
835
839
  continue;
836
840
  }
@@ -11,7 +11,12 @@ import {
11
11
  import { is_ignored } from '../../../../state.js';
12
12
  import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js';
13
13
  import * as b from '#compiler/builders';
14
- import { create_attribute, ExpressionMetadata, is_custom_element_node } from '../../../nodes.js';
14
+ import {
15
+ create_attribute,
16
+ ExpressionMetadata,
17
+ is_custom_element_node,
18
+ is_customizable_select_element
19
+ } from '../../../nodes.js';
15
20
  import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
16
21
  import { build_getter } from '../utils.js';
17
22
  import {
@@ -21,9 +26,12 @@ import {
21
26
  build_set_class,
22
27
  build_set_style
23
28
  } from './shared/element.js';
24
- import { process_children } from './shared/fragment.js';
29
+ import { process_children, is_static_element } from './shared/fragment.js';
25
30
  import { build_render_statement, build_template_chunk, Memoizer } from './shared/utils.js';
26
31
  import { visit_event_attribute } from './shared/events.js';
32
+ import { Template } from '../transform-template/template.js';
33
+ import { transform_template } from '../transform-template/index.js';
34
+ import { TEMPLATE_FRAGMENT } from '../../../../../constants.js';
27
35
 
28
36
  /**
29
37
  * @param {AST.RegularElement} node
@@ -351,13 +359,65 @@ export function RegularElement(node, context) {
351
359
  b.stmt(b.assignment('=', b.member(context.state.node, 'textContent'), value))
352
360
  );
353
361
  }
362
+ } else if (is_customizable_select_element(node)) {
363
+ // For <option>, <optgroup>, or <select> elements with rich content, we need to branch based on browser support.
364
+ // Modern browsers preserve rich HTML in options, older browsers strip it to text only.
365
+ // We create a separate template for the rich content and append it to the element.
366
+
367
+ const element_node = context.state.node;
368
+
369
+ // Add a hydration marker inside the option element so $.child() has an anchor to find
370
+ context.state.template.push_comment();
371
+
372
+ // Create a separate template for the rich content
373
+ const template_name = context.state.scope.root.unique(`${node.name}_content`);
374
+ const fragment_id = b.id(context.state.scope.generate('fragment'));
375
+ const anchor_id = b.id(context.state.scope.generate('anchor'));
376
+
377
+ // Create state with a new template for the rich content
378
+ /** @type {typeof state} */
379
+ const select_state = {
380
+ ...state,
381
+ init: [],
382
+ update: [],
383
+ after_update: [],
384
+ template: new Template()
385
+ };
386
+
387
+ process_children(
388
+ trimmed,
389
+ (is_text) => b.call('$.first_child', fragment_id, is_text && b.true),
390
+ false,
391
+ {
392
+ ...context,
393
+ state: select_state
394
+ }
395
+ );
396
+
397
+ // Transform the template to $.from_html(...) and hoist it
398
+ const template = transform_template(select_state, metadata.namespace, TEMPLATE_FRAGMENT);
399
+ context.state.hoisted.push(b.var(template_name, template));
400
+
401
+ // Build the rich content function body
402
+ // The anchor is the child of the element (a hydration marker during hydration)
403
+ const body = b.block([
404
+ b.var(anchor_id, b.call('$.child', element_node)),
405
+ b.var(fragment_id, b.call(template_name)),
406
+ ...select_state.init,
407
+ ...(select_state.update.length > 0 ? [build_render_statement(select_state)] : []),
408
+ ...select_state.after_update,
409
+ b.stmt(b.call('$.append', anchor_id, fragment_id))
410
+ ]);
411
+
412
+ child_state.init.push(b.stmt(b.call('$.customizable_select', element_node, b.arrow([], body))));
354
413
  } else {
355
414
  /** @type {Expression} */
356
415
  let arg = context.state.node;
357
416
 
358
417
  // If `hydrate_node` is set inside the element, we need to reset it
359
- // after the element has been hydrated
360
- let needs_reset = trimmed.some((node) => node.type !== 'Text');
418
+ // after the element has been hydrated. We need to check if any child
419
+ // would actually advance the hydrate_node cursor - static elements don't.
420
+ let needs_reset = trimmed.some((node) => node.type !== 'Text' && !is_static_element(node));
361
421
 
362
422
  // The same applies if it's a `<template>` element, since we need to
363
423
  // set the value of `hydrate_node` to `node.content`
@@ -98,7 +98,7 @@ export function process_children(nodes, initial, is_element, context) {
98
98
 
99
99
  let child_state = context.state;
100
100
 
101
- if (is_static_element(node, context.state)) {
101
+ if (is_static_element(node)) {
102
102
  skipped += 1;
103
103
  } else if (
104
104
  node.type === 'EachBlock' &&
@@ -137,9 +137,8 @@ export function process_children(nodes, initial, is_element, context) {
137
137
 
138
138
  /**
139
139
  * @param {AST.SvelteNode} node
140
- * @param {ComponentContext["state"]} state
141
140
  */
142
- function is_static_element(node, state) {
141
+ export function is_static_element(node) {
143
142
  if (node.type !== 'RegularElement') return false;
144
143
  if (node.fragment.metadata.dynamic) return false;
145
144
  if (is_custom_element_node(node)) return false; // we're setting all attributes on custom elements through properties
@@ -15,6 +15,7 @@ import {
15
15
  PromiseOptimiser,
16
16
  create_async_block
17
17
  } from './shared/utils.js';
18
+ import { is_customizable_select_element } from '../../../nodes.js';
18
19
 
19
20
  /**
20
21
  * @param {AST.RegularElement} node
@@ -124,6 +125,10 @@ export function RegularElement(node, context) {
124
125
 
125
126
  const [attributes, ...rest] = prepare_element_spread_object(node, context, optimiser.transform);
126
127
 
128
+ if (is_customizable_select_element(node)) {
129
+ rest.push(b.true);
130
+ }
131
+
127
132
  const statement = b.stmt(b.call('$$renderer.select', attributes, fn, ...rest));
128
133
 
129
134
  if (optimiser.expressions.length > 0) {
@@ -149,14 +154,34 @@ export function RegularElement(node, context) {
149
154
  const inner_state = { ...state, template: [], init: [] };
150
155
  process_children(trimmed, { ...context, state: inner_state });
151
156
 
152
- body = b.arrow(
153
- [b.id('$$renderer')],
154
- b.block([...state.init, ...build_template(inner_state.template)])
155
- );
157
+ /** @type {import('estree').Statement[]} */
158
+ const body_statements = [...state.init, ...build_template(inner_state.template)];
159
+
160
+ if (dev) {
161
+ const location = locator(node.start);
162
+ body_statements.unshift(
163
+ b.stmt(
164
+ b.call(
165
+ '$.push_element',
166
+ b.id('$$renderer'),
167
+ b.literal(node.name),
168
+ b.literal(location.line),
169
+ b.literal(location.column)
170
+ )
171
+ )
172
+ );
173
+ body_statements.push(b.stmt(b.call('$.pop_element')));
174
+ }
175
+
176
+ body = b.arrow([b.id('$$renderer')], b.block(body_statements));
156
177
  }
157
178
 
158
179
  const [attributes, ...rest] = prepare_element_spread_object(node, context, optimiser.transform);
159
180
 
181
+ if (is_customizable_select_element(node)) {
182
+ rest.push(b.true);
183
+ }
184
+
160
185
  const statement = b.stmt(b.call('$$renderer.option', attributes, body, ...rest));
161
186
 
162
187
  if (optimiser.expressions.length > 0) {
@@ -192,7 +217,14 @@ export function RegularElement(node, context) {
192
217
  )
193
218
  );
194
219
  } else {
220
+ // For optgroup or select with rich content, add hydration marker at the start
195
221
  process_children(trimmed, { ...context, state });
222
+ if (
223
+ (node.name === 'optgroup' || node.name === 'select') &&
224
+ is_customizable_select_element(node)
225
+ ) {
226
+ state.template.push(b.literal('<!>'));
227
+ }
196
228
  }
197
229
 
198
230
  if (!node_is_void) {
@@ -148,3 +148,98 @@ export function get_name(node) {
148
148
 
149
149
  return null;
150
150
  }
151
+
152
+ /**
153
+ * Checks if an <option>, <optgroup>, or <select> element has rich content that requires special hydration handling.
154
+ * Rich content is anything beyond simple text, expressions, and comments for <option>,
155
+ * anything beyond <option> children for <optgroup>,
156
+ * or anything beyond <option>, <optgroup>, and empty text for <select>.
157
+ * Control flow blocks are recursively checked - they only count as rich content if they contain rich content.
158
+ * @param {AST.RegularElement} node
159
+ * @returns {boolean}
160
+ */
161
+ export function is_customizable_select_element(node) {
162
+ if (node.name === 'select' || node.name === 'optgroup' || node.name === 'option') {
163
+ for (const child of find_descendants(node.fragment)) {
164
+ if (child.type === 'RegularElement') {
165
+ if (node.name === 'select' && child.name !== 'option' && child.name !== 'optgroup') {
166
+ return true;
167
+ }
168
+
169
+ if (node.name === 'optgroup' && child.name !== 'option') {
170
+ return true;
171
+ }
172
+
173
+ if (node.name === 'option') {
174
+ return true;
175
+ }
176
+ }
177
+
178
+ // Text nodes directly in <select> or <optgroup> are rich content
179
+ else if (child.type === 'Text') {
180
+ if (node.name === 'select' || node.name === 'optgroup') {
181
+ return true;
182
+ }
183
+ }
184
+
185
+ // Any non-RegularElement, non-Text node is rich content
186
+ else {
187
+ return true;
188
+ }
189
+ }
190
+ }
191
+
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * @param {AST.Fragment | null} fragment
197
+ * @returns {Iterable<AST.SvelteNode>}
198
+ */
199
+ function* find_descendants(fragment) {
200
+ if (fragment === null) return;
201
+
202
+ for (const node of fragment.nodes) {
203
+ switch (node.type) {
204
+ case 'SnippetBlock':
205
+ case 'DebugTag':
206
+ case 'ConstTag':
207
+ case 'Comment':
208
+ case 'ExpressionTag':
209
+ break;
210
+
211
+ case 'Text':
212
+ if (node.data.trim() !== '') {
213
+ yield node;
214
+ }
215
+ break;
216
+
217
+ case 'IfBlock':
218
+ yield* find_descendants(node.consequent);
219
+ yield* find_descendants(node.alternate);
220
+ break;
221
+
222
+ case 'EachBlock':
223
+ yield* find_descendants(node.body);
224
+ yield* find_descendants(node.fallback ?? null);
225
+ break;
226
+
227
+ case 'KeyBlock':
228
+ yield* find_descendants(node.fragment);
229
+ break;
230
+
231
+ case 'AwaitBlock':
232
+ yield* find_descendants(node.pending);
233
+ yield* find_descendants(node.then);
234
+ yield* find_descendants(node.catch);
235
+ break;
236
+
237
+ case 'SvelteBoundary':
238
+ yield* find_descendants(node.fragment);
239
+ break;
240
+
241
+ default:
242
+ yield node;
243
+ }
244
+ }
245
+ }
@@ -80,9 +80,9 @@ export function closing_tag_omitted(current, next) {
80
80
  */
81
81
  const disallowed_children = {
82
82
  ...autoclosing_children,
83
- optgroup: { only: ['option', '#text'] },
84
83
  // Strictly speaking, seeing an <option> doesn't mean we're in a <select>, but we assume it here
85
- option: { only: ['#text'] },
84
+ // option or optgroup does not have an `only` restriction because newer browsers support rich HTML content
85
+ // inside option elements. For older browsers, hydration will handle the mismatch.
86
86
  form: { descendant: ['form'] },
87
87
  a: { descendant: ['a'] },
88
88
  button: { descendant: ['button'] },
@@ -92,8 +92,6 @@ const disallowed_children = {
92
92
  h4: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
93
93
  h5: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
94
94
  h6: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
95
- // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect
96
- select: { only: ['option', 'optgroup', '#text', 'hr', 'script', 'template'] },
97
95
 
98
96
  // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intd
99
97
  // https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incaption
@@ -0,0 +1,51 @@
1
+ import { hydrating, reset, set_hydrate_node, set_hydrating } from '../hydration.js';
2
+ import { create_comment } from '../operations.js';
3
+
4
+ /** @type {boolean | null} */
5
+ let supported = null;
6
+
7
+ /**
8
+ * Checks if the browser supports rich HTML content inside `<option>` elements.
9
+ * Modern browsers preserve HTML elements inside options, while older browsers
10
+ * strip them during parsing, leaving only text content.
11
+ * @returns {boolean}
12
+ */
13
+ function is_supported() {
14
+ if (supported === null) {
15
+ var select = document.createElement('select');
16
+ select.innerHTML = '<option><span>t</span></option>';
17
+ supported = /** @type {Element} */ (select.firstChild)?.firstChild?.nodeType === 1;
18
+ }
19
+
20
+ return supported;
21
+ }
22
+
23
+ /**
24
+ * Handles rich HTML content inside `<option>`, `<optgroup>`, or `<select>` elements with browser-specific branching.
25
+ * Modern browsers preserve HTML inside options, while older browsers strip it to text only.
26
+ *
27
+ * @param {HTMLOptionElement | HTMLOptGroupElement | HTMLSelectElement} element The element to process
28
+ * @param {() => void} rich_fn Function to process rich HTML content (modern browsers)
29
+ */
30
+ export function customizable_select(element, rich_fn) {
31
+ var was_hydrating = hydrating;
32
+
33
+ if (!is_supported()) {
34
+ set_hydrating(false);
35
+ element.textContent = '';
36
+ element.append(create_comment(''));
37
+ }
38
+
39
+ try {
40
+ rich_fn();
41
+ } finally {
42
+ if (was_hydrating) {
43
+ if (hydrating) {
44
+ reset(element);
45
+ } else {
46
+ set_hydrating(true);
47
+ set_hydrate_node(element);
48
+ }
49
+ }
50
+ }
51
+ }
@@ -42,6 +42,7 @@ export {
42
42
  export { set_class } from './dom/elements/class.js';
43
43
  export { apply, event, delegate, replay_events } from './dom/elements/events.js';
44
44
  export { autofocus, remove_textarea_child } from './dom/elements/misc.js';
45
+ export { customizable_select } from './dom/elements/customizable-select.js';
45
46
  export { set_style } from './dom/elements/style.js';
46
47
  export { animation, transition } from './dom/elements/transitions.js';
47
48
  export { bind_active_element } from './dom/elements/bindings/document.js';
@@ -10,6 +10,7 @@ import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
10
10
  import { attributes } from './index.js';
11
11
  import { get_render_context, with_render_context, init_render_context } from './render-context.js';
12
12
  import { sha256 } from './crypto.js';
13
+ import * as devalue from 'devalue';
13
14
 
14
15
  /** @typedef {'head' | 'body'} RendererType */
15
16
  /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
@@ -219,9 +220,10 @@ export class Renderer {
219
220
  * @param {Record<string, boolean> | undefined} [classes]
220
221
  * @param {Record<string, string> | undefined} [styles]
221
222
  * @param {number | undefined} [flags]
223
+ * @param {boolean | undefined} [is_rich]
222
224
  * @returns {void}
223
225
  */
224
- select(attrs, fn, css_hash, classes, styles, flags) {
226
+ select(attrs, fn, css_hash, classes, styles, flags, is_rich) {
225
227
  const { value, ...select_attrs } = attrs;
226
228
 
227
229
  this.push(`<select${attributes(select_attrs, css_hash, classes, styles, flags)}>`);
@@ -229,7 +231,7 @@ export class Renderer {
229
231
  renderer.local.select_value = value;
230
232
  fn(renderer);
231
233
  });
232
- this.push('</select>');
234
+ this.push(`${is_rich ? '<!>' : ''}</select>`);
233
235
  }
234
236
 
235
237
  /**
@@ -239,8 +241,9 @@ export class Renderer {
239
241
  * @param {Record<string, boolean> | undefined} [classes]
240
242
  * @param {Record<string, string> | undefined} [styles]
241
243
  * @param {number | undefined} [flags]
244
+ * @param {boolean | undefined} [is_rich]
242
245
  */
243
- option(attrs, body, css_hash, classes, styles, flags) {
246
+ option(attrs, body, css_hash, classes, styles, flags, is_rich) {
244
247
  this.#out.push(`<option${attributes(attrs, css_hash, classes, styles, flags)}`);
245
248
 
246
249
  /**
@@ -257,7 +260,7 @@ export class Renderer {
257
260
  renderer.#out.push(' selected');
258
261
  }
259
262
 
260
- renderer.#out.push(`>${body}</option>`);
263
+ renderer.#out.push(`>${body}${is_rich ? '<!>' : ''}</option>`);
261
264
 
262
265
  // super edge case, but may as well handle it
263
266
  if (head) {
@@ -669,7 +672,7 @@ export class Renderer {
669
672
  for (const p of v.promises) await p;
670
673
  }
671
674
 
672
- entries.push(`[${JSON.stringify(k)},${v.serialized}]`);
675
+ entries.push(`[${devalue.uneval(k)},${v.serialized}]`);
673
676
  }
674
677
 
675
678
  let prelude = `const h = (window.__svelte ??= {}).h ??= new Map();`;
package/src/version.js CHANGED
@@ -4,5 +4,5 @@
4
4
  * The current version, as set in package.json.
5
5
  * @type {string}
6
6
  */
7
- export const VERSION = '5.46.3';
7
+ export const VERSION = '5.47.0';
8
8
  export const PUBLIC_VERSION = '5';