feat: adds reactive Map class to svelte/reactivity (#10803)

* feat: adds reactive Map class to svelte/reactivity

* add docs

* add docs

* add test case

* types

* make reactive set better

* address feedback

* fix typo

* more efficient initialisation

* this is incorrect, it would fail if given a map for example

* increase consistency (with e.g. proxy.js)

* tidy up

* Revert "more efficient initialisation"

This reverts commit 29d4a8078b.

* efficient initialization, without bugs this time

* convention

* delete make_iterable

* update changeset

* efficient initialization

* avoid generator functions

* Update sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
Co-authored-by: Rich Harris <richard.a.harris@gmail.com>
pull/10806/head
Dominic Gannaway 10 months ago committed by GitHub
parent c35f0c16af
commit fe3b3b463c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -48,7 +48,6 @@ export function proxy(value, immutable = true, owners) {
const prototype = get_prototype_of(value);
// TODO handle Map and Set as well
if (prototype === object_prototype || prototype === array_prototype) {
const proxy = new Proxy(value, state_proxy_handler);

@ -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';

@ -0,0 +1,157 @@
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 { map } from './utils.js';
/**
* @template K
* @template V
*/
export class ReactiveMap extends Map {
/** @type {Map<K, import('#client').Source<V>>} */
#sources = new Map();
#version = source(0);
#size = source(0);
/**
* @param {Iterable<readonly [K, V]> | 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) {
var sources = this.#sources;
for (var [key, v] of value) {
sources.set(key, source(v));
super.set(key, v);
}
this.#size.v = sources.size;
}
}
#increment_version() {
set(this.#version, this.#version.v + 1);
}
/** @param {K} key */
has(key) {
var s = this.#sources.get(key);
if (s === undefined) {
// We should always track the version in case
// the Set ever gets this value in the future.
get(this.#version);
return false;
}
get(s);
return true;
}
/**
* @param {(value: V, key: K, map: Map<K, V>) => 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 s = this.#sources.get(key);
if (s === 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(s);
}
/**
* @param {K} key
* @param {V} value
* */
set(key, value) {
var sources = this.#sources;
var s = sources.get(key);
if (s === undefined) {
sources.set(key, source(value));
set(this.#size, sources.size);
this.#increment_version();
} else {
set(s, value);
}
return super.set(key, value);
}
/** @param {K} key */
delete(key) {
var sources = this.#sources;
var s = sources.get(key);
if (s !== undefined) {
sources.delete(key);
set(this.#size, sources.size);
set(s, /** @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 s of sources.values()) {
set(s, /** @type {V} */ (UNINITIALIZED));
}
this.#increment_version();
}
sources.clear();
super.clear();
}
keys() {
get(this.#version);
return this.#sources.keys();
}
values() {
get(this.#version);
return map(this.#sources.values(), get);
}
entries() {
get(this.#version);
return map(
this.#sources.entries(),
([key, source]) => /** @type {[K, V]} */ ([key, get(source)])
);
}
[Symbol.iterator]() {
return this.entries();
}
get size() {
return get(this.#size);
}
}

@ -0,0 +1,151 @@
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();
});
test('map handling of undefined values', () => {
const map = new ReactiveMap();
const log: any = [];
const cleanup = user_root_effect(() => {
map.set(1, undefined);
pre_effect(() => {
log.push(map.get(1));
});
flushSync(() => {
map.delete(1);
});
flushSync(() => {
map.set(1, 1);
});
});
assert.deepEqual(log, [undefined, undefined, 1]);
cleanup();
});

@ -1,33 +1,10 @@
import { DEV } from 'esm-env';
import { source, set } from '../internal/client/reactivity/sources.js';
import { get } from '../internal/client/runtime.js';
import { map } from './utils.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 read_methods = ['forEach', 'isDisjointFrom', 'isSubsetOf', 'isSupersetOf'];
var set_like_methods = ['difference', 'intersection', 'symmetricDifference', 'union'];
var inited = false;
@ -41,7 +18,7 @@ export class ReactiveSet extends Set {
#size = source(0);
/**
* @param {Iterable<T> | null | undefined} value
* @param {Iterable<T> | null | undefined} [value]
*/
constructor(value) {
super();
@ -50,9 +27,14 @@ export class ReactiveSet extends Set {
if (DEV) new Set(value);
if (value) {
var sources = this.#sources;
for (var element of value) {
this.add(element);
sources.set(element, source(true));
super.add(element);
}
this.#size.v = sources.size;
}
if (!inited) this.#init();
@ -65,7 +47,10 @@ export class ReactiveSet extends Set {
var proto = ReactiveSet.prototype;
var set_proto = Set.prototype;
for (var method of read) {
/** @type {string} */
var method;
for (method of read_methods) {
// @ts-ignore
proto[method] = function (...v) {
get(this.#version);
@ -73,6 +58,17 @@ export class ReactiveSet extends Set {
return set_proto[method].apply(this, v);
};
}
for (method of set_like_methods) {
// @ts-ignore
proto[method] = function (...v) {
get(this.#version);
// @ts-ignore
var set = /** @type {Set<T>} */ (set_proto[method].apply(this, v));
return new ReactiveSet(set);
};
}
}
#increment_version() {
@ -81,9 +77,9 @@ export class ReactiveSet extends Set {
/** @param {T} value */
has(value) {
var source = this.#sources.get(value);
var s = this.#sources.get(value);
if (source === undefined) {
if (s === undefined) {
// We should always track the version in case
// the Set ever gets this value in the future.
get(this.#version);
@ -91,7 +87,7 @@ export class ReactiveSet extends Set {
return false;
}
return get(source);
return get(s);
}
/** @param {T} value */
@ -110,12 +106,12 @@ export class ReactiveSet extends Set {
/** @param {T} value */
delete(value) {
var sources = this.#sources;
var source = sources.get(value);
var s = sources.get(value);
if (source !== undefined) {
if (s !== undefined) {
sources.delete(value);
set(this.#size, sources.size);
set(source, false);
set(s, false);
this.#increment_version();
}
@ -127,8 +123,8 @@ export class ReactiveSet extends Set {
if (sources.size !== 0) {
set(this.#size, 0);
for (var source of sources.values()) {
set(source, false);
for (var s of sources.values()) {
set(s, false);
}
this.#increment_version();
}
@ -138,45 +134,20 @@ export class ReactiveSet extends Set {
}
keys() {
return this.values();
get(this.#version);
return this.#sources.keys();
}
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 };
}
})
);
return this.keys();
}
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 };
}
})
);
return map(this.keys(), (key) => /** @type {[T, T]} */ ([key, key]));
}
[Symbol.iterator]() {
return this.values();
return this.keys();
}
get size() {

@ -0,0 +1,24 @@
/**
* @template T
* @template U
* @param {Iterable<T>} iterable
* @param {(value: T) => U} fn
* @returns {IterableIterator<U>}
*/
export function map(iterable, fn) {
return {
[Symbol.iterator]: get_this,
next() {
for (const value of iterable) {
return { done: false, value: fn(value) };
}
return { done: true, value: undefined };
}
};
}
/** @this {any} */
function get_this() {
return this;
}

@ -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:1</div>`
);
flushSync(() => {
btn?.click();
});
flushSync(() => {
btn?.click();
});
assert.htmlEqual(
target.innerHTML,
`<button>add</button><button>delete</button><button>clear</button><div>1:1</div><div>2:2</div><div>3:3</div>`
);
flushSync(() => {
btn2?.click();
});
assert.htmlEqual(
target.innerHTML,
`<button>add</button><button>delete</button><button>clear</button><div>1:1</div><div>2:2</div>`
);
flushSync(() => {
btn3?.click();
});
assert.htmlEqual(
target.innerHTML,
`<button>add</button><button>delete</button><button>clear</button>`
);
}
});

@ -0,0 +1,21 @@
<script>
import {Map} from 'svelte/reactivity';
let state = new Map();
</script>
<button onclick={() => {
state.set(state.size + 1, state.size + 1);
}}>add</button>
<button onclick={() => {
state.delete(state.size);
}}>delete</button>
<button onclick={() => {
state.clear();
}}>clear</button>
{#each state as [key, value]}
<div>{key}:{value}</div>
{/each}

@ -93,6 +93,25 @@ This can improve performance with large arrays and objects that you weren't plan
> Objects and arrays passed to `$state.frozen` will be shallowly frozen using `Object.freeze()`. If you don't want this, pass in a clone of the object or array instead.
### Reactive Map, Set and Date
Svelte provides reactive `Map`, `Set` and `Date` classes. These can be imported from `svelte/reactivity` and used just like their native counterparts.
```svelte
<script>
import { Map } from 'svelte/reactivity';
const map = new Map();
map.set('message', 'hello');
function update_message() {
map.set('message', 'goodbye');
}
</script>
<p>{map.get('message')}</p>
```
## `$derived`
Derived state is declared with the `$derived` rune:

Loading…
Cancel
Save