feat: add reactive Set class to svelte/reactivity (#10781)

* feat: add reactive Set class to svelte/reactivity

* add some type safety

* simplify, read entries lazily

* failing unit test

* fix deletions

* minor tweaks

* work around effect ordering bug

* simplify, make entries lazy

* small tweak

* use var, minor tweaks

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/10770/head
Dominic Gannaway 7 months ago committed by GitHub
parent eb40417c36
commit 3f7fcf9aec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: add reactive Set class to svelte/reactivity

@ -147,8 +147,7 @@ export function unstate(value) {
* @param {1 | -1} [d]
*/
function update_version(signal, d = 1) {
const value = untrack(() => get(signal));
set(signal, value + d);
set(signal, signal.v + d);
}
/** @type {ProxyHandler<import('./types.js').ProxyStateObject<any>>} */

@ -0,0 +1,101 @@
import { source, set } from '../internal/client/reactivity/sources.js';
import { get } from '../internal/client/runtime.js';
/** @type {Array<keyof Date>} */
const read = [
'getDate',
'getDay',
'getFullYear',
'getHours',
'getMilliseconds',
'getMinutes',
'getMonth',
'getSeconds',
'getTime',
'getTimezoneOffset',
'getUTCDate',
'getUTCDay',
'getUTCFullYear',
'getUTCHours',
'getUTCMilliseconds',
'getUTCMinutes',
'getUTCMonth',
'getUTCSeconds',
// @ts-expect-error this is deprecated
'getYear',
'toDateString',
'toISOString',
'toJSON',
'toLocaleDateString',
'toLocaleString',
'toLocaleTimeString',
'toString',
'toTimeString',
'toUTCString'
];
/** @type {Array<keyof Date>} */
const write = [
'setDate',
'setFullYear',
'setHours',
'setMilliseconds',
'setMinutes',
'setMonth',
'setSeconds',
'setTime',
'setUTCDate',
'setUTCFullYear',
'setUTCHours',
'setUTCMilliseconds',
'setUTCMinutes',
'setUTCMonth',
'setUTCSeconds',
// @ts-expect-error this is deprecated
'setYear'
];
export class ReactiveDate extends Date {
#raw_time = source(super.getTime());
static #inited = false;
// We init as part of the first instance so that we can treeshake this class
#init() {
if (!ReactiveDate.#inited) {
ReactiveDate.#inited = true;
const proto = ReactiveDate.prototype;
const date_proto = Date.prototype;
for (const method of read) {
// @ts-ignore
proto[method] = function () {
get(this.#raw_time);
// @ts-ignore
return date_proto[method].call(this);
};
}
for (const method of write) {
// @ts-ignore
proto[method] = function (/** @type {any} */ ...args) {
// @ts-ignore
const v = date_proto[method].apply(this, args);
const time = date_proto.getTime.call(this);
if (time !== this.#raw_time.v) {
set(this.#raw_time, time);
}
return v;
};
}
}
}
/**
* @param {any[]} values
*/
constructor(...values) {
// @ts-ignore
super(...values);
this.#init();
}
}

@ -1,103 +1,2 @@
import { source, set } from '../internal/client/reactivity/sources.js';
import { get } from '../internal/client/runtime.js';
/** @type {Array<keyof Date>} */
const read = [
'getDate',
'getDay',
'getFullYear',
'getHours',
'getMilliseconds',
'getMinutes',
'getMonth',
'getSeconds',
'getTime',
'getTimezoneOffset',
'getUTCDate',
'getUTCDay',
'getUTCFullYear',
'getUTCHours',
'getUTCMilliseconds',
'getUTCMinutes',
'getUTCMonth',
'getUTCSeconds',
// @ts-expect-error this is deprecated
'getYear',
'toDateString',
'toISOString',
'toJSON',
'toLocaleDateString',
'toLocaleString',
'toLocaleTimeString',
'toString',
'toTimeString',
'toUTCString'
];
/** @type {Array<keyof Date>} */
const write = [
'setDate',
'setFullYear',
'setHours',
'setMilliseconds',
'setMinutes',
'setMonth',
'setSeconds',
'setTime',
'setUTCDate',
'setUTCFullYear',
'setUTCHours',
'setUTCMilliseconds',
'setUTCMinutes',
'setUTCMonth',
'setUTCSeconds',
// @ts-expect-error this is deprecated
'setYear'
];
class ReactiveDate extends Date {
#raw_time = source(super.getTime());
static #inited = false;
// We init as part of the first instance so that we can treeshake this class
#init() {
if (!ReactiveDate.#inited) {
ReactiveDate.#inited = true;
const proto = ReactiveDate.prototype;
const date_proto = Date.prototype;
for (const method of read) {
// @ts-ignore
proto[method] = function () {
get(this.#raw_time);
// @ts-ignore
return date_proto[method].call(this);
};
}
for (const method of write) {
// @ts-ignore
proto[method] = function (/** @type {any} */ ...args) {
// @ts-ignore
const v = date_proto[method].apply(this, args);
const time = date_proto.getTime.call(this);
if (time !== this.#raw_time.v) {
set(this.#raw_time, time);
}
return v;
};
}
}
}
/**
* @param {any[]} values
*/
constructor(...values) {
// @ts-ignore
super(...values);
this.#init();
}
}
export { ReactiveDate as Date };
export { ReactiveDate as Date } from './date.js';
export { ReactiveSet as Set } from './set.js';

@ -0,0 +1,182 @@
import { DEV } from 'esm-env';
import { source, set } from '../internal/client/reactivity/sources.js';
import { get } from '../internal/client/runtime.js';
var read = [
'difference',
'forEach',
'intersection',
'isDisjointFrom',
'isSubsetOf',
'isSupersetOf',
'symmetricDifference',
'union'
];
/**
* @template T
* @param {IterableIterator<T>} iterator
*/
function make_iterable(iterator) {
iterator[Symbol.iterator] = get_self;
return iterator;
}
/**
* @this {any}
*/
function get_self() {
return this;
}
var inited = false;
/**
* @template T
*/
export class ReactiveSet extends Set {
/** @type {Map<T, import('#client').Source<boolean>>} */
#sources = new Map();
#version = source(0);
#size = source(0);
/**
* @param {Iterable<T> | null | undefined} value
*/
constructor(value) {
super();
// If the value is invalid then the native exception will fire here
if (DEV) new Set(value);
if (value) {
for (var element of value) {
this.add(element);
}
}
if (!inited) this.#init();
}
// We init as part of the first instance so that we can treeshake this class
#init() {
inited = true;
var proto = ReactiveSet.prototype;
var set_proto = Set.prototype;
for (var method of read) {
// @ts-ignore
proto[method] = function (...v) {
get(this.#version);
// @ts-ignore
return set_proto[method].apply(this, v);
};
}
}
#increment_version() {
set(this.#version, this.#version.v + 1);
}
/** @param {T} value */
has(value) {
var source = this.#sources.get(value);
if (source === undefined) {
get(this.#version);
return false;
}
return get(source);
}
/** @param {T} value */
add(value) {
var sources = this.#sources;
if (!sources.has(value)) {
sources.set(value, source(true));
set(this.#size, sources.size);
this.#increment_version();
}
return super.add(value);
}
/** @param {T} value */
delete(value) {
var sources = this.#sources;
var source = sources.get(value);
if (source !== undefined) {
sources.delete(value);
set(this.#size, sources.size);
set(source, false);
this.#increment_version();
}
return super.delete(value);
}
clear() {
var sources = this.#sources;
if (sources.size !== 0) {
set(this.#size, 0);
for (var source of sources.values()) {
set(source, false);
}
this.#increment_version();
}
sources.clear();
super.clear();
}
keys() {
return this.values();
}
values() {
get(this.#version);
var iterator = this.#sources.keys();
return make_iterable(
/** @type {IterableIterator<T>} */ ({
next() {
for (var value of iterator) {
return { value, done: false };
}
return { done: true };
}
})
);
}
entries() {
var iterator = this.values();
return make_iterable(
/** @type {IterableIterator<[T, T]>} */ ({
next() {
for (var value of iterator) {
return { value: [value, value], done: false };
}
return { done: true };
}
})
);
}
[Symbol.iterator]() {
return this.values();
}
get size() {
return get(this.#size);
}
}

@ -0,0 +1,38 @@
import { pre_effect, user_root_effect } from '../internal/client/reactivity/effects.js';
import { flushSync } from '../main/main-client.js';
import { ReactiveSet } from './set.js';
import { assert, test } from 'vitest';
test('set.values()', () => {
const set = new ReactiveSet([1, 2, 3, 4, 5]);
const log: any = [];
const cleanup = user_root_effect(() => {
pre_effect(() => {
log.push(set.size);
});
pre_effect(() => {
log.push(set.has(3));
});
pre_effect(() => {
log.push(Array.from(set));
});
});
flushSync(() => {
set.delete(3);
});
flushSync(() => {
set.clear();
});
// TODO looks like another effect ordering bug — sequence should be <size, has, values>,
// but values is reversed at end
assert.deepEqual(log, [5, true, [1, 2, 3, 4, 5], 4, false, [1, 2, 4, 5], 0, [], false]);
cleanup();
});

@ -0,0 +1,50 @@
import { flushSync } from '../../../../src/main/main-client';
import { test } from '../../test';
export default test({
html: `<button>add</button><button>delete</button><button>clear</button>`,
test({ assert, target }) {
const [btn, btn2, btn3] = target.querySelectorAll('button');
flushSync(() => {
btn?.click();
});
assert.htmlEqual(
target.innerHTML,
`<button>add</button><button>delete</button><button>clear</button><div>1</div>`
);
flushSync(() => {
btn?.click();
});
flushSync(() => {
btn?.click();
});
assert.htmlEqual(
target.innerHTML,
`<button>add</button><button>delete</button><button>clear</button><div>1</div><div>2</div><div>3</div>`
);
flushSync(() => {
btn2?.click();
});
assert.htmlEqual(
target.innerHTML,
`<button>add</button><button>delete</button><button>clear</button><div>1</div><div>2</div>`
);
flushSync(() => {
btn3?.click();
});
assert.htmlEqual(
target.innerHTML,
`<button>add</button><button>delete</button><button>clear</button>`
);
}
});

@ -0,0 +1,21 @@
<script>
import {Set} from 'svelte/reactivity';
let state = new Set();
</script>
<button onclick={() => {
state.add(state.size + 1);
}}>add</button>
<button onclick={() => {
state.delete(state.size);
}}>delete</button>
<button onclick={() => {
state.clear();
}}>clear</button>
{#each state as item}
<div>{item}</div>
{/each}
Loading…
Cancel
Save