From 3f7fcf9aec1feb7b35ee75e70ec607c2545eb02e Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Wed, 13 Mar 2024 02:11:25 +0000 Subject: [PATCH] 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 --- .changeset/calm-ravens-sneeze.md | 5 + packages/svelte/src/internal/client/proxy.js | 3 +- packages/svelte/src/reactivity/date.js | 101 ++++++++++ packages/svelte/src/reactivity/index.js | 105 +--------- packages/svelte/src/reactivity/set.js | 182 ++++++++++++++++++ packages/svelte/src/reactivity/set.test.ts | 38 ++++ .../{date => reactive-date}/_config.js | 0 .../{date => reactive-date}/main.svelte | 0 .../samples/reactive-set/_config.js | 50 +++++ .../samples/reactive-set/main.svelte | 21 ++ 10 files changed, 400 insertions(+), 105 deletions(-) create mode 100644 .changeset/calm-ravens-sneeze.md create mode 100644 packages/svelte/src/reactivity/date.js create mode 100644 packages/svelte/src/reactivity/set.js create mode 100644 packages/svelte/src/reactivity/set.test.ts rename packages/svelte/tests/runtime-runes/samples/{date => reactive-date}/_config.js (100%) rename packages/svelte/tests/runtime-runes/samples/{date => reactive-date}/main.svelte (100%) create mode 100644 packages/svelte/tests/runtime-runes/samples/reactive-set/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/reactive-set/main.svelte diff --git a/.changeset/calm-ravens-sneeze.md b/.changeset/calm-ravens-sneeze.md new file mode 100644 index 0000000000..4c463f5169 --- /dev/null +++ b/.changeset/calm-ravens-sneeze.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: add reactive Set class to svelte/reactivity diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index fbc827cee5..801cd5c15e 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -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>} */ diff --git a/packages/svelte/src/reactivity/date.js b/packages/svelte/src/reactivity/date.js new file mode 100644 index 0000000000..eb1e72fcc9 --- /dev/null +++ b/packages/svelte/src/reactivity/date.js @@ -0,0 +1,101 @@ +import { source, set } from '../internal/client/reactivity/sources.js'; +import { get } from '../internal/client/runtime.js'; + +/** @type {Array} */ +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} */ +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(); + } +} diff --git a/packages/svelte/src/reactivity/index.js b/packages/svelte/src/reactivity/index.js index f802121a2e..67c4279fce 100644 --- a/packages/svelte/src/reactivity/index.js +++ b/packages/svelte/src/reactivity/index.js @@ -1,103 +1,2 @@ -import { source, set } from '../internal/client/reactivity/sources.js'; -import { get } from '../internal/client/runtime.js'; - -/** @type {Array} */ -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} */ -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'; diff --git a/packages/svelte/src/reactivity/set.js b/packages/svelte/src/reactivity/set.js new file mode 100644 index 0000000000..5ec8193f0e --- /dev/null +++ b/packages/svelte/src/reactivity/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} 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>} */ + #sources = new Map(); + #version = source(0); + #size = source(0); + + /** + * @param {Iterable | 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} */ ({ + 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); + } +} diff --git a/packages/svelte/src/reactivity/set.test.ts b/packages/svelte/src/reactivity/set.test.ts new file mode 100644 index 0000000000..625a90db7d --- /dev/null +++ b/packages/svelte/src/reactivity/set.test.ts @@ -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 , + // 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(); +}); diff --git a/packages/svelte/tests/runtime-runes/samples/date/_config.js b/packages/svelte/tests/runtime-runes/samples/reactive-date/_config.js similarity index 100% rename from packages/svelte/tests/runtime-runes/samples/date/_config.js rename to packages/svelte/tests/runtime-runes/samples/reactive-date/_config.js diff --git a/packages/svelte/tests/runtime-runes/samples/date/main.svelte b/packages/svelte/tests/runtime-runes/samples/reactive-date/main.svelte similarity index 100% rename from packages/svelte/tests/runtime-runes/samples/date/main.svelte rename to packages/svelte/tests/runtime-runes/samples/reactive-date/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/reactive-set/_config.js b/packages/svelte/tests/runtime-runes/samples/reactive-set/_config.js new file mode 100644 index 0000000000..2ee78de79a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/reactive-set/_config.js @@ -0,0 +1,50 @@ +import { flushSync } from '../../../../src/main/main-client'; +import { test } from '../../test'; + +export default test({ + html: ``, + + test({ assert, target }) { + const [btn, btn2, btn3] = target.querySelectorAll('button'); + + flushSync(() => { + btn?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `
1
` + ); + + flushSync(() => { + btn?.click(); + }); + + flushSync(() => { + btn?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `
1
2
3
` + ); + + flushSync(() => { + btn2?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `
1
2
` + ); + + flushSync(() => { + btn3?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/reactive-set/main.svelte b/packages/svelte/tests/runtime-runes/samples/reactive-set/main.svelte new file mode 100644 index 0000000000..523335ced9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/reactive-set/main.svelte @@ -0,0 +1,21 @@ + + + + + + + + +{#each state as item} +
{item}
+{/each}