fix: ensure custom element attribute/prop changes are in their own context (#14016)

Fixes #13848.

When we set custom element attributes/props, we should be doing so without the current effect/reaction active. Otherwise, the custom element lifecycle might attach effects/dependencies to the wrong reaction and all manner of things can incorrectly occur

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
pull/14056/head
Dominic Gannaway 2 months ago committed by GitHub
parent 4c6255f8dd
commit cdec39afac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: ensure custom element attribute/prop changes are in their own context

@ -7,6 +7,12 @@ import * as w from '../../warnings.js';
import { LOADING_ATTR_SYMBOL } from '../../constants.js';
import { queue_idle_task, queue_micro_task } from '../task.js';
import { is_capture_event, is_delegated, normalize_attribute } from '../../../../utils.js';
import {
active_effect,
active_reaction,
set_active_effect,
set_active_reaction
} from '../../runtime.js';
/**
* The value/checked attribute in the template actually corresponds to the defaultValue property, so we need
@ -145,10 +151,24 @@ export function set_xlink_attribute(dom, attribute, value) {
* @param {any} value
*/
export function set_custom_element_data(node, prop, value) {
if (get_setters(node).includes(prop)) {
node[prop] = value;
} else {
set_attribute(node, prop, value);
// We need to ensure that setting custom element props, which can
// invoke lifecycle methods on other custom elements, does not also
// associate those lifecycle methods with the current active reaction
// or effect
var previous_reaction = active_reaction;
var previous_effect = active_effect;
set_active_reaction(null);
set_active_effect(null);
try {
if (get_setters(node).includes(prop)) {
node[prop] = value;
} else {
set_attribute(node, prop, value);
}
} finally {
set_active_reaction(previous_reaction);
set_active_effect(previous_effect);
}
}

@ -0,0 +1,24 @@
import { test } from '../../assert';
const tick = () => Promise.resolve();
export default test({
async test({ assert, target }) {
target.innerHTML = '<my-app/>';
await tick();
await tick();
/** @type {any} */
const el = target.querySelector('my-app');
const button = el.shadowRoot.querySelector('button');
const p = el.shadowRoot.querySelector('my-tracking').shadowRoot.querySelector('p');
assert.equal(button.innerHTML, '0');
assert.equal(p.innerHTML, 'false');
button.click();
await tick();
assert.equal(button.innerHTML, '1');
assert.equal(p.innerHTML, 'false');
}
});

@ -0,0 +1,31 @@
<svelte:options customElement="my-app" />
<script module>
class Tracking extends HTMLElement {
static observedAttributes = ["count"];
tracking = false;
set count(_) {
this.tracking = $effect.tracking();
this.render();
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
render() {
this.shadowRoot.innerHTML = `<p>${this.tracking}</p>`;
}
}
customElements.define("my-tracking", Tracking);
</script>
<script>
let count = $state(0);
</script>
<button onclick={() => (count += 1)}>{count}</button>
<my-tracking {count}></my-tracking>
Loading…
Cancel
Save