ripple 0.4.3 → 0.4.4

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/package.json +3 -3
  3. package/src/constants.js +6 -0
  4. package/src/runtime/internal/client/attributes.js +251 -125
  5. package/src/runtime/internal/client/blocks.js +16 -6
  6. package/src/runtime/internal/client/composite.js +292 -100
  7. package/src/runtime/internal/client/constants.js +8 -0
  8. package/src/runtime/internal/client/expression.js +4 -4
  9. package/src/runtime/internal/client/for.js +13 -38
  10. package/src/runtime/internal/client/if.js +56 -1
  11. package/src/runtime/internal/client/index.js +6 -6
  12. package/src/runtime/internal/client/runtime.js +73 -11
  13. package/src/runtime/internal/client/template-ns.js +45 -4
  14. package/src/runtime/internal/client/template.js +56 -0
  15. package/tests/client/composite/composite.dynamic-components.test.tsrx +40 -0
  16. package/tests/client/css/scoped-styles.test.tsrx +3 -1
  17. package/tests/client/svg.test.tsrx +273 -1
  18. package/tests/hydration/compiled/client/basic.js +36 -35
  19. package/tests/hydration/compiled/client/composite.js +11 -2
  20. package/tests/hydration/compiled/client/events.js +1 -1
  21. package/tests/hydration/compiled/client/for.js +73 -62
  22. package/tests/hydration/compiled/client/fragment-cursor.js +182 -115
  23. package/tests/hydration/compiled/client/head.js +10 -10
  24. package/tests/hydration/compiled/client/hmr.js +11 -2
  25. package/tests/hydration/compiled/client/html-in-template.js +1 -1
  26. package/tests/hydration/compiled/client/html.js +78 -50
  27. package/tests/hydration/compiled/client/if-children.js +94 -26
  28. package/tests/hydration/compiled/client/if-fragment-controlflow.js +34 -14
  29. package/tests/hydration/compiled/client/if.js +65 -17
  30. package/tests/hydration/compiled/client/mixed-control-flow.js +50 -20
  31. package/tests/hydration/compiled/client/nested-control-flow.js +188 -97
  32. package/tests/hydration/compiled/client/portal.js +14 -5
  33. package/tests/hydration/compiled/client/reactivity.js +3 -3
  34. package/tests/hydration/compiled/client/streaming.js +17 -17
  35. package/tests/hydration/compiled/client/switch.js +17 -17
  36. package/tests/hydration/compiled/client/track-async-serialization.js +19 -19
  37. package/tests/hydration/compiled/client/try.js +9 -9
  38. package/tests/hydration/compiled/client/typed-text.js +3 -2
@@ -27,10 +27,13 @@ import {
27
27
  SCHEDULED,
28
28
  SELECTOR,
29
29
  IF_BLOCK,
30
+ COMPOSITE_BLOCK,
30
31
  ITEM_BLOCK,
31
32
  RELEASED,
32
33
  RENDER_ENTRY,
33
34
  CREATES_DERIVEDS,
35
+ NAMESPACE_BLOCK,
36
+ SVG_BLOCK,
34
37
  } from './constants.js';
35
38
  import { is_ripple_object } from './utils.js';
36
39
  import { scope_orphan, set_in_derived, track_orphan, update_depth_exceeded } from './errors.js';
