pull/18869/merge
Serghei 12 hours ago committed by GitHub
commit 989082fb0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: skip redundant attribute writes during hydration

@ -1,7 +1,7 @@
/** @import { Blocker, Effect } from '#client' */
import { DEV } from 'esm-env';
import { hydrating, set_hydrating } from '../hydration.js';
import { get_descriptors, get_prototype_of } from '../../../shared/utils.js';
import { get_descriptor, get_descriptors, get_prototype_of } from '../../../shared/utils.js';
import { create_event, delegate, delegated, event, event_symbol } from './events.js';
import { add_form_reset_listener, autofocus } from './misc.js';
import * as w from '../../warnings.js';
@ -178,7 +178,8 @@ export function set_attribute(element, attribute, value, skip_warning) {
}
}
if (attributes[attribute] === (attributes[attribute] = value)) return;
var previous = attributes[attribute];
if (previous === (attributes[attribute] = value)) return;
if (attribute === 'loading') {
// @ts-expect-error
@ -190,11 +191,52 @@ export function set_attribute(element, attribute, value, skip_warning) {
} else if (typeof value !== 'string' && get_setters(element).has(attribute)) {
// @ts-ignore
element[attribute] = value;
} else {
} else if (
// while hydrating, skip a number or boolean whose string the server already rendered
!(
hydrating &&
(typeof value === 'number' || typeof value === 'boolean') &&
previous === String(value) &&
is_native_element(element)
)
) {
element.setAttribute(attribute, value);
}
}
/** @type {Map<string, object>} */
var native_prototypes = new Map();
/** @type {(this: Element) => string | null} */
var get_namespace_uri;
/** @type {(this: Element) => string} */
var get_local_name;
/**
* Whether the element is not a customized built-in, which would observe writing an attribute's
* current value again through `attributeChangedCallback`. Autonomous custom elements never get here,
* as they are written with hydration turned off. The platform getters are used because form
* controls and own properties can shadow `localName` and `namespaceURI`
* @param {Element} element
*/
function is_native_element(element) {
get_namespace_uri ??= /** @type {any} */ (get_descriptor(Element.prototype, 'namespaceURI')).get;
if (get_namespace_uri.call(element) !== NAMESPACE_HTML) return true;
get_local_name ??= /** @type {any} */ (get_descriptor(Element.prototype, 'localName')).get;
var name = get_local_name.call(element);
var prototype = native_prototypes.get(name);
if (prototype === undefined) {
// read from the prototype, as named properties like `<form name="createElement">` shadow it
var created = get_prototype_of(document).createElement.call(document, name);
native_prototypes.set(name, (prototype = get_prototype_of(created)));
}
return get_prototype_of(element) === prototype;
}
/**
* @param {Element} dom
* @param {string} attribute

@ -0,0 +1,78 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
let conversions = 0;
const object = { toString: () => String(++conversions) };
/** @type {Record<string, [server: any, client: any, written: boolean]>} */
const values = {
'data-same': [1, 1, false],
'data-nan': [NaN, NaN, false],
'data-large': [1e21, 1e21, false],
'data-yes': [true, true, false],
'data-no': [false, false, false],
'aria-hidden': [true, true, false],
'data-decimal': ['1.0', 1, true],
'data-zero': ['-0', -0, true],
'aria-checked': ['False', false, true],
'data-empty': ['', true, true],
'aria-rowindex': [2, 3, true],
'data-null': [1, null, true],
'data-undefined': [1, undefined, true],
'data-object': ['server', object, true]
};
/** @param {number} i */
const pick = (i) =>
Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value[i]]));
/** @type {MutationObserver} */
let observer;
const written = () =>
observer
.takeRecords()
.map((record) => `${record.target.nodeName.toLowerCase()} ${record.attributeName}`);
export default test({
server_props: {
values: pick(0),
spread: { disabled: true, tabindex: -1, hidden: false },
show: false
},
props: {
values: pick(1),
spread: { disabled: true, tabindex: -1, hidden: true },
show: true
},
before_test() {
observer = new MutationObserver(() => {});
observer.observe(document.body, { attributes: true, subtree: true });
},
test(assert, target, snapshot, component) {
// spread `disabled` and `hidden` always use their setters
assert.deepEqual(written(), [
'button disabled',
'button hidden',
...Object.keys(values)
.filter((key) => values[key][2])
.map((key) => `div ${key}`)
]);
assert.equal(conversions, 1);
component.$set({ n: 2 });
flushSync();
// the mismatched `{#if}` branch was created, then hydration resumed for the `<p>`
assert.deepEqual(
written().filter((record) => record.endsWith('data-n')),
['section data-n', 'p data-n']
);
},
after_test() {
observer.disconnect();
}
});

