Skip to content

Commit 60c2029

Browse files
committed
perf(toc): reuse IntersectionObserver
1 parent aa6d1b3 commit 60c2029

4 files changed

Lines changed: 90 additions & 63 deletions

File tree

.changeset/seven-badgers-act.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@svelte-put/toc': patch
3+
---
4+
5+
use as few IntersectionObserver as possible, only create for each new threshold, otherwise reuse

packages/actions/toc/api/toc.api.md

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,6 @@
77
import type { Action } from 'svelte/action';
88
import { Writable } from 'svelte/store';
99

10-
// Warning: (ae-internal-missing-underscore) The name "ATTRIBUTES" should be prefixed with an underscore because the declaration is marked as @internal
11-
//
12-
// @internal
13-
export const ATTRIBUTES: {
14-
autoslug: string;
15-
autoSlugAnchor: string;
16-
toc: string;
17-
anchor: string;
18-
id: string;
19-
ignore: string;
20-
strategy: string;
21-
threshold: string;
22-
};
23-
2410
// @public
2511
export function createTocStore(): Writable<TocStoreValue>;
2612

@@ -47,14 +33,6 @@ export const DEFAULT_TOC_PARAMETERS: {
4733
};
4834
};
4935

50-
// Warning: (ae-internal-missing-underscore) The name "EVENTS" should be prefixed with an underscore because the declaration is marked as @internal
51-
//
52-
// @internal
53-
export const EVENTS: {
54-
init: string;
55-
change: string;
56-
};
57-
5836
// Warning: (ae-internal-missing-underscore) The name "ResolvedTocParameters" should be prefixed with an underscore because the declaration is marked as @internal
5937
//
6038
// @internal

packages/actions/toc/src/lib/toc.action.ts

Lines changed: 28 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { tick } from 'svelte';
33
import type { Action, ActionReturn } from 'svelte/action';
44
import { writable } from 'svelte/store';
55