@@ -367,9 +370,10 @@ function handle_run_error(error, block) {
367
370
  * @returns {Derived[] | null}
368
371
  */
369
372
  function prepare_rerun(block) {
370
- // A list, an if, and a list item re-run only their own logic; their child
371
- // blocks (items, the branch's blocks, an item body's nested blocks) stay.
372
- if ((block.f & (FOR_BLOCK | IF_BLOCK | ITEM_BLOCK)) === 0) {
373
+ // A list, an if, a composite and a list item re-run only their own logic;
374
+ // their child blocks (items, the branch's blocks, a dynamic element's
375
+ // children, an item body's nested blocks) stay.
376
+ if ((block.f & (FOR_BLOCK | IF_BLOCK | ITEM_BLOCK | COMPOSITE_BLOCK)) === 0) {
373
377
  destroy_non_branch_children(block);
374
378
  }
375
379
  run_teardown(block);
@@ -393,17 +397,18 @@ function register_teardown(block, teardown) {
393
397
 
394
398
  /**
395
399
  * Runs the body of a branch block that was just created, in place of a first
396
- * `run_block`: a branch renders untracked and never re-runs, so the only
397
- * bookkeeping its first run needs is the globals the body reads and the
398
- * dependencies a nested `item` records on it. Errors are handled as
399
- * `run_block` handles them.
400
+ * `run_block`: a branch renders untracked, so the only bookkeeping its first
401
+ * run needs is the globals the body reads and the dependencies a nested
402
+ * `item` records on it (a list item re-runs through `run_block`, see
403
+ * `run_item`). Errors are handled as `run_block` handles them.
400
404
  * @param {Block} block
401
405
  * @param {(anchor: any, value: any, index?: any, key?: any) => void} fn
402
406
  * @param {any} anchor
403
- * @param {any} value
404
- * @param {any} [key] a keyed list item's key (see `create_item`)
407
+ * @param {any} value a list item's value, or its tracked
408
+ * @param {any} [index] a list item's tracked index (see `create_item`)
409
+ * @param {any} [key] a keyed list item's key
405
410
  */
406
- export function run_branch(block, fn, anchor, value, key) {
411
+ export function run_branch(block, fn, anchor, value, index, key) {
407
412
  var previous_block = active_block;
408
413
  var previous_reaction = active_reaction;
409
414
  var previous_tracking = tracking;
@@ -416,7 +421,7 @@ export function run_branch(block, fn, anchor, value, key) {
416
421
  active_component = block.co;
417
422
  tracking = false;
418
423
  active_dependency = null;
419
- fn(anchor, value, undefined, key);
424
+ fn(anchor, value, index, key);
420
425
  if (active_dependency !== null) {
421
426
  block.d = active_dependency;
422
427
  }
@@ -431,6 +436,43 @@ export function run_branch(block, fn, anchor, value, key) {
431
436
  }
432
437
  }
433
438
 
439
+ /**
440
+ * Runs `fn(block.s, arg)` as a rerun of `block` driven from outside it: an if
441
+ * whose condition the enclosing render function evaluates renders its branch
442
+ * this way. The function runs under the block, in the block's namespace, and
443
+ * the deriveds the block's previous run created are released afterwards
444
+ * unless still read, as `run_block` does for a rerun. An error propagates to
445
+ * the caller's block.
446
+ * @template S, A
447
+ * @param {Block} block
448
+ * @param {(state: S, arg: A) => void} fn
449
+ * @param {A} arg
450
+ */
451
+ export function run_in_block(block, fn, arg) {
452
+ var previous_block = active_block;
453
+ var previous_reaction = active_reaction;
454
+ var previous_component = active_component;
455
+ var previous_namespace = active_namespace;
456
+ var previous_deriveds = (block.f & CREATES_DERIVEDS) !== 0 ? take_created_deriveds(block) : null;
457
+
458
+ try {
459
+ active_block = block;
460
+ active_reaction = block;
461
+ active_component = block.co;
462
+ var ns = block.f & NAMESPACE_BLOCK;
463
+ active_namespace = ns === 0 ? DEFAULT_NAMESPACE : ns === SVG_BLOCK ? 'svg' : 'mathml';
464
+ fn(/** @type {S} */ (block.s), arg);
465
+ } finally {
466
+ active_block = previous_block;
467
+ active_reaction = previous_reaction;
468
+ active_component = previous_component;
469
+ active_namespace = previous_namespace;
470
+ if (previous_deriveds !== null) {
471
+ keep_deriveds(block, release_deriveds(previous_deriveds));
472
+ }
473
+ }
474
+ }
475
+
434
476
  /**
435
477
  * @param {Block} block
436
478
  * @param {boolean} [first_run] true when the block has no children, teardown,
@@ -442,6 +484,7 @@ export function run_block(block, first_run = false) {
442
484
  var previous_tracking = tracking;
443
485
  var previous_dependency = active_dependency;
444
486
  var previous_component = active_component;
487
+ var previous_namespace = active_namespace;
445
488
  /** @type {Derived[] | null} */
446
489
  var previous_deriveds = null;
447
490
 
@@ -449,6 +492,10 @@ export function run_block(block, first_run = false) {
449
492
  active_block = block;
450
493
  active_reaction = block;
451
494
  active_component = block.co;
495
+ // The namespace the block was created in (see `create_block`): a rerun
496
+ // from a flush is outside the `with_ns()` call that established it.
497
+ var ns = block.f & NAMESPACE_BLOCK;
498
+ active_namespace = ns === 0 ? DEFAULT_NAMESPACE : ns === SVG_BLOCK ? 'svg' : 'mathml';
452
499
 
453
500
  if (!first_run) {
454
501
  previous_deriveds = prepare_rerun(block);
@@ -478,6 +525,7 @@ export function run_block(block, first_run = false) {
478
525
  tracking = previous_tracking;
479
526
  active_dependency = previous_dependency;
480
527
  active_component = previous_component;
528
+ active_namespace = previous_namespace;
481
529
 
482
530
  // The previous run's deriveds, whether this run finished or threw, and
483
531
  // after this run's dependencies replaced the old ones (so a derived the
@@ -1827,6 +1875,20 @@ export function with_ns(namespace, fn) {
1827
1875
  }
1828
1876
  }
1829
1877
 
1878
+ /**
1879
+ * Sets the active namespace and returns the previous one, for setup code that
1880
+ * switches namespace part-way through a render function (the children of a
1881
+ * `foreignObject`), where a `with_ns()` thunk would hide its declarations.
1882
+ * `run_block` restores the namespace after the render function either way.
1883
+ * @param {keyof typeof NAMESPACE_URI} namespace
1884
+ * @returns {keyof typeof NAMESPACE_URI}
1885
+ */
1886
+ export function set_ns(namespace) {
1887
+ var previous_namespace = active_namespace;
1888
+ active_namespace = namespace;
1889
+ return previous_namespace;
1890
+ }
1891
+
1830
1892
  /**
1831
1893
  * @returns {symbol}
1832
1894
  */
@@ -1,4 +1,6 @@
1
+ /** @import { NAMESPACE_URI } from './constants.js' */
1
2
  import { get_first_child } from './operations.js';
3
+ import { render_component, set_ns, with_ns as run_with_ns } from './runtime.js';
2
4
  import { set_ns_parser, template } from './template.js';
3
5
 
4
6
  /**
@@ -26,12 +28,20 @@ function from_namespace(content, ns) {
26
28
  }
27
29
 
28
30
  /**
29
- * Makes namespaced template parsing available: called by every path that can
30
- * clone a template into the SVG or MathML namespace, so an app without one
31
- * never loads it.
31
+ * Runs `fn` with `namespace` active: content rendered inside it (children
32
+ * passed into an `<svg>`, a component rendered there, a dynamic element's
33
+ * children) creates its DOM in that namespace, and every block created
34
+ * inside records it (see `create_block`) so its reruns do too. Templates
35
+ * cloned in the namespace parse through `from_namespace`, so it is installed
36
+ * here: the compiler only emits this call for the SVG and MathML namespaces.
37
+ * @template T
38
+ * @param {keyof typeof NAMESPACE_URI} namespace
39
+ * @param {() => T} fn
40
+ * @returns {T}
32
41
  */
33
- export function install_ns_templates() {
42
+ export function with_ns(namespace, fn) {
34
43
  set_ns_parser(from_namespace);
44
+ return run_with_ns(namespace, fn);
35
45
  }
36
46
 
37
47
  /**
@@ -45,3 +55,34 @@ export function template_ns(content, flags, count) {
45
55
  set_ns_parser(from_namespace);
46
56
  return template(content, flags, count);
47
57
  }
58
+
59
+ /**
60
+ * Enters `namespace` for the rest of a setup function, as `with_ns` does for
61
+ * a thunk: the compiler emits this where the content that follows is a
62
+ * dynamic element or a component called inside an `<svg>` or `<math>`
63
+ * template. Returns the namespace to restore with `set_ns`.
64
+ * @param {keyof typeof NAMESPACE_URI} namespace
65
+ * @returns {keyof typeof NAMESPACE_URI}
66
+ */
67
+ export function push_ns(namespace) {
68
+ set_ns_parser(from_namespace);
69
+ return set_ns(namespace);
70
+ }
71
+
72
+ /**
73
+ * `render_component` for a component called inside an `<svg>` or `<math>`
74
+ * template: its content renders in that namespace.
75
+ * @param {keyof typeof NAMESPACE_URI} namespace
76
+ * @param {Function} fn
77
+ * @param {Node | import('#client').AppendIntoAnchor} anchor
78
+ * @param {Record<string, any>} props
79
+ * @returns {void}
80
+ */
81
+ export function render_component_ns(namespace, fn, anchor, props) {
82
+ var previous = push_ns(namespace);
83
+ try {
84
+ render_component(fn, anchor, props);
85
+ } finally {
86
+ set_ns(previous);
87
+ }
88
+ }
@@ -9,6 +9,7 @@ import {
9
9
  import { H, hydrate_node, hydrating } from './hydration.js';
10
10
  import { create_text, get_first_child, is_firefox } from './operations.js';
11
11
  import { active_block, active_namespace } from './runtime.js';
12
+ import { DEFAULT_NAMESPACE, NAMESPACE_URI } from './constants.js';
12
13
  import { HYDRATION } from 'ripple/internal/client/hydration-enabled';
13
14
 
14
15
  /**
@@ -142,6 +143,61 @@ export function template(content, flags = 0, count = 1) {
142
143
  : clone_template(t);
143
144
  }
144
145
 
146
+ /**
147
+ * The document that owns parsed template content: it has no browsing
148
+ * context, so an element created in it does not load its `src` (an image,
149
+ * a video) until a clone is adopted into the page, exactly like a node
150
+ * parsed from a `<template>`.
151
+ * @type {Document | undefined}
152
+ */
153
+ var inert_document;
154
+
155
+ /**
156
+ * A template that is one HTML element with static attributes and at most one
157
+ * text child, built with DOM calls instead of parsed: a `<template>` parse
158
+ * has a fixed cost that dwarfs the element itself, and an app's first render
159
+ * pays it once per distinct template. The compiler decides which templates
160
+ * qualify (`template_el` in the client transform); every other shape still
161
+ * parses. The element is created in the inert template document and in the
162
+ * active namespace, as a parsed template would be.
163
+ * @param {string} tag
164
+ * @param {string[] | null} [attributes] - flat name/value pairs
165
+ * @param {string} [text]
166
+ * @returns {() => Node}
167
+ */
168
+ export function template_el(tag, attributes = null, text = '') {
169
+ /** @type {Element | undefined} */
170
+ var node;
171
+ /** @type {string | undefined} */
172
+ var node_ns;
173
+
174
+ return () => {
175
+ if (HYDRATION && hydrating) {
176
+ return /** @type {import('./hydrate.js').HydrationRuntime} */ (H).t(false, 1);
177
+ }
178
+ var ns = active_namespace;
179
+ if (node === undefined || node_ns !== ns) {
180
+ var doc = (inert_document ??= document.createElement('template').content.ownerDocument);
181
+ node =
182
+ ns === DEFAULT_NAMESPACE
183
+ ? doc.createElement(tag)
184
+ : doc.createElementNS(NAMESPACE_URI[ns], tag);
185
+ if (attributes !== null) {
186
+ for (var i = 0; i < attributes.length; i += 2) {
187
+ node.setAttribute(attributes[i], attributes[i + 1]);
188
+ }
189
+ }
190
+ if (text !== '') {
191
+ node.textContent = text;
192
+ }
193
+ node_ns = ns;
194
+ }
195
+ var clone = is_firefox ? document.importNode(node, true) : node.cloneNode(true);
196
+ assign_nodes(clone, clone);
197
+ return clone;
198
+ };
199
+ }
200
+
145
201
  /**
146
202
  * Appends a DOM node before the anchor node.
147
203
  * @param {ChildNode | AppendIntoAnchor} anchor - The anchor node.
@@ -76,6 +76,46 @@ describe('composite > dynamic components', () => {
76
76
  expect(container.textContent).toBe('hidden');
77
77
  });
78
78
 
79
+ it(
80
+ 'remounts a childless dynamic component with fresh props when its tag expression re-evaluates',
81
+ () => {
82
+ let bump: () => void = () => {};
83
+ let poke: () => void = () => {};
84
+
85
+ function Child(props: { n: number }) @{
86
+ <span>{props.n}</span>
87
+ }
88
+
89
+ function App() @{
90
+ const n = track(0);
91
+ const flag = track(true);
92
+ bump = () => {
93
+ n.value++;
94
+ };
95
+ poke = () => {
96
+ flag.value = !flag.value;
97
+ };
98
+ // The tag expression reads `flag`, but always resolves to `Child`.
99
+ <{flag.value || !flag.value ? Child : null} n={n.value} />
100
+ }
101
+
102
+ render(App);
103
+ flushSync();
104
+ const span = () => container.querySelector('span');
105
+ expect(span()?.textContent).toBe('0');
106
+
107
+ // A prop is a value: the instance keeps the number it got.
108
+ bump();
109
+ flushSync();
110
+ expect(span()?.textContent).toBe('0');
111
+
112
+ // The tag expression re-evaluated: a new instance reads the literal again.
113
+ poke();
114
+ flushSync();
115
+ expect(span()?.textContent).toBe('1');
116
+ },
117
+ );
118
+
79
119
  it('handles dynamic component switching', () => {
80
120
  function Child1() @{
81
121
  <div>{'I am child 1'}</div>
@@ -645,8 +645,10 @@ export function Precedence() @{
645
645
  // block is written after the nested scope.
646
646
  expect(black).toBeLessThan(italic);
647
647
  expect(italic).toBeLessThan(blue);
648
+ // The outer div shares a fragment template with the placeholder that
649
+ // follows it; the inner div is a flat element template of its own.
648
650
  expect(code).toContain(`class="outer ${outer} ${theme_hash}"`);
649
- expect(code).toContain(`class="inner ${outer} ${inner} ${theme_hash}"`);
651
+ expect(code).toContain(`'class', 'inner ${outer} ${inner} ${theme_hash}'`);
650
652
  },
651
653
  );
652
654
  });
@@ -1,7 +1,279 @@
1
- import { track } from 'ripple';
1
+ import { track, flushSync } from 'ripple';
2
2
  import type { Component, PropsWithChildren, PropsWithExtras } from 'ripple';
3
3
 
4
+ const SVG_NS = 'http://www.w3.org/2000/svg';
5
+ const HTML_NS = 'http://www.w3.org/1999/xhtml';
6
+
7
+ // The namespace is only known at runtime here: the component's children are
8
+ // compiled without it and rendered into an `<svg>` the component owns, so
9
+ // every block must keep the namespace it was created in when it reruns.
10
+ describe('SVG namespace in runtime-only contexts', () => {
11
+ function SVG({ children }: PropsWithChildren<{}>) @{
12
+ <svg>{children}</svg>
13
+ }
14
+
15
+ it('parses a nested template in the SVG namespace', () => {
16
+ function App() @{
17
+ <SVG>
18
+ <g class="group">
19
+ <circle r="1" />
20
+ </g>
21
+ </SVG>
22
+ }
23
+
24
+ render(App);
25
+ expect(container.querySelector('.group').namespaceURI).toBe(SVG_NS);
26
+ expect(container.querySelector('circle').namespaceURI).toBe(SVG_NS);
27
+ });
28
+
29
+ it('keeps the namespace when an @if in component children toggles', () => {
30
+ let setFlag: ((value: boolean) => void) | undefined;
31
+
32
+ function App() @{
33
+ const flag = track(true);
34
+ setFlag = (value) => {
35
+ flag.value = value;
36
+ };
37
+ <SVG>
38
+ @if (flag.value) {
39
+ <polygon class="a" points="0,0 1,1" />
40
+ } @else {
41
+ <g class="b">
42
+ <rect width="1" height="1" />
43
+ </g>
44
+ }
45
+ </SVG>
46
+ }
47
+
48
+ render(App);
49
+ expect(container.querySelector('.a').namespaceURI).toBe(SVG_NS);
50
+ flushSync(() => setFlag?.(false));
51
+ expect(container.querySelector('.b').namespaceURI).toBe(SVG_NS);
52
+ expect(container.querySelector('rect').namespaceURI).toBe(SVG_NS);
53
+ flushSync(() => setFlag?.(true));
54
+ expect(container.querySelector('.a').namespaceURI).toBe(SVG_NS);
55
+ });
56
+
57
+ it('keeps the namespace when an @if in a leaf component toggles', () => {
58
+ let setFlag: ((value: boolean) => void) | undefined;
59
+
60
+ function Shape() @{
61
+ const flag = track(true);
62
+ setFlag = (value) => {
63
+ flag.value = value;
64
+ };
65
+ @if (flag.value) {
66
+ <polygon class="a" points="0,0 1,1" />
67
+ } @else {
68
+ <rect class="b" width="1" height="1" />
69
+ }
70
+ }
71
+
72
+ function App() @{
73
+ <svg>
74
+ <Shape />
75
+ </svg>
76
+ }
77
+
78
+ render(App);
79
+ expect(container.querySelector('.a').namespaceURI).toBe(SVG_NS);
80
+ flushSync(() => setFlag?.(false));
81
+ expect(container.querySelector('.b').namespaceURI).toBe(SVG_NS);
82
+ });
83
+
84
+ it('keeps the namespace when a dynamic tag in component children changes', () => {
85
+ let setTag: ((value: string) => void) | undefined;
86
+
87
+ function App() @{
88
+ const tag = track('polygon');
89
+ setTag = (value) => {
90
+ tag.value = value;
91
+ };
92
+ <SVG>
93
+ <{tag.value} class="shape" />
94
+ </SVG>
95
+ }
96
+
97
+ render(App);
98
+ expect(container.querySelector('.shape').namespaceURI).toBe(SVG_NS);
99
+ flushSync(() => setTag?.('rect'));
100
+ const shape = container.querySelector('.shape');
101
+ expect(shape.localName).toBe('rect');
102
+ expect(shape.namespaceURI).toBe(SVG_NS);
103
+ });
104
+
105
+ it('keeps the namespace for items added to an @for in component children', () => {
106
+ let setItems: ((value: string[]) => void) | undefined;
107
+
108
+ function App() @{
109
+ const items = track(['a']);
110
+ setItems = (value) => {
111
+ items.value = value;
112
+ };
113
+ <SVG>
114
+ @for (const id of items.value) {
115
+ <circle data-id={id} r="1" />
116
+ }
117
+ </SVG>
118
+ }
119
+
120
+ render(App);
121
+ expect(container.querySelector('[data-id=a]').namespaceURI).toBe(SVG_NS);
122
+ flushSync(() => setItems?.(['a', 'b']));
123
+ expect(container.querySelector('[data-id=b]').namespaceURI).toBe(SVG_NS);
124
+ });
125
+
126
+ it('renders HTML inside a foreignObject in component children', () => {
127
+ let setTag: ((value: string) => void) | undefined;
128
+
129
+ function App() @{
130
+ const tag = track('div');
131
+ setTag = (value) => {
132
+ tag.value = value;
133
+ };
134
+ <SVG>
135
+ <foreignObject>
136
+ @if (tag.value !== 'none') {
137
+ <div class="static-label">
138
+ <span>hi</span>
139
+ </div>
140
+ }
141
+ <{tag.value} class="html-label">Label</{tag.value}>
142
+ </foreignObject>
143
+ <circle class="after" r="1" />
144
+ </SVG>
145
+ }
146
+
147
+ render(App);
148
+ expect(container.querySelector('.static-label').namespaceURI).toBe(HTML_NS);
149
+ expect(container.querySelector('span').namespaceURI).toBe(HTML_NS);
150
+ expect(container.querySelector('.html-label').namespaceURI).toBe(HTML_NS);
151
+ expect(container.querySelector('.after').namespaceURI).toBe(SVG_NS);
152
+ flushSync(() => setTag?.('span'));
153
+ const label = container.querySelector('.html-label');
154
+ expect(label.localName).toBe('span');
155
+ expect(label.namespaceURI).toBe(HTML_NS);
156
+ });
157
+
158
+ it('sets a dynamic class on SVG elements in component children', () => {
159
+ let setName: ((value: string) => void) | undefined;
160
+
161
+ function App() @{
162
+ const name = track('one');
163
+ setName = (value) => {
164
+ name.value = value;
165
+ };
166
+ <SVG>
167
+ <circle class={name.value} r="1" />
168
+ <a class={name.value} href="#link">
169
+ <text>link</text>
170
+ </a>
171
+ </SVG>
172
+ }
173
+
174
+ render(App);
175
+ expect(container.querySelector('circle').getAttribute('class')).toBe('one');
176
+ expect(container.querySelector('a').getAttribute('class')).toBe('one');
177
+ flushSync(() => setName?.('two'));
178
+ expect(container.querySelector('circle').getAttribute('class')).toBe('two');
179
+ expect(container.querySelector('a').getAttribute('class')).toBe('two');
180
+ });
181
+ });
182
+
183
+ describe('dynamic element namespace changes', () => {
184
+ it('switches a root between HTML and SVG with its children', () => {
185
+ let setTag: ((value: string) => void) | undefined;
186
+
187
+ function App() @{
188
+ const tag = track('div');
189
+ setTag = (value) => {
190
+ tag.value = value;
191
+ };
192
+ <{tag.value} class="root">
193
+ <g class="inner">
194
+ <title>t</title>
195
+ </g>
196
+ </{tag.value}>
197
+ }
198
+
199
+ render(App);
200
+ expect(container.querySelector('.root').namespaceURI).toBe(HTML_NS);
201
+ expect(container.querySelector('.inner').namespaceURI).toBe(HTML_NS);
202
+ flushSync(() => setTag?.('svg'));
203
+ expect(container.querySelector('.root').namespaceURI).toBe(SVG_NS);
204
+ expect(container.querySelector('.inner').namespaceURI).toBe(SVG_NS);
205
+ expect(container.querySelector('title').namespaceURI).toBe(SVG_NS);
206
+ flushSync(() => setTag?.('div'));
207
+ expect(container.querySelector('.root').namespaceURI).toBe(HTML_NS);
208
+ expect(container.querySelector('.inner').namespaceURI).toBe(HTML_NS);
209
+ });
210
+
211
+ it('renders HTML children once the tag becomes foreignObject', () => {
212
+ let setTag: ((value: string) => void) | undefined;
213
+
214
+ function App() @{
215
+ const tag = track('g');
216
+ setTag = (value) => {
217
+ tag.value = value;
218
+ };
219
+ <svg>
220
+ <{tag.value} class="host">
221
+ <a class="content">hi</a>
222
+ </{tag.value}>
223
+ </svg>
224
+ }
225
+
226
+ render(App);
227
+ expect(container.querySelector('.host').namespaceURI).toBe(SVG_NS);
228
+ expect(container.querySelector('.content').namespaceURI).toBe(SVG_NS);
229
+ flushSync(() => setTag?.('foreignObject'));
230
+ const host = container.querySelector('.host');
231
+ expect(host.localName).toBe('foreignObject');
232
+ expect(host.namespaceURI).toBe(SVG_NS);
233
+ expect(container.querySelector('.content').namespaceURI).toBe(HTML_NS);
234
+ flushSync(() => setTag?.('g'));
235
+ expect(container.querySelector('.content').namespaceURI).toBe(SVG_NS);
236
+ });
237
+ });
238
+
4
239
  describe('SVG namespace handling', () => {
240
+ it('preserves SVG and foreignObject namespaces when dynamic tags change', () => {
241
+ let setTags: ((svgTag: string, htmlTag: string) => void) | undefined;
242
+
243
+ function App() @{
244
+ const shape = track('circle');
245
+ const html = track('div');
246
+ setTags = (svgTag, htmlTag) => {
247
+ shape.value = svgTag;
248
+ html.value = htmlTag;
249
+ };
250
+ <svg>
251
+ <{shape.value} class="shape">
252
+ <title>Dynamic shape</title>
253
+ </{shape.value}>
254
+ <foreignObject>
255
+ <{html.value} class="html-label">Label</{html.value}>
256
+ </foreignObject>
257
+ </svg>
258
+ }
259
+
260
+ render(App);
261
+ for (const [svgTag, htmlTag] of [['circle', 'div'], ['path', 'span'], ['line', 'div']]) {
262
+ flushSync(() => {
263
+ setTags?.(svgTag, htmlTag);
264
+ });
265
+ const element = container.querySelector('.shape');
266
+ expect(element.localName).toBe(svgTag);
267
+ expect(element.namespaceURI).toBe('http://www.w3.org/2000/svg');
268
+ expect(element.querySelector('title').namespaceURI).toBe('http://www.w3.org/2000/svg');
269
+ expect(element.textContent).toBe('Dynamic shape');
270
+ const label = container.querySelector('.html-label');
271
+ expect(label.localName).toBe(htmlTag);
272
+ expect(label.namespaceURI).toBe('http://www.w3.org/1999/xhtml');
273
+ expect(label.textContent).toBe('Label');
274
+ }
275
+ });
276
+
5
277
  it('should render static SVG elements with correct namespace', () => {
6
278
  function App() @{
7
279
  <svg