diff --git a/.changeset/fuzzy-bags-camp.md b/.changeset/fuzzy-bags-camp.md new file mode 100644 index 0000000000..513b7b98a5 --- /dev/null +++ b/.changeset/fuzzy-bags-camp.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: adds reactive Map class to svelte/reactivity diff --git a/packages/svelte/src/reactivity/index.js b/packages/svelte/src/reactivity/index.js index 67c4279fce..38c7d91885 100644 --- a/packages/svelte/src/reactivity/index.js +++ b/packages/svelte/src/reactivity/index.js @@ -1,2 +1,3 @@ export { ReactiveDate as Date } from './date.js'; export { ReactiveSet as Set } from './set.js'; +export { ReactiveMap as Map } from './map.js'; diff --git a/packages/svelte/src/reactivity/map.js b/packages/svelte/src/reactivity/map.js new file mode 100644 index 0000000000..e85836036d --- /dev/null +++ b/packages/svelte/src/reactivity/map.js @@ -0,0 +1,185 @@ +import { DEV } from 'esm-env'; +import { source, set } from '../internal/client/reactivity/sources.js'; +import { get } from '../internal/client/runtime.js'; +import { UNINITIALIZED } from '../internal/client/constants.js'; +import { make_iterable } from './utils.js'; + +/** + * @template K + * @template V + */ +export class ReactiveMap extends Map { + /** @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 Map(value); + + if (value) { + for (var [key, v] of value) { + this.set(key, v); + } + } + } + + #increment_version() { + set(this.#version, this.#version.v + 1); + } + + /** @param {K} key */ + has(key) { + var source = this.#sources.get(key); + + if (source === undefined) { + // We should always track the version in case + // the Set ever gets this value in the future. + get(this.#version); + + return false; + } + + get(source); + return true; + } + + /** + * @param {(value: V, key: K, map: Map) => void} callbackfn + * @param {any} [this_arg] + */ + forEach(callbackfn, this_arg) { + get(this.#version); + + return super.forEach(callbackfn, this_arg); + } + + /** @param {K} key */ + get(key) { + var source = this.#sources.get(key); + + if (source === undefined) { + // We should always track the version in case + // the Set ever gets this value in the future. + get(this.#version); + + return undefined; + } + + return get(source); + } + + /** + * @param {K} key + * @param {V} value + * */ + set(key, value) { + var sources = this.#sources; + var source_value = sources.get(key); + + if (source_value === undefined) { + sources.set(key, source(value)); + set(this.#size, sources.size); + this.#increment_version(); + } else { + set(source_value, value); + } + + return super.set(key, value); + } + + /** @param {K} key */ + delete(key) { + var sources = this.#sources; + var source = sources.get(key); + + if (source !== undefined) { + sources.delete(key); + set(this.#size, sources.size); + set(source, /** @type {V} */ (UNINITIALIZED)); + this.#increment_version(); + } + + return super.delete(key); + } + + clear() { + var sources = this.#sources; + + if (sources.size !== 0) { + set(this.#size, 0); + for (var source of sources.values()) { + set(source, /** @type {V} */ (UNINITIALIZED)); + } + this.#increment_version(); + } + + sources.clear(); + super.clear(); + } + + keys() { + 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 }; + } + }) + ); + } + + values() { + get(this.#version); + var iterator = this.#sources.values(); + + return make_iterable( + /** @type {IterableIterator} */ ({ + next() { + for (var source of iterator) { + return { value: get(source), done: false }; + } + + return { done: true }; + } + }) + ); + } + + entries() { + get(this.#version); + var iterator = this.#sources.entries(); + + return make_iterable( + /** @type {IterableIterator<[K, V]>} */ ({ + next() { + for (var [key, source] of iterator) { + return { value: [key, get(source)], done: false }; + } + + return { done: true }; + } + }) + ); + } + + [Symbol.iterator]() { + return this.entries(); + } + + get size() { + return get(this.#size); + } +} diff --git a/packages/svelte/src/reactivity/map.test.ts b/packages/svelte/src/reactivity/map.test.ts new file mode 100644 index 0000000000..e50696309b --- /dev/null +++ b/packages/svelte/src/reactivity/map.test.ts @@ -0,0 +1,125 @@ +import { pre_effect, user_root_effect } from '../internal/client/reactivity/effects.js'; +import { flushSync } from '../main/main-client.js'; +import { ReactiveMap } from './map.js'; +import { assert, test } from 'vitest'; + +test('map.values()', () => { + const map = new ReactiveMap([ + [1, 1], + [2, 2], + [3, 3], + [4, 4], + [5, 5] + ]); + + const log: any = []; + + const cleanup = user_root_effect(() => { + pre_effect(() => { + log.push(map.size); + }); + + pre_effect(() => { + log.push(map.has(3)); + }); + + pre_effect(() => { + log.push(Array.from(map.values())); + }); + }); + + flushSync(() => { + map.delete(3); + }); + + flushSync(() => { + map.clear(); + }); + + assert.deepEqual(log, [5, true, [1, 2, 3, 4, 5], 4, false, [1, 2, 4, 5], 0, [], false]); // TODO update when we fix effect ordering bug + + cleanup(); +}); + +test('map.get(...)', () => { + const map = new ReactiveMap([ + [1, 1], + [2, 2], + [3, 3] + ]); + + const log: any = []; + + const cleanup = user_root_effect(() => { + pre_effect(() => { + log.push('get 1', map.get(1)); + }); + + pre_effect(() => { + log.push('get 2', map.get(2)); + }); + + pre_effect(() => { + log.push('get 3', map.get(3)); + }); + }); + + flushSync(() => { + map.delete(2); + }); + + flushSync(() => { + map.set(2, 2); + }); + + assert.deepEqual(log, ['get 1', 1, 'get 2', 2, 'get 3', 3, 'get 2', undefined, 'get 2', 2]); + + cleanup(); +}); + +test('map.has(...)', () => { + const map = new ReactiveMap([ + [1, 1], + [2, 2], + [3, 3] + ]); + + const log: any = []; + + const cleanup = user_root_effect(() => { + pre_effect(() => { + log.push('has 1', map.has(1)); + }); + + pre_effect(() => { + log.push('has 2', map.has(2)); + }); + + pre_effect(() => { + log.push('has 3', map.has(3)); + }); + }); + + flushSync(() => { + map.delete(2); + }); + + flushSync(() => { + map.set(2, 2); + }); + + assert.deepEqual(log, [ + 'has 1', + true, + 'has 2', + true, + 'has 3', + true, + 'has 2', + false, + 'has 2', + true + ]); + + cleanup(); +}); diff --git a/packages/svelte/src/reactivity/set.js b/packages/svelte/src/reactivity/set.js index 173c0095a0..b601f4efb5 100644 --- a/packages/svelte/src/reactivity/set.js +++ b/packages/svelte/src/reactivity/set.js @@ -1,6 +1,7 @@ import { DEV } from 'esm-env'; import { source, set } from '../internal/client/reactivity/sources.js'; import { get } from '../internal/client/runtime.js'; +import { make_iterable } from './utils.js'; var read = [ 'difference', @@ -13,22 +14,6 @@ var read = [ '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; /** diff --git a/packages/svelte/src/reactivity/utils.js b/packages/svelte/src/reactivity/utils.js new file mode 100644 index 0000000000..e30ec9f16e --- /dev/null +++ b/packages/svelte/src/reactivity/utils.js @@ -0,0 +1,15 @@ +/** + * @template T + * @param {IterableIterator} iterator + */ +export function make_iterable(iterator) { + iterator[Symbol.iterator] = get_self; + return iterator; +} + +/** + * @this {any} + */ +function get_self() { + return this; +} diff --git a/packages/svelte/tests/runtime-runes/samples/reactive-map/_config.js b/packages/svelte/tests/runtime-runes/samples/reactive-map/_config.js new file mode 100644 index 0000000000..45f72a95e2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/reactive-map/_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:1
` + ); + + flushSync(() => { + btn?.click(); + }); + + flushSync(() => { + btn?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `
1:1
2:2
3:3
` + ); + + flushSync(() => { + btn2?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `
1:1
2:2
` + ); + + flushSync(() => { + btn3?.click(); + }); + + assert.htmlEqual( + target.innerHTML, + `` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/reactive-map/main.svelte b/packages/svelte/tests/runtime-runes/samples/reactive-map/main.svelte new file mode 100644 index 0000000000..703ef4cf99 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/reactive-map/main.svelte @@ -0,0 +1,21 @@ + + + + + + + + +{#each state as [key, value]} +
{key}:{value}
+{/each}