6+
import { ATTRIBUTES, EVENTS, cache, intersectionObserverCallback } from './toc.internal';
7+
import type { ActiveTocItemStore } from './toc.internal';
68
import { compare, resolve } from './toc.parameters';
79
import type {
810
ResolvedTocParameters,
@@ -15,32 +17,6 @@ import type {
1517
} from './toc.types';
1618
import { slugify } from './toc.utils';
1719

18-
/**
19-
* all relevant data attribute name literals
20-
* @internal
21-
*/
22-
export const ATTRIBUTES = {
23-
autoslug: 'data-auto-slug',
24-
autoSlugAnchor: 'data-auto-slug-anchor',
25-
toc: 'data-toc',
26-
anchor: 'data-toc-anchor',
27-
id: 'data-toc-id',
28-
ignore: 'data-toc-ignore',
29-
strategy: 'data-toc-strategy',
30-
threshold: 'data-toc-threshold',
31-
};
32-
33-
/**
34-
* all relevant event name literals
35-
* @internal
36-
*/
37-
export const EVENTS = {
38-
init: 'tocinit',
39-
change: 'tocchange',
40-
};
41-
42-
const cache: Record<string, TocCacheItem> = {};
43-
4420
/**
4521
* search for matching elements, inject anchor element, watch for active element
4622
* in viewport with `IntersectionObserver`. All for building table of contents.
@@ -121,7 +97,11 @@ export const toc: Action<HTMLElement, UserTocParameters, TocAttributes> = functi
12197
let resolved = resolve(parameters);
12298

12399
let items: TocCacheItem['items'] = {};
124-
const activeTocItemStore = writable<string | undefined>(undefined);
100+
const activeTocItemStore = writable<string | undefined>(undefined) satisfies ActiveTocItemStore;
101+
102+
// stay minimal by reusing as few `IntersectionObserver` as possible
103+
// only create new `IntersectionObserver` for each new `threshold`
104+
const observers: Record<number, IntersectionObserver> = {};
125105

126106
const activeTocItemStoreUnsubscribe = activeTocItemStore.subscribe((activeTocItemId) => {
127107
if (activeTocItemId) {
@@ -134,15 +114,6 @@ export const toc: Action<HTMLElement, UserTocParameters, TocAttributes> = functi
134114
}
135115
});
136116

137-
const createObserverCallback: (tocId: string) => IntersectionObserverCallback =
138-
(tocId) => (entries) => {
139-
for (const entry of entries) {
140-
if (entry.isIntersecting && tocId) {
141-
activeTocItemStore.set(tocId);
142-
}
143-
}
144-
};
145-
146117
tick().then(async () => {
147118
const { id, selector, anchor, observe, scrollMarginTop } = resolved;
148119
const elements: HTMLElement[] = Array.from(node.querySelectorAll(selector));
@@ -252,11 +223,18 @@ export const toc: Action<HTMLElement, UserTocParameters, TocAttributes> = functi
252223
: observe.threshold;
253224
}
254225
const { root, rootMargin } = observe;
255-
const observer = new IntersectionObserver(createObserverCallback(tocId), {
256-
threshold,
257-
rootMargin,
258-
root,
259-
});
226+
nodeToObserve.setAttribute(ATTRIBUTES.observeFor, tocId);
227+
let observer: IntersectionObserver;
228+
if (observers[threshold]) {
229+
observer = observers[threshold];
230+
} else {
231+
observer = new IntersectionObserver(intersectionObserverCallback(activeTocItemStore), {
232+
threshold,
233+
rootMargin,
234+
root,
235+
});
236+
observers[threshold] = observer;
237+
}
260238
observer.observe(nodeToObserve);
261239
rObserve = { strategy: rStrategy, observer, threshold };
262240
}
@@ -285,9 +263,18 @@ export const toc: Action<HTMLElement, UserTocParameters, TocAttributes> = functi
285263
return {
286264
update(update) {
287265
resolved = resolve(update);
266+
// right now `toc` does not support dynamic parameter updates
267+
// meaning it'll only run once on component initialization
268+
// and not on subsequent updates
269+
// this is because the effort to support dynamic updates is rather high:
270+
// - revert all previous operation (or detect which ones are still valid/invalid)
271+
// - re-run operations
288272
},
289273
destroy() {
290274
activeTocItemStoreUnsubscribe();
275+
for (const observer of Object.values(observers)) {
276+
observer.disconnect();
277+
}
291278
},
292279
};
293280
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { Writable } from 'svelte/store';
2+
3+
import type { TocCacheItem } from './toc.types';
4+
/**
5+
* all relevant data attribute name literals
6+
* @internal
7+
*/
8+
export const ATTRIBUTES = {
9+
// markers from `@svelte-put/preprocess-auo-slug`
10+
autoslug: 'data-auto-slug',
11+
autoSlugAnchor: 'data-auto-slug-anchor',
12+
// markers
13+
toc: 'data-toc',
14+
anchor: 'data-toc-anchor',
15+
// customization per matching element
16+
id: 'data-toc-id',
17+
ignore: 'data-toc-ignore',
18+
strategy: 'data-toc-strategy',
19+
threshold: 'data-toc-threshold',
20+
// tracking information for `IntersectionObserver`
21+
observeFor: 'data-toc-observe-for',
22+
};
23+
24+
/**
25+
* all relevant event name literals
26+
* @internal
27+
*/
28+
export const EVENTS = {
29+
init: 'tocinit',
30+
change: 'tocchange',
31+
};
32+
33+
/**
34+
* @internal
35+
*/
36+
export const cache: Record<string, TocCacheItem> = {};
37+
38+
/**
39+
* @internal
40+
*/
41+
export type ActiveTocItemStore = Writable<string | undefined>;
42+
43+
/**
44+
* @internal
45+
*/
46+
export const intersectionObserverCallback: (
47+
store: ActiveTocItemStore,
48+
) => IntersectionObserverCallback = (store) => {
49+
return (entries) => {
50+
for (const entry of entries) {
51+
const tocId = entry.target.getAttribute(ATTRIBUTES.observeFor);
52+
if (tocId && entry.isIntersecting) {
53+
store.set(tocId);
54+
}
55+
}
56+
};
57+
};

0 commit comments

Comments
 (0)