Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 2382eb0

Browse files
authoredMay 31, 2024
fix: improve reactive Map and Set implementations (#11827)
Closes #11727. This PR aims to tackle issues around our reactive Map/Set implementations. Notably: - We now store the values on the backing Map/Set, allowing for much better introspection in console/dev tools - We no longer store the values inside the source signals, instead we use Symbols and booleans only as markers There's one limitation around `.has(x)` when `x` is not in the Map/Set yet - it's not fine-grained. Making it so could create too much memory pressure when e.g. iterating a big list of items with few of them being in the set (`has` returns `false` most of the time).
1 parent 3c84c21 commit 2382eb0

File tree

6 files changed

+176
-88
lines changed

6 files changed

+176
-88
lines changed
 

‎.changeset/eight-jeans-compare.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: improve reactive Map and Set implementations

‎packages/svelte/src/internal/client/dev/inspect.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,6 @@ export function inspect(get_value, inspector = console.log) {
6262
*/
6363
function deep_snapshot(value, visited = new Map()) {
6464
if (typeof value === 'object' && value !== null && !visited.has(value)) {
65-
if (DEV) {
66-
// When dealing with ReactiveMap or ReactiveSet, return normal versions
67-
// so that console.log provides better output versions
68-
if (value instanceof Map && value.constructor !== Map) {
69-
return new Map(value);
70-
}
71-
if (value instanceof Set && value.constructor !== Set) {
72-
return new Set(value);
73-
}
74-
}
7565
const unstated = snapshot(value);
7666

7767
if (unstated !== value) {

‎packages/svelte/src/reactivity/map.js

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@ import { DEV } from 'esm-env';
22
import { source, set } from '../internal/client/reactivity/sources.js';
33
import { get } from '../internal/client/runtime.js';
44
import { UNINITIALIZED } from '../constants.js';
5-
import { map } from './utils.js';
65

76
/**
87
* @template K
98
* @template V
109
* @extends {Map<K, V>}
1110
*/
1211
export class ReactiveMap extends Map {
13-
/** @type {Map<K, import('#client').Source<V>>} */
12+
/** @type {Map<K, import('#client').Source<symbol>>} */
1413
#sources = new Map();
1514
#version = source(0);
1615
#size = source(0);
@@ -25,13 +24,10 @@ export class ReactiveMap extends Map {
2524
if (DEV) new Map(value);
2625

2726
if (value) {
28-
var sources = this.#sources;
29-
3027
for (var [key, v] of value) {
31-
sources.set(key, source(v));
28+
super.set(key, v);
3229
}
33-
34-
this.#size.v = sources.size;
30+
this.#size.v = super.size;
3531
}
3632
}
3733

@@ -41,14 +37,20 @@ export class ReactiveMap extends Map {
4137

4238
/** @param {K} key */
4339
has(key) {
44-
var s = this.#sources.get(key);
40+
var sources = this.#sources;
41+
var s = sources.get(key);
4542

4643
if (s === undefined) {
47-
// We should always track the version in case
48-
// the Set ever gets this value in the future.
49-
get(this.#version);
50-
51-
return false;
44+
var ret = super.get(key);
45+
if (ret !== undefined) {
46+
s = source(Symbol());
47+
sources.set(key, s);
48+
} else {
49+
// We should always track the version in case
50+
// the Set ever gets this value in the future.
51+
get(this.#version);
52+
return false;
53+
}
5254
}
5355

5456
get(s);
@@ -62,23 +64,29 @@ export class ReactiveMap extends Map {
6264
forEach(callbackfn, this_arg) {
6365
get(this.#version);
6466

65-
var bound_callbackfn = callbackfn.bind(this_arg);
66-
this.#sources.forEach((s, key) => bound_callbackfn(s.v, key, this));
67+
return super.forEach(callbackfn, this_arg);
6768
}
6869

6970
/** @param {K} key */
7071
get(key) {
71-
var s = this.#sources.get(key);
72+
var sources = this.#sources;
73+
var s = sources.get(key);
7274

7375
if (s === undefined) {
74-
// We should always track the version in case
75-
// the Set ever gets this value in the future.
76-
get(this.#version);
77-
78-
return undefined;
76+
var ret = super.get(key);
77+
if (ret !== undefined) {
78+
s = source(Symbol());
79+
sources.set(key, s);
80+
} else {
81+
// We should always track the version in case
82+
// the Set ever gets this value in the future.
83+
get(this.#version);
84+
return undefined;
85+
}
7986
}
8087

81-
return get(s);
88+
get(s);
89+
return super.get(key);
8290
}
8391

8492
/**
@@ -88,72 +96,71 @@ export class ReactiveMap extends Map {
8896
set(key, value) {
8997
var sources = this.#sources;
9098
var s = sources.get(key);
99+
var prev_res = super.get(key);
100+
var res = super.set(key, value);
91101

92102
if (s === undefined) {
93-
sources.set(key, source(value));
94-
set(this.#size, sources.size);
103+
sources.set(key, source(Symbol()));
104+
set(this.#size, super.size);
95105
this.#increment_version();
96-
} else {
97-
set(s, value);
106+
} else if (prev_res !== value) {
107+
set(s, Symbol());
98108
}
99109

100-
return this;
110+
return res;
101111
}
102112

103113
/** @param {K} key */
104114
delete(key) {
105115
var sources = this.#sources;
106116
var s = sources.get(key);
117+
var res = super.delete(key);
107118

108119
if (s !== undefined) {
109-
var removed = sources.delete(key);
110-
set(this.#size, sources.size);
111-
set(s, /** @type {V} */ (UNINITIALIZED));
120+
sources.delete(key);
121+
set(this.#size, super.size);
122+
set(s, UNINITIALIZED);
112123
this.#increment_version();
113-
return removed;
114124
}
115125

116-
return false;
126+
return res;
117127
}
118128

119129
clear() {
120130
var sources = this.#sources;
121131

122-
if (sources.size !== 0) {
132+
if (super.size !== 0) {
123133
set(this.#size, 0);
124134
for (var s of sources.values()) {
125-
set(s, /** @type {V} */ (UNINITIALIZED));
135+
set(s, UNINITIALIZED);
126136
}
127137
this.#increment_version();
138+
sources.clear();
128139
}
129-
130-
sources.clear();
140+
super.clear();
131141
}
132142

133143
keys() {
134144
get(this.#version);
135-
return this.#sources.keys();
145+
return super.keys();
136146
}
137147

138148
values() {
139149
get(this.#version);
140-
return map(this.#sources.values(), get, 'Map Iterator');
150+
return super.values();
141151
}
142152

143153
entries() {
144154
get(this.#version);
145-
return map(
146-
this.#sources.entries(),
147-
([key, source]) => /** @type {[K, V]} */ ([key, get(source)]),
148-
'Map Iterator'
149-
);
155+
return super.entries();
150156
}
151157

152158
[Symbol.iterator]() {
153159
return this.entries();
154160
}
155161

156162
get size() {
157-
return get(this.#size);
163+
get(this.#size);
164+
return super.size;
158165
}
159166
}

‎packages/svelte/src/reactivity/map.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,35 @@ test('map handling of undefined values', () => {
183183

184184
cleanup();
185185
});
186+
187+
test('not invoking reactivity when value is not in the map after changes', () => {
188+
const map = new ReactiveMap([[1, 1]]);
189+
190+
const log: any = [];
191+
192+
const cleanup = effect_root(() => {
193+
render_effect(() => {
194+
log.push(map.get(1));
195+
});
196+
197+
render_effect(() => {
198+
log.push(map.get(2));
199+
});
200+
201+
flushSync(() => {
202+
map.delete(1);
203+
});
204+
205+
flushSync(() => {
206+
map.set(1, 1);
207+
});
208+
});
209+
210+
assert.deepEqual(log, [1, undefined, undefined, undefined, 1, undefined]);
211+
212+
cleanup();
213+
});
214+
215+
test('Map.instanceOf', () => {
216+
assert.equal(new ReactiveMap() instanceof Map, true);
217+
});

‎packages/svelte/src/reactivity/set.js

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { DEV } from 'esm-env';
22
import { source, set } from '../internal/client/reactivity/sources.js';
33
import { get } from '../internal/client/runtime.js';
4-
import { map } from './utils.js';
54

65
var read_methods = ['forEach', 'isDisjointFrom', 'isSubsetOf', 'isSupersetOf'];
76
var set_like_methods = ['difference', 'intersection', 'symmetricDifference', 'union'];
@@ -28,13 +27,10 @@ export class ReactiveSet extends Set {
2827
if (DEV) new Set(value);
2928

3029
if (value) {
31-
var sources = this.#sources;
32-
3330
for (var element of value) {
34-
sources.set(element, source(true));
31+
super.add(element);
3532
}
36-
37-
this.#size.v = sources.size;
33+
this.#size.v = super.size;
3834
}
3935

4036
if (!inited) this.#init();
@@ -51,23 +47,17 @@ export class ReactiveSet extends Set {
5147
// @ts-ignore
5248
proto[method] = function (...v) {
5349
get(this.#version);
54-
// We don't populate the underlying Set, so we need to create a clone using
55-
// our internal values and then pass that to the method.
56-
var clone = new Set(this.values());
5750
// @ts-ignore
58-
return set_proto[method].apply(clone, v);
51+
return set_proto[method].apply(this, v);
5952
};
6053
}
6154

6255
for (const method of set_like_methods) {
6356
// @ts-ignore
6457
proto[method] = function (...v) {
6558
get(this.#version);
66-
// We don't populate the underlying Set, so we need to create a clone using
67-
// our internal values and then pass that to the method.
68-
var clone = new Set(this.values());
6959
// @ts-ignore
70-
var set = /** @type {Set<T>} */ (set_proto[method].apply(clone, v));
60+
var set = /** @type {Set<T>} */ (set_proto[method].apply(this, v));
7161
return new ReactiveSet(set);
7262
};
7363
}
@@ -79,73 +69,86 @@ export class ReactiveSet extends Set {
7969

8070
/** @param {T} value */
8171
has(value) {
82-
var s = this.#sources.get(value);
72+
var sources = this.#sources;
73+
var s = sources.get(value);
8374

8475
if (s === undefined) {
85-
// We should always track the version in case
86-
// the Set ever gets this value in the future.
87-
get(this.#version);
88-
89-
return false;
76+
var ret = super.has(value);
77+
if (ret) {
78+
s = source(true);
79+
sources.set(value, s);
80+
} else {
81+
// We should always track the version in case
82+
// the Set ever gets this value in the future.
83+
get(this.#version);
84+
return false;
85+
}
9086
}
9187

92-
return get(s);
88+
get(s);
89+
return super.has(value);
9390
}
9491

9592
/** @param {T} value */
9693
add(value) {
9794
var sources = this.#sources;
95+
var res = super.add(value);
96+
var s = sources.get(value);
9897

99-
if (!sources.has(value)) {
98+
if (s === undefined) {
10099
sources.set(value, source(true));
101-
set(this.#size, sources.size);
100+
set(this.#size, super.size);
102101
this.#increment_version();
102+
} else {
103+
set(s, true);
103104
}
104105

105-
return this;
106+
return res;
106107
}
107108

108109
/** @param {T} value */
109110
delete(value) {
110111
var sources = this.#sources;
111112
var s = sources.get(value);
113+
var res = super.delete(value);
112114

113115
if (s !== undefined) {
114-
var removed = sources.delete(value);
115-
set(this.#size, sources.size);
116+
sources.delete(value);
117+
set(this.#size, super.size);
116118
set(s, false);
117119
this.#increment_version();
118-
return removed;
119120
}
120121

121-
return false;
122+
return res;
122123
}
123124

124125
clear() {
125126
var sources = this.#sources;
126127

127-
if (sources.size !== 0) {
128+
if (super.size !== 0) {
128129
set(this.#size, 0);
129130
for (var s of sources.values()) {
130131
set(s, false);
131132
}
132133
this.#increment_version();
134+
sources.clear();
133135
}
134-
135-
sources.clear();
136+
super.clear();
136137
}
137138

138139
keys() {
139140
get(this.#version);
140-
return map(this.#sources.keys(), (key) => key, 'Set Iterator');
141+
return super.keys();
141142
}
142143

143144
values() {
144-
return this.keys();
145+
get(this.#version);
146+
return super.values();
145147
}
146148

147149
entries() {
148-
return map(this.keys(), (key) => /** @type {[T, T]} */ ([key, key]), 'Set Iterator');
150+
get(this.#version);
151+
return super.entries();
149152
}
150153

151154
[Symbol.iterator]() {

‎packages/svelte/src/reactivity/set.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,54 @@ test('set.forEach()', () => {
106106

107107
cleanup();
108108
});
109+
110+
test('not invoking reactivity when value is not in the set after changes', () => {
111+
const set = new ReactiveSet([1, 2]);
112+
113+
const log: any = [];
114+
115+
const cleanup = effect_root(() => {
116+
render_effect(() => {
117+
log.push('has 1', set.has(1));
118+
});
119+
120+
render_effect(() => {
121+
log.push('has 2', set.has(2));
122+
});
123+
124+
render_effect(() => {
125+
log.push('has 3', set.has(3));
126+
});
127+
});
128+
129+
flushSync(() => {
130+
set.delete(2);
131+
});
132+
133+
flushSync(() => {
134+
set.add(2);
135+
});
136+
137+
assert.deepEqual(log, [
138+
'has 1',
139+
true,
140+
'has 2',
141+
true,
142+
'has 3',
143+
false,
144+
'has 2',
145+
false,
146+
'has 3',
147+
false,
148+
'has 2',
149+
true,
150+
'has 3',
151+
false
152+
]);
153+
154+
cleanup();
155+
});
156+
157+
test('Set.instanceOf', () => {
158+
assert.equal(new ReactiveSet() instanceof Set, true);
159+
});

0 commit comments

Comments
 (0)
Please sign in to comment.