@ -0,0 +1 @@
<!--[--><div tabindex="-1" data-same="1" data-nan="NaN" data-large="1e+21" data-yes="true" data-no="false" aria-hidden="true" data-decimal="1" data-zero="0" aria-checked="false" data-empty="true" aria-rowindex="3" data-object="1"></div> <svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24"><rect x="1"></rect></svg> <button tabindex="-1" disabled hidden>x</button> <section data-n="1" tabindex="-1"></section><!----> <p data-n="1"></p><!--]-->

@ -0,0 +1,27 @@
<script>
let { values, size = 24, spread, show, n = 1 } = $props();
</script>
<div
tabindex={-1}
data-same={values['data-same']}
data-nan={values['data-nan']}
data-large={values['data-large']}
data-yes={values['data-yes']}
data-no={values['data-no']}
aria-hidden={values['aria-hidden']}
data-decimal={values['data-decimal']}
data-zero={values['data-zero']}
aria-checked={values['aria-checked']}
data-empty={values['data-empty']}
aria-rowindex={values['aria-rowindex']}
data-null={values['data-null']}
data-undefined={values['data-undefined']}
data-object={values['data-object']}
></div>
<svg width={size} height={size} stroke-width={1.5} viewBox="0 0 24 24"><rect x={1} /></svg>
<button {...spread}>x</button>
{#if show}
<section data-n={n} tabindex={-1}></section>
{/if}
<p data-n={n}></p>

@ -127,6 +127,8 @@ function normalize_children(node) {
* ssrHtml?: string;
* id_prefix?: string;
* props?: Props;
* server_props?: Props;
* options?: Record<string, any>;
* compileOptions?: Partial<CompileOptions>;
* before_test?: () => void;
* test?: (args: {

@ -7,7 +7,7 @@ import { render } from 'svelte/server';
export default function () {
const { head, body, html } = render(SvelteComponent, {
props: config.props || {},
props: config.server_props || config.props || {},
idPrefix: config?.id_prefix
});
return { head, body, html };

@ -0,0 +1,83 @@
import { assert_ok, test } from '../../assert';
/** @type {string[]} */
const changes = [];
const attributes = { disabled: true, hidden: true, tabindex: -1, 'aria-rowindex': 2 };
/** @type {ShadowRoot} */
let root;
/** @param {typeof HTMLButtonElement} base */
function observed(base) {
return class extends base {
static observedAttributes = Object.keys(attributes);
/** @param {string} name */
attributeChangedCallback(name) {
changes.push(`${this.textContent} ${name}`);
}
};
}
export default test({
mode: ['hydrate'],
props: {
// hydrated first, so `get_setters` has `BUTTON` cached for the adopted button
warm: attributes,
scoped: { is: 'scoped-button', ...attributes },
adopted: { ...attributes, is: 'realm-button' }
},
options: {
get target() {
return root;
}
},
before_test() {
const main = document.querySelector('main');
assert_ok(main);
const registry = new CustomElementRegistry();
registry.define('scoped-button', observed(HTMLButtonElement), { extends: 'button' });
const markup = main.innerHTML;
main.innerHTML = '';
root = main.attachShadow(
/** @type {any} */ ({ mode: 'open', customElementRegistry: registry })
);
root.innerHTML = markup;
// adopted from another realm, without its `is`
const iframe = document.createElement('iframe');
document.body.append(iframe);
const realm = /** @type {Window & typeof globalThis} */ (iframe.contentWindow);
realm.customElements.define('realm-button', observed(realm.HTMLButtonElement), {
extends: 'button'
});
const button = root.querySelectorAll('button')[2];
realm.document.body.innerHTML = button.outerHTML;
const adopted = /** @type {HTMLButtonElement} */ (realm.document.body.firstChild);
button.replaceWith(adopted);
adopted.removeAttribute('is');
changes.length = 0;
},
test({ assert }) {
assert.deepEqual(
changes.sort(),
['adopted', 'scoped'].flatMap((button) =>
['aria-rowindex', 'disabled', 'hidden', 'tabindex'].map((name) => `${button} ${name}`)
)
);
for (const button of root.querySelectorAll('button')) {
assert.equal(button.disabled, true);
assert.equal(button.tabIndex, -1);
}
}
});

@ -0,0 +1,9 @@
<script>
export let warm;
export let scoped;
export let adopted;
</script>
<button {...warm}>warm</button>
<button {...scoped}>scoped</button>
<button {...adopted}>adopted</button>

@ -0,0 +1,53 @@
import { flushSync, hydrate, unmount } from 'svelte';
import { assert_ok, test } from '../../assert';
/** @type {string} */
let html;
/** @type {Element | null} */
let server_div;
export default test({
mode: ['hydrate'],
options: { recover: true },
before_test() {
const main = document.querySelector('main');
assert_ok(main);
html = main.innerHTML;
server_div = main.querySelector('div');
// without its end marker, hydration throws after the component was hydrated
main.lastChild?.remove();
},
test({ assert, component, componentCtor, target }) {
assert.equal(target.querySelector('div') === server_div, false);
assert.htmlEqual(target.innerHTML, '<div data-n="1"></div> <svg width="24"></svg>');
const observer = new MutationObserver(() => {});
observer.observe(document.body, { attributes: true, subtree: true });
// hydration ended despite the failure, so an unchanged string is written
flushSync(() => (component.n = '1'));
assert.deepEqual(
observer.takeRecords().map((record) => record.attributeName),
['data-n']
);
// and a later hydration skips unchanged values again
const other = document.createElement('section');
other.innerHTML = html;
document.body.append(other);
const app = hydrate(componentCtor, { target: other, props: { n: 1 } });
flushSync();
assert.deepEqual(observer.takeRecords(), []);
assert.htmlEqual(other.innerHTML, '<div data-n="1"></div> <svg width="24"></svg>');
unmount(app);
observer.disconnect();
}
});

@ -0,0 +1,6 @@
<script>
export let n = 1;
</script>
<div data-n={n}></div>
<svg width={24}></svg>

@ -0,0 +1,8 @@
<script>
import { rename } from './names.js';
/** @type {'both' | 'object'} */
export let step;
rename(step);
</script>

@ -0,0 +1,113 @@
import { flushSync } from 'svelte';
import { assert_ok, test } from '../../assert';
import { record, shadowed } from './names.js';
/** @type {HTMLInputElement[]} */
let inputs;
/** @type {MutationObserver} */
let observer;
/** @type {{ value: number }} */
let item;
export default test({
mode: ['hydrate'],
before_test() {
const target = document.querySelector('main');
assert_ok(target);
inputs = [...target.querySelectorAll('input')];
for (const input of inputs) input.value = 'typed';
// no element can have this name
Object.defineProperty(target.querySelector('#local-name'), 'localName', { value: 'bad name' });
record();
item = /** @type {any} */ (target.querySelector('text')).x.baseVal.getItem(0);
observer = new MutationObserver(() => {});
observer.observe(target, { attributes: true, subtree: true });
},
test({ assert, component, target }) {
/** @param {Record<string, any>} [props] */
const written = (props) => {
if (props) flushSync(() => component.$set(props));
return observer
.takeRecords()
.map((record) => `${record.target.nodeName.toLowerCase()} ${record.attributeName}`);
};
// `$effect.pre` runs while hydrating, `$effect` after it
assert.deepEqual(written(), [
'object id',
'form name',
'button disabled',
'button hidden',
'div data-pre',
'div data-post'
]);
// after hydration, a new type is written even when the string is the same
assert.deepEqual(written({ label: 2 }), ['div data-label']);
assert.deepEqual(written({ label: 2 }), []);
assert.deepEqual(written({ n: '1' }), ['div data-n']);
assert.deepEqual(written({ on: false }), ['div data-active']);
assert.deepEqual(written({ spread: { disabled: false, hidden: true, tabindex: -1 } }), [
'button disabled'
]);
assert.deepEqual(written({ spread: { disabled: true, hidden: true, tabindex: -1 } }), [
'button disabled'
]);
observer.disconnect();
// the list item retrieved before hydration is still attached
item.value = 2;
assert.equal(target.querySelector('text')?.getAttribute('x'), '2');
assert.deepEqual(shadowed, ['HTMLFormElement', 'HTMLCollection', 'HTMLObjectElement']);
// no recovery
assert.deepEqual([...target.querySelectorAll('input')], inputs);
for (const input of inputs) assert.equal(input.value, 'typed');
// `htmlEqual` needs `document.createElement` back
target.querySelector('object')?.removeAttribute('id');
assert.htmlEqual(
target.innerHTML,
`
<form name="renamed"><input></form>
<object title="named later"></object>
<div data-n="1"></div> <p data-n="1"></p> <span data-n="1"></span>
<div role="row" aria-rowindex="2" data-active="false" data-label="2" data-n="1" data-pre="2" data-post="2"></div>
<a href="#top" tabindex="-1">x</a>
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24"></svg>
<button disabled hidden tabindex="-1">x</button>
<div id="local-name" data-n="1"><input></div>
<form data-n="1"><input name="localName"></form>
<form data-n="1"><input id="localName"></form>
<form data-n="1"><input name="namespaceURI"></form>
<xmlns data-n="1">x</xmlns><xmlns:ab data-n="1">x</xmlns:ab><svg><xmlns data-n="1"></xmlns></svg>
<svg>
<text x="2" rotate="1">a<tspan dy="1">b</tspan></text>
<polygon points="1"></polygon>
<filter><feColorMatrix type="saturate" values="1"></feColorMatrix></filter>
<image href="1"></image>
<image xlink:href="1"></image>
</svg>
`
);
const button = target.querySelector('button');
assert_ok(button);
assert.equal(button.disabled, true);
assert.equal(button.hidden, true);
assert.equal(button.tabIndex, -1);
assert.equal(button.getAttribute('disabled'), '');
}
});

@ -0,0 +1,63 @@
<script>
import Rename from './Rename.svelte';
let {
index = 2,
focus = -1,
on = true,
size = 24,
label = '2',
n = 1,
point = 1,
spread = { disabled: true, hidden: true, tabindex: -1 }
} = $props();
let pre = $state(1);
let post = $state(1);
$effect.pre(() => {
pre = 2;
});
$effect(() => {
post = 2;
});
</script>
<form name="createElement"><input /></form>
<object title="named later"></object>
<div data-n={1}></div>
<Rename step="both" />
<p data-n={1}></p>
<Rename step="object" />
<span data-n={1}></span>
<div
role="row"
aria-rowindex={index}
data-active={on}
data-label={label}
data-n={n}
data-pre={pre}
data-post={post}
></div>
<a href="#top" tabindex={focus}>x</a>
<svg width={size} height={size} stroke-width={1.5} viewBox="0 0 24 24"></svg>
<button {...spread}>x</button>
<div id="local-name" data-n={1}><input /></div>
<form data-n={1}><input name="localName" /></form>
<form data-n={1}><input id="localName" /></form>
<form data-n={1}><input name="namespaceURI" /></form>
<xmlns data-n={1}>x</xmlns>
<xmlns:ab data-n={1}>x</xmlns:ab>
<svg><xmlns data-n={1}></xmlns></svg>
<svg>
<text x={point} rotate={point}>a<tspan {...{ dy: point }}>b</tspan></text>
<polygon points={point} />
<filter><feColorMatrix type="saturate" values={point} /></filter>
<image href={point} />
<image {...{ 'xlink:href': point }} />
</svg>

@ -0,0 +1,19 @@
/** @type {string[]} */
export const shadowed = [];
export function record() {
shadowed.push(/** @type {any} */ (document).createElement.constructor.name);
}
/** @param {'both' | 'object'} step */
export function rename(step) {
const form = /** @type {HTMLFormElement | null} */ (document.querySelector('main form'));
const object = document.querySelector('main object');
// the server renders this component too, before there is any markup
if (!form || !object) return;
if (step === 'both') object.id = 'createElement';
if (step === 'object') form.name = 'renamed';
record();
}

@ -0,0 +1,5 @@
<script>
import { replace } from './accessors.js';
replace();
</script>

@ -0,0 +1,324 @@
import { flushSync } from 'svelte';
import { assert_ok, ok, test } from '../../assert';
import { calls, wrap } from './accessors.js';
const attributes = { disabled: true, hidden: true, tabindex: -1, 'aria-rowindex': 2 };
const until_found = { hidden: 'until-found' };
/** @type {Record<string, any>} */
const props = {
to_true: { hidden: true },
to_false: { hidden: false },
kept: until_found,
translate: false,
draggable: false,
observed: {
retained: { is: 'observed-button', ...attributes },
// `is` comes last, so the other attributes are hydrated while it is missing
removed: { ...attributes, is: 'observed-button' },
unset: { is: undefined, ...attributes }
},
native: {
retained: { is: 'native-prototype-button', ...attributes },
removed: { ...attributes, is: 'native-prototype-button' }
},
named: {
retained: { is: 'named-form', 'data-n': 1 },
removed: { 'data-n': 1, is: 'named-form' }
},
late: {
retained: { is: 'late-button', ...attributes },
removed: { ...attributes, is: 'late-button' }
},
same: { is: 'same-string-button', tabindex: '-1', 'aria-rowindex': '2', 'data-state': 'on' }
};
/** @type {string[]} */
const changes = [];
/**
* @param {typeof HTMLElement} base
* @param {string[]} observed
* @param {boolean} [values]
*/
function observe(base, observed, values) {
return class extends base {
static observedAttributes = observed;
/**
* @param {string} name
* @param {string | null} old_value
* @param {string | null} value
*/
attributeChangedCallback(name, old_value, value) {
const label = this.textContent || this.getAttribute('data-case');
changes.push(values ? `${label} ${name} ${old_value} ${value}` : `${label} ${name}`);
}
};
}
/** @param {string} text */
function find(text) {
const element = [...document.querySelectorAll('main *')].find((e) => e.textContent === text);
assert_ok(element);
return /** @type {any} */ (element);
}
/** @param {HTMLCanvasElement} canvas */
function alpha(canvas) {
return canvas.getContext('2d')?.getImageData(0, 0, 1, 1).data[3];
}
/** @type {MutationObserver} */
let observer;
export default test({
mode: ['hydrate'],
server_props: {
...props,
to_true: until_found,
to_false: until_found,
translate: undefined,
draggable: undefined,
observed: { ...props.observed, unset: props.observed.retained }
},
props,
before_test() {
const target = document.querySelector('main');
assert_ok(target);
for (const element of target.querySelectorAll('#spread [disabled]')) {
element.setAttribute('disabled', 'disabled');
}
for (const element of target.querySelectorAll('#accessors div')) {
element.setAttribute('hidden', 'hidden');
}
for (const [selector, name] of [
['main > svg', 'hidden'],
['main > button', 'disabled']
]) {
Object.defineProperty(target.querySelector(selector), name, {
/** @param {boolean} value */
set(value) {
this.setAttribute('data-effect', String(value));
}
});
}
wrap('wrapped');
for (const canvas of target.querySelectorAll('canvas')) {
const context = canvas.getContext('2d');
assert_ok(context);
context.fillRect(0, 0, 1, 1);
ok(alpha(canvas) === 255);
}
const ObservedButton = observe(HTMLButtonElement, Object.keys(attributes));
const Inherited = observe(HTMLButtonElement, ['data-n']);
const Own = observe(HTMLButtonElement, ['data-n']);
const NativePrototypeButton = observe(HTMLButtonElement, Object.keys(attributes));
const SameStringButton = observe(HTMLButtonElement, [
'tabindex',
'aria-rowindex',
'data-state'
]);
const NamedForm = observe(HTMLFormElement, ['data-n']);
customElements.define('observed-button', ObservedButton, { extends: 'button' });
customElements.define('inherited-namespace-button', Inherited, { extends: 'button' });
customElements.define('own-namespace-button', Own, { extends: 'button' });
customElements.define('native-prototype-button', NativePrototypeButton, { extends: 'button' });
customElements.define('same-string-button', SameStringButton, { extends: 'button' });
customElements.define('named-form', NamedForm, { extends: 'form' });
const upgraded = {
retained: ObservedButton,
removed: ObservedButton,
unset: ObservedButton,
inherited: Inherited,
'own-namespace': Own,
'native-retained': NativePrototypeButton,
'native-removed': NativePrototypeButton,
same: SameStringButton
};
for (const [text, constructor] of Object.entries(upgraded))
ok(find(text) instanceof constructor);
for (const form of target.querySelectorAll('form')) ok(form instanceof NamedForm);
// an upgraded element stays custom without `is`
for (const element of [find('removed'), find('native-removed'), find('late-removed')]) {
element.removeAttribute('is');
}
for (const form of target.querySelectorAll('[data-case]:not([data-case=form-retained])')) {
form.removeAttribute('is');
}
const SVG = 'http://www.w3.org/2000/svg';
Object.defineProperty(Inherited.prototype, 'namespaceURI', { get: () => SVG });
Object.defineProperty(find('own-namespace'), 'namespaceURI', { value: SVG });
ok(find('inherited').namespaceURI === SVG && find('own-namespace').namespaceURI === SVG);
for (const text of ['native-retained', 'native-removed']) {
Object.setPrototypeOf(find(text), HTMLButtonElement.prototype);
}
changes.length = 0;
observer = new MutationObserver(() => {});
observer.observe(/** @type {Element} */ (target.querySelector('#spread')), {
attributes: true,
subtree: true
});
},
test({ assert, component, target }) {
// spread `disabled` and `hidden` always use their setters
assert.deepEqual(
observer
.takeRecords()
.map((record) => `${record.target.nodeName.toLowerCase()} ${record.attributeName}`),
[
'button disabled',
'fieldset disabled',
'input disabled',
'select disabled',
'optgroup disabled',
'option disabled',
'textarea disabled',
'div disabled',
'p hidden',
'p hidden'
]
);
observer.disconnect();
for (const name of [
'button',
'fieldset',
'input',
'optgroup',
'option',
'select',
'textarea'
]) {
const element = /** @type {HTMLButtonElement} */ (target.querySelector(`#spread ${name}`));
assert.equal(element.disabled, true);
assert.equal(element.getAttribute('disabled'), '');
}
assert.equal(target.querySelector('#spread div')?.getAttribute('disabled'), 'true');
const [to_true, to_false, kept] = target.querySelectorAll('#spread p');
assert.equal(to_true.getAttribute('hidden'), '');
assert.equal(to_false.hasAttribute('hidden'), false);
assert.equal(kept.getAttribute('hidden'), 'until-found');
assert.equal(target.querySelector('main > svg')?.getAttribute('data-effect'), 'true');
assert.equal(target.querySelector('main > button')?.getAttribute('data-effect'), 'true');
assert.deepEqual(calls, [
'wrapped early disabled true',
'wrapped early hidden true',
'replaced late disabled true',
'wrapped late disabled true',
'replaced late hidden true',
'wrapped late hidden true',
'own-while-hydrating true'
]);
for (const div of target.querySelectorAll('#accessors div')) {
assert.equal(div.getAttribute('hidden'), '');
}
// `translate` and `draggable` already read `false`
const div = /** @type {HTMLElement} */ (target.querySelector('[translate] > div'));
const a = /** @type {HTMLElement} */ (target.querySelector('a'));
assert.equal(div.getAttribute('translate'), 'no');
assert.equal(a.getAttribute('draggable'), 'false');
flushSync(() => component.$set({ parent: 'yes', href: '#top' }));
assert.equal(div.translate, false);
assert.equal(a.draggable, false);
// a canvas dimension write resets the bitmap
for (const canvas of target.querySelectorAll('canvas')) {
assert.equal(alpha(canvas), 0);
assert.equal(canvas.width, 30);
assert.equal(canvas.height, 30);
}
// with the native prototype, a customized built-in is compared as native (a known limitation)
assert.deepEqual(
changes.splice(0).sort(),
[
...['retained', 'removed', 'unset'].flatMap((text) =>
Object.keys(attributes).map((name) => `${text} ${name}`)
),
'inherited data-n',
'own-namespace data-n',
'native-retained disabled',
'native-retained hidden',
'native-removed disabled',
'native-removed hidden',
'form-retained data-n',
'form-removed data-n',
'form-local-name data-n'
].sort()
);
for (const text of ['retained', 'removed', 'unset']) {
const button = find(text);
assert.equal(button.disabled, true);
assert.equal(button.hidden, true);
assert.equal(button.tabIndex, -1);
assert.equal(button.getAttribute('aria-rowindex'), '2');
}
assert.equal(find('unset').hasAttribute('is'), false);
flushSync(() =>
component.$set({
native: { ...props.native, retained: { ...props.native.retained, tabindex: 0 } },
same: { ...props.same, 'data-state': 'off' }
})
);
assert.deepEqual(changes.splice(0).sort(), ['native-retained tabindex', 'same data-state']);
// defined after hydration, which wrote to plain buttons
customElements.define(
'late-button',
observe(HTMLButtonElement, Object.keys(attributes), true),
{
extends: 'button'
}
);
assert.deepEqual(
changes.splice(0).sort(),
['late-removed', 'late-retained'].flatMap((text) => [
`${text} aria-rowindex null 2`,
`${text} disabled null `,
`${text} hidden null `,
`${text} tabindex null -1`
])
);
flushSync(() =>
component.$set({
late: {
retained: { ...props.late.retained, tabindex: 0 },
removed: { ...props.late.removed, disabled: false }
}
})
);
assert.deepEqual(changes.sort(), [
'late-removed disabled null',
'late-retained tabindex -1 0'
]);
}
});

@ -0,0 +1,51 @@
/** @type {string[]} */
export const calls = [];
/**
* Wraps the native `disabled` and `hidden` setters, recording calls inside `#accessors`
* @param {string} label
*/
export function wrap(label) {
/** @type {Array<[object, string]>} */
const accessors = [
[HTMLButtonElement.prototype, 'disabled'],
[HTMLElement.prototype, 'hidden']
];
for (const [prototype, name] of accessors) {
const descriptor = /** @type {PropertyDescriptor} */ (
Object.getOwnPropertyDescriptor(prototype, name)
);
Object.defineProperty(prototype, name, {
...descriptor,
/**
* @this {HTMLElement}
* @param {boolean} value
*/
set(value) {
if (this.closest('#accessors')) calls.push(`${label} ${this.textContent} ${name} ${value}`);
descriptor.set?.call(this, value);
}
});
}
}
/** Replaces the setters while hydrating, and gives the button labelled `own-while-hydrating` an own `disabled` setter */
export function replace() {
const own = [...document.querySelectorAll('#accessors button')].find(
(button) => button.textContent === 'own-while-hydrating'
);
// the server renders this component too, before there is any markup
if (!own) return;
wrap('replaced');
Object.defineProperty(own, 'disabled', {
/** @param {boolean} value */
set(value) {
calls.push(`own-while-hydrating ${value}`);
}
});
}

@ -0,0 +1,67 @@
<script>
import Replace from './Replace.svelte';
let {
on = { disabled: true },
to_true,
to_false,
kept,
parent = 'no',
translate,
draggable,
href,
size = 30,
observed,
native,
named,
late,
same
} = $props();
const disabled = { disabled: true };
const hidden = { hidden: true };
</script>
<svg {...hidden}></svg>
<button {...disabled}>own</button>
<section id="spread">
<button {...on}>x</button>
<fieldset {...on}></fieldset>
<input {...on} />
<select {...on}><optgroup {...on} label="g"><option {...on}>o</option></optgroup></select>
<textarea {...on}></textarea>
<div {...on}></div>
<p {...to_true}>a</p>
<p {...to_false}>b</p>
<p {...kept}>c</p>
</section>
<section id="accessors">
<button {...disabled}>early</button>
<div {...hidden}>early</div>
<Replace />
<button {...disabled}>late</button>
<div {...hidden}>late</div>
<button {...disabled}>own-while-hydrating</button>
</section>
<section translate={parent}><div {translate}>translate</div></section>
<a {href} {draggable}>link</a>
<canvas width={size} height={size}></canvas>
<canvas {...{ width: size, height: size }}></canvas>
<button {...observed.retained}>retained</button>
<button {...observed.removed}>removed</button>
<button {...observed.unset}>unset</button>
<button {...{ is: 'inherited-namespace-button', 'data-n': 1 }}>inherited</button>
<button {...{ is: 'own-namespace-button', 'data-n': 1 }}>own-namespace</button>
<button {...native.retained}>native-retained</button>
<button {...native.removed}>native-removed</button>
<button {...same}>same</button>
<form data-case="form-retained" {...named.retained}><input name="namespaceURI" /></form>
<form data-case="form-removed" {...named.removed}><input name="namespaceURI" /></form>
<form data-case="form-local-name" {...named.removed}><input name="localName" /></form>
<button {...late.retained}>late-retained</button>
<button {...late.removed}>late-removed</button>

@ -30,6 +30,9 @@ const { run: run_browser_tests } = suite_with_variants<
if (variant === 'hydrate') {
if (config.mode && !config.mode.includes('hydrate')) return 'no-test';
if (config.skip_mode?.includes('hydrate')) return true;
} else {
if (config.mode && !config.mode.includes('client')) return 'no-test';
if (config.skip_mode?.includes('client')) return true;
}
return false;

@ -0,0 +1,36 @@
import { tick } from 'svelte';
import { test } from '../../test';
/** @type {string[]} */
const written = [];
/** @type {MutationObserver | undefined} */
let observer;
export default test({
mode: ['hydrate'],
server_props: { n: 1, m: 1 },
props: { n: 1, m: 2 },
before_test() {
observer?.disconnect();
written.length = 0;
observer = new MutationObserver((records) => {
for (const record of records) written.push(/** @type {string} */ (record.attributeName));
});
observer.observe(document.body, { attributes: true, subtree: true });
},
async test({ assert, target }) {
await tick();
observer?.disconnect();
// the `{#if}` is hydrated once its condition resolves; an awaited value is applied after hydration
assert.deepEqual(written.sort(), ['data-deferred', 'data-m']);
assert.htmlEqual(
target.innerHTML,
'<div data-n="1" data-m="2"></div> <p data-deferred="1"></p>'
);
}
});

@ -0,0 +1,14 @@
<script>
let { n, m } = $props();
/** @param {any} value */
async function get(value) {
return value;
}
</script>
{#if await get(true)}
<div data-n={n} data-m={m}></div>
{/if}
<p data-deferred={await get(n)}></p>
Loading…
Cancel
Save