Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: improve reactive Map and Set implementations #11827

Merged
merged 4 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eight-jeans-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

fix: improve reactive Map and Set implementations
10 changes: 0 additions & 10 deletions packages/svelte/src/internal/client/dev/inspect.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,6 @@ export function inspect(get_value, inspector = console.log) {
*/
function deep_snapshot(value, visited = new Map()) {
if (typeof value === 'object' && value !== null && !visited.has(value)) {
if (DEV) {
// When dealing with ReactiveMap or ReactiveSet, return normal versions
// so that console.log provides better output versions
if (value instanceof Map && value.constructor !== Map) {
return new Map(value);
}
if (value instanceof Set && value.constructor !== Set) {
return new Set(value);
}
}
const unstated = snapshot(value);

if (unstated !== value) {
Expand Down
95 changes: 51 additions & 44 deletions packages/svelte/src/reactivity/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ import { DEV } from 'esm-env';
import { source, set } from '../internal/client/reactivity/sources.js';
import { get } from '../internal/client/runtime.js';
import { UNINITIALIZED } from '../constants.js';
import { map } from './utils.js';

/**
* @template K
* @template V
* @extends {Map<K, V>}
*/
export class ReactiveMap extends Map {
/** @type {Map<K, import('#client').Source<V>>} */
/** @type {Map<K, import('#client').Source<symbol>>} */
#sources = new Map();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could use a WeakMap here to allow for object keys?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah WeakMaps don't give us anything here really. It means we'd have to duplicate data as we need to store the value on the super and in the WeakMap, which means the WeakMap never does anything as the value is strongly held. Also WeakMaps are very slow.

#version = source(0);
#size = source(0);
Expand All @@ -25,13 +24,10 @@ export class ReactiveMap extends Map {
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;
this.#size.v = super.size;
}
}

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

/** @param {K} key */
has(key) {
var s = this.#sources.get(key);
var sources = this.#sources;
var s = 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;
var ret = super.get(key);
if (ret !== undefined) {
s = source(Symbol());
sources.set(key, s);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still has the same problem as #11504 and #11287. We need a way to remove these signals when we are no longer listening to them. Otherwise it would create a memory leak (see #11504 (comment))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Unfortunately, this means that we can't retain primitives either. So they won't be fine-grain, as there's no way to do so without leaking.

} else {
// We should always track the version in case
// the Set ever gets this value in the future.
get(this.#version);
return false;
}
}

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

var bound_callbackfn = callbackfn.bind(this_arg);
this.#sources.forEach((s, key) => bound_callbackfn(s.v, key, this));
return super.forEach(callbackfn, this_arg);
}

/** @param {K} key */
get(key) {
var s = this.#sources.get(key);
var sources = this.#sources;
var s = 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;
var ret = super.get(key);
if (ret !== undefined) {
s = source(Symbol());
sources.set(key, s);
} else {
// 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);
get(s);
return super.get(key);
}

/**
Expand All @@ -88,72 +96,71 @@ export class ReactiveMap extends Map {
set(key, value) {
var sources = this.#sources;
var s = sources.get(key);
var prev_res = super.get(key);
var res = super.set(key, value);

if (s === undefined) {
sources.set(key, source(value));
set(this.#size, sources.size);
sources.set(key, source(Symbol()));
set(this.#size, super.size);
this.#increment_version();
} else {
set(s, value);
} else if (prev_res !== value) {
set(s, Symbol());
}

return this;
return res;
}

/** @param {K} key */
delete(key) {
var sources = this.#sources;
var s = sources.get(key);
var res = super.delete(key);

if (s !== undefined) {
var removed = sources.delete(key);
set(this.#size, sources.size);
set(s, /** @type {V} */ (UNINITIALIZED));
sources.delete(key);
set(this.#size, super.size);
set(s, UNINITIALIZED);
this.#increment_version();
return removed;
}

return false;
return res;
}

clear() {
var sources = this.#sources;

if (sources.size !== 0) {
if (super.size !== 0) {
set(this.#size, 0);
for (var s of sources.values()) {
set(s, /** @type {V} */ (UNINITIALIZED));
set(s, UNINITIALIZED);
}
this.#increment_version();
sources.clear();
}

sources.clear();
super.clear();
}

keys() {
get(this.#version);
return this.#sources.keys();
return super.keys();
}

values() {
get(this.#version);
return map(this.#sources.values(), get, 'Map Iterator');
return super.values();
}

entries() {
get(this.#version);
return map(
this.#sources.entries(),
([key, source]) => /** @type {[K, V]} */ ([key, get(source)]),
'Map Iterator'
);
return super.entries();
}

[Symbol.iterator]() {
return this.entries();
}

get size() {
return get(this.#size);
get(this.#size);
return super.size;
}
}
32 changes: 32 additions & 0 deletions packages/svelte/src/reactivity/map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,35 @@ test('map handling of undefined values', () => {

cleanup();
});

test('not invoking reactivity when value is not in the map after changes', () => {
const map = new ReactiveMap([[1, 1]]);

const log: any = [];

const cleanup = effect_root(() => {
render_effect(() => {
log.push(map.get(1));
});

render_effect(() => {
log.push(map.get(2));
});

flushSync(() => {
map.delete(1);
});

flushSync(() => {
map.set(1, 1);
});
});

assert.deepEqual(log, [1, undefined, undefined, undefined, 1, undefined]);

cleanup();
});

test('Map.instanceOf', () => {
assert.equal(new ReactiveMap() instanceof Map, true);
});
Loading
Loading