-
-
Notifications
You must be signed in to change notification settings - Fork 104
/
Copy pathindex.ts
408 lines (370 loc) · 12.2 KB
/
index.ts
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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
import {
signal,
computed,
effect,
Signal,
ReadonlySignal,
} from "@preact/signals-core";
import {
useRef,
useMemo,
useEffect,
useLayoutEffect,
version as reactVersion,
} from "react";
import { useSyncExternalStore } from "use-sync-external-store/shim/index.js";
import { isAutoSignalTrackingInstalled } from "./auto";
export { installAutoSignalTracking } from "./auto";
const [major] = reactVersion.split(".").map(Number);
const Empty = [] as const;
// V19 https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
// V18 https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
const ReactElemType = Symbol.for(
major >= 19 ? "react.transitional.element" : "react.element"
);
const noop = () => {};
export function wrapJsx<T>(jsx: T): T {
if (typeof jsx !== "function") return jsx;
return function (type: any, props: any, ...rest: any[]) {
if (typeof type === "string" && props) {
for (let i in props) {
let v = props[i];
if (i !== "children" && v instanceof Signal) {
props[i] = v.value;
}
}
}
return jsx.call(jsx, type, props, ...rest);
} as any as T;
}
const symDispose: unique symbol =
(Symbol as any).dispose || Symbol.for("Symbol.dispose");
interface Effect {
_sources: object | undefined;
_start(): () => void;
_callback(): void;
_dispose(): void;
}
/**
* Use this flag to represent a bare `useSignals` call that doesn't manually
* close its effect store and relies on auto-closing when the next useSignals is
* called or after a microtask
*/
const UNMANAGED = 0;
/**
* Use this flag to represent a `useSignals` call that is manually closed by a
* try/finally block in a component's render method. This is the default usage
* that the react-transform plugin uses.
*/
const MANAGED_COMPONENT = 1;
/**
* Use this flag to represent a `useSignals` call that is manually closed by a
* try/finally block in a hook body. This is the default usage that the
* react-transform plugin uses.
*/
const MANAGED_HOOK = 2;
/**
* An enum defining how this store is used. See the documentation for each enum
* member for more details.
* @see {@link UNMANAGED}
* @see {@link MANAGED_COMPONENT}
* @see {@link MANAGED_HOOK}
*/
type EffectStoreUsage =
| typeof UNMANAGED
| typeof MANAGED_COMPONENT
| typeof MANAGED_HOOK;
export interface EffectStore {
/**
* An enum defining how this hook is used and whether it is invoked in a
* component's body or hook body. See the comment on `EffectStoreUsage` for
* more details.
*/
readonly _usage: EffectStoreUsage;
readonly effect: Effect;
subscribe(onStoreChange: () => void): () => void;
getSnapshot(): number;
/** startEffect - begin tracking signals used in this component */
_start(): void;
/** finishEffect - stop tracking the signals used in this component */
f(): void;
[symDispose](): void;
}
let currentStore: EffectStore | undefined;
function startComponentEffect(
prevStore: EffectStore | undefined,
nextStore: EffectStore
) {
const endEffect = nextStore.effect._start();
currentStore = nextStore;
return finishComponentEffect.bind(nextStore, prevStore, endEffect);
}
function finishComponentEffect(
this: EffectStore,
prevStore: EffectStore | undefined,
endEffect: () => void
) {
endEffect();
currentStore = prevStore;
}
/**
* A redux-like store whose store value is a positive 32bit integer (a
* 'version').
*
* React subscribes to this store and gets a snapshot of the current 'version',
* whenever the 'version' changes, we tell React it's time to update the
* component (call 'onStoreChange').
*
* How we achieve this is by creating a binding with an 'effect', when the
* `effect._callback' is called, we update our store version and tell React to
* re-render the component ([1] We don't really care when/how React does it).
*
* [1]
* @see https://react.dev/reference/react/useSyncExternalStore
* @see
* https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
*
* @param _usage An enum defining how this hook is used and whether it is
* invoked in a component's body or hook body. See the comment on
* `EffectStoreUsage` for more details.
*/
function createEffectStore(_usage: EffectStoreUsage): EffectStore {
let effectInstance!: Effect;
let endEffect: (() => void) | undefined;
let version = 0;
let onChangeNotifyReact: (() => void) | undefined;
let unsubscribe = effect(function (this: Effect) {
effectInstance = this;
});
effectInstance._callback = function () {
version = (version + 1) | 0;
if (onChangeNotifyReact) onChangeNotifyReact();
};
return {
_usage,
effect: effectInstance,
subscribe(onStoreChange) {
onChangeNotifyReact = onStoreChange;
return function () {
/**
* Rotate to next version when unsubscribing to ensure that components are re-run
* when subscribing again.
*
* In StrictMode, 'memo'-ed components seem to keep a stale snapshot version, so
* don't re-run after subscribing again if the version is the same as last time.
*
* Because we unsubscribe from the effect, the version may not change. We simply
* set a new initial version in case of stale snapshots here.
*/
version = (version + 1) | 0;
onChangeNotifyReact = undefined;
unsubscribe();
};
},
getSnapshot() {
return version;
},
_start() {
// In general, we want to support two kinds of usages of useSignals:
//
// A) Managed: calling useSignals in a component or hook body wrapped in a
// try/finally (like what the react-transform plugin does)
//
// B) Unmanaged: Calling useSignals directly without wrapping in a
// try/finally
//
// For managed, we finish the effect in the finally block of the component
// or hook body. For unmanaged, we finish the effect in the next
// useSignals call or after a microtask.
//
// There are different tradeoffs which each approach. With managed, using
// a try/finally ensures that only signals used in the component or hook
// body are tracked. However, signals accessed in render props are missed
// because the render prop is invoked in another component that may or may
// not realize it is rendering signals accessed in the render prop it is
// given.
//
// The other approach is "unmanaged": to call useSignals directly without
// wrapping in a try/finally. This approach is easier to manually write in
// situations where a build step isn't available but does open up the
// possibility of catching signals accessed in other code before the
// effect is closed (e.g. in a layout effect). Most situations where this
// could happen are generally consider bad patterns or bugs. For example,
// using a signal in a component and not having a call to `useSignals`
// would be an bug. Or using a signal in `useLayoutEffect` is generally
// not recommended since that layout effect won't update when the signals'
// value change.
//
// To support both approaches, we need to track how each invocation of
// useSignals is used, so we can properly transition between different
// kinds of usages.
//
// The following table shows the different scenarios and how we should
// handle them.
//
// Key:
// 0 = UNMANAGED
// 1 = MANAGED_COMPONENT
// 2 = MANAGED_HOOK
//
// Pattern:
// prev store usage -> this store usage: action to take
//
// - 0 -> 0: finish previous effect (unknown to unknown)
//
// We don't know how the previous effect was used, so we need to finish
// it before starting the next effect.
//
// - 0 -> 1: finish previous effect
//
// Assume previous invocation was another component or hook from another
// component. Nested component renders (renderToStaticMarkup within a
// component's render) won't be supported with bare useSignals calls.
//
// - 0 -> 2: capture & restore
//
// Previous invocation could be a component or a hook. Either way,
// restore it after our invocation so that it can continue to capture
// any signals after we exit.
//
// - 1 -> 0: Do nothing. Signals already captured by current effect store
// - 1 -> 1: capture & restore (e.g. component calls renderToStaticMarkup)
// - 1 -> 2: capture & restore (e.g. hook)
//
// - 2 -> 0: Do nothing. Signals already captured by current effect store
// - 2 -> 1: capture & restore (e.g. hook calls renderToStaticMarkup)
// - 2 -> 2: capture & restore (e.g. nested hook calls)
if (currentStore == undefined) {
endEffect = startComponentEffect(undefined, this);
return;
}
const prevUsage = currentStore._usage;
const thisUsage = this._usage;
if (
(prevUsage == UNMANAGED && thisUsage == UNMANAGED) || // 0 -> 0
(prevUsage == UNMANAGED && thisUsage == MANAGED_COMPONENT) // 0 -> 1
) {
// finish previous effect
currentStore.f();
endEffect = startComponentEffect(undefined, this);
} else if (
(prevUsage == MANAGED_COMPONENT && thisUsage == UNMANAGED) || // 1 -> 0
(prevUsage == MANAGED_HOOK && thisUsage == UNMANAGED) // 2 -> 0
) {
// Do nothing since it'll be captured by current effect store
} else {
// nested scenarios, so capture and restore the previous effect store
endEffect = startComponentEffect(currentStore, this);
}
},
f() {
const end = endEffect;
endEffect = undefined;
end?.();
},
[symDispose]() {
this.f();
},
};
}
function createEmptyEffectStore(): EffectStore {
return {
_usage: UNMANAGED,
effect: {
_sources: undefined,
_callback() {},
_start() {
return noop;
},
_dispose() {},
},
subscribe() {
return noop;
},
getSnapshot() {
return 0;
},
_start() {},
f() {},
[symDispose]() {},
};
}
const emptyEffectStore = createEmptyEffectStore();
const _queueMicroTask = Promise.prototype.then.bind(Promise.resolve());
let finalCleanup: Promise<void> | undefined;
export function ensureFinalCleanup() {
if (!finalCleanup) {
finalCleanup = _queueMicroTask(cleanupTrailingStore);
}
}
function cleanupTrailingStore() {
finalCleanup = undefined;
currentStore?.f();
}
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
/**
* Custom hook to create the effect to track signals used during render and
* subscribe to changes to rerender the component when the signals change.
*/
export function _useSignalsImplementation(
_usage: EffectStoreUsage = UNMANAGED
): EffectStore {
ensureFinalCleanup();
const storeRef = useRef<EffectStore>();
if (storeRef.current == null) {
storeRef.current = createEffectStore(_usage);
}
const store = storeRef.current;
useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
store._start();
// note: _usage is a constant here, so conditional is okay
if (_usage === UNMANAGED) useIsomorphicLayoutEffect(cleanupTrailingStore);
return store;
}
/**
* A wrapper component that renders a Signal's value directly as a Text node or JSX.
*/
function SignalValue({ data }: { data: Signal }) {
const store = _useSignalsImplementation(1);
try {
return data.value;
} finally {
store.f();
}
}
// Decorate Signals so React renders them as <SignalValue> components.
Object.defineProperties(Signal.prototype, {
$$typeof: { configurable: true, value: ReactElemType },
type: { configurable: true, value: SignalValue },
props: {
configurable: true,
get() {
return { data: this };
},
},
ref: { configurable: true, value: null },
});
export function useSignals(usage?: EffectStoreUsage): EffectStore {
if (isAutoSignalTrackingInstalled) return emptyEffectStore;
return _useSignalsImplementation(usage);
}
export function useSignal<T>(value: T): Signal<T>;
export function useSignal<T = undefined>(): Signal<T | undefined>;
export function useSignal<T>(value?: T) {
return useMemo(() => signal<T | undefined>(value), Empty);
}
export function useComputed<T>(compute: () => T): ReadonlySignal<T> {
const $compute = useRef(compute);
$compute.current = compute;
return useMemo(() => computed<T>(() => $compute.current()), Empty);
}
export function useSignalEffect(cb: () => void | (() => void)) {
const callback = useRef(cb);
callback.current = cb;
useEffect(() => {
return effect(function (this: Effect) {
return callback.current();
});
}, Empty);
}