Repository navigation
Expand file tree
/
Copy pathstore.js
More file actions
267 lines (235 loc) · 10.9 KB
/
Copy pathstore.js
File metadata and controls
267 lines (235 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
//supports "import", global "<script>" and pasting in your JS.
(function (root, factory) {
const api = factory();
// Support global "StoreLib" for paste or use via <script>
const ns = root.StoreLib || (root.StoreLib = {});
for (const k in api) ns[k] = api[k];
})(typeof globalThis !== 'undefined' ? globalThis
: typeof window !== 'undefined' ? window
: typeof global !== 'undefined' ? global
: this, function () {
/* Reactive store
* - get() snapshot
* - set(value) replace
* - patch(value) shallow merge
* - subscribe(callback, selector) -> unsubscribe
*
* Mutations batch notifications on next paint via requestAnimationFrame
* while the page is visible; hidden pages batch on a microtask instead
* (browsers pause rAF there), and a pass armed before hiding is flushed
* as the page hides — so consumers that outlive the page's visibility
* (Document PiP windows, popups) keep updating. queueMicrotask or
* setTimeout are the fallbacks for non-visual environments, and
* options.scheduler injects custom timing.
*
* Subscribers receive updates only when their selected value actually changes,
* using shallow comparison for plain objects and arrays, and Object.is for primitives.
*
* For safety, any failure in a callback or selector automatically unsubscribes that subscriber.
*/
// ---- shared notification scheduling (module scope, all stores) ----
// Flushes armed on requestAnimationFrame while the page was visible.
// ONE shared visibilitychange listener (installed lazily, below) runs
// them if the page hides before their frame fires — rAF is paused for
// hidden documents, so an armed frame would otherwise hang until
// refocus. A store is referenced here only while such a pass is
// pending (flush deregisters itself), so idle stores stay
// garbage-collectable and N stores share a single listener.
var pendingHiddenFlushes = new Set();
var visibilityHooked = false;
function hookVisibility() {
if (visibilityHooked) return;
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return;
visibilityHooked = true;
document.addEventListener('visibilitychange', function () {
if (document.visibilityState !== 'hidden') return;
const pending = Array.from(pendingHiddenFlushes); // copy: flushes deregister themselves
for (let i = 0; i < pending.length; i++) pending[i]();
});
}
/* choose scheduler per pending pass, by environment AND page visibility:
* - requestAnimationFrame: best for browser UI (frame batching) — only
* while the document is visible; no document, or no visibilityState
* (ancient browser), counts as visible, preserving the pre-visibility
* behavior in environments the rAF pause never affected.
* - queueMicrotask: hidden pages, headless or server tests
* - setTimeout: generic fallback
*/
function defaultSchedule(flush) {
const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden';
if (!hidden && typeof requestAnimationFrame === 'function') {
hookVisibility();
pendingHiddenFlushes.add(flush);
requestAnimationFrame(flush);
return;
}
if (typeof queueMicrotask === 'function') {
queueMicrotask(flush);
return;
}
setTimeout(flush, 0);
}
/**
* Create a reactive store around the provided initial state.
* @template T
* @param {T} initialState Initial value for the store. Mutations replace or
* merge against this value; consumers should treat it as immutable.
* @param {Object} [options] Optional store configuration.
* @param {(flush: () => void) => void} [options.scheduler] Custom
* notification scheduler. Called with a `flush` callback whenever a
* notification pass is pending; invoke `flush` to deliver it. `flush` is
* idempotent per pending pass (a second call before the next mutation is
* a no-op). When a scheduler is provided the store's built-in
* visibility handling is bypassed — the caller owns timing entirely.
* @returns {{get: () => T, set: (newState: T) => void, patch: (partial: Partial<T>) => void, subscribe: (callback: (value: any, isInit: boolean) => void, selector?: (state: T) => any) => () => void}}
* An object exposing snapshot access, mutation helpers, and reactive subscription.
* @throws {TypeError} When options.scheduler is provided but is not a function.
*/
function createStore(initialState, options) {
var state = initialState;
var subscribers = new Set(); // { callback, selector, prev, active }
var scheduled = false;
var customScheduler = null;
if (options && options.scheduler !== undefined) {
if (typeof options.scheduler !== 'function') {
throw new TypeError('options.scheduler must be a function');
}
customScheduler = options.scheduler;
}
/**
* Obtain the current snapshot of the store.
* @returns {T} The latest state value. Callers must not mutate the
* returned object directly; use the provided mutation helpers instead.
*/
function get() { return state; }
/**
* Replace the entire state and queue notifications to subscribers.
* @param {T} newState The new value to use for the store.
* @returns {void}
*/
function set(newState) {
state = newState;
scheduleNotify();
}
/**
* Perform a shallow merge with the current state and queue notifications to subscribers.
* @param {Partial<T>} partial Object containing properties to merge into a shallow clone of the current state.
* @returns {void}
*/
function patch(partial) {
set(Object.assign({}, state, partial));
}
/**
* Subscribe to state changes with an optional selector function.
* @param {(value: any, isInit: boolean) => void} callback Invoked with
* the selected value. Receives `isInit = true` for the synchronous
* initial call, then `false` for subsequent updates.
* @param {(state: T) => any} [selector] A selector that derives the
* relevant portion of the state. Defaults to the identity function.
* The callback only fires when the selected value changes (shallow
* comparison for arrays/objects, `Object.is` otherwise).
* @returns {() => void} An unsubscribe function. Once invoked the
* subscription is removed; callbacks throwing an error also cause
* automatic unsubscription.
*/
function subscribe(callback, selector) {
if (!selector) selector = (x) => x;
let prev;
try {
prev = selector(state);
} catch (err) {
console.error(err);
throw err;
}
const sub = { callback: callback, selector: selector, prev: prev, active: true };
subscribers.add(sub);
try {
callback(prev, true); // firstTime = true
} catch (err) {
console.error(err);
unsubscribe(sub);
throw err;
}
return () => { if (sub.active) unsubscribe(sub); };
}
// ---- internal helpers ----
// explicitly remove subscriber
function unsubscribe(sub) {
sub.active = false;
subscribers.delete(sub);
}
function scheduleNotify() {
if (scheduled) return;
scheduled = true;
(customScheduler || defaultSchedule)(flush);
}
/* Deliver the pending notification pass exactly once.
* Guarded on `scheduled` so competing delivery paths are safe: a stale
* rAF callback firing after the hide-flush already ran (or a second
* call from a custom scheduler) finds `scheduled === false` and no-ops.
* The deregister is a no-op unless this pass was rAF-armed.
*/
function flush() {
pendingHiddenFlushes.delete(flush);
if (!scheduled) return;
runNotifications();
}
// run notifications to all subscribers whose selected value changed
function runNotifications() {
scheduled = false;
const snapshot = state;
const arr = Array.from(subscribers); // snapshot avoids reentrancy
for (let i = 0; i < arr.length; i++) {
const sub = arr[i];
if (!sub.active) continue;
let next;
try {
next = sub.selector(snapshot);
} catch (err) {
console.error(err);
unsubscribe(sub);
continue;
}
if (valuesEqual(next, sub.prev)) continue;
sub.prev = next;
try {
sub.callback(next, false); // not first time
} catch (err) {
console.error(err);
unsubscribe(sub);
}
}
}
function valuesEqual(a, b) {
if (Object.is(a, b)) return true;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!Object.is(a[i], b[i])) return false;
}
return true;
}
if (isPlainObject(a) && isPlainObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (let i = 0; i < keysA.length; i++) {
const k = keysA[i];
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!Object.is(a[k], b[k])) return false;
}
return true;
}
// everything else (functions, dates, maps, etc.) compares by reference
return false;
}
function isPlainObject(x) {
if (x === null || typeof x !== 'object') return false;
const proto = Object.getPrototypeOf(x);
return proto === Object.prototype || proto === null;
}
return { get: get, set: set, patch: patch, subscribe: subscribe };
}
// expose factory
return { createStore: createStore };
});