mirror of https://github.com/sveltejs/svelte
Merge 88445e8270 into 020242d6be
commit
989082fb0f
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: skip redundant attribute writes during hydration
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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…
Reference in new issue