@blender-ui/react 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -171
- package/hooks.js +397 -397
- package/index.d.ts +110 -110
- package/index.js +370 -370
- package/jsx-dev-runtime.d.ts +2 -2
- package/jsx-dev-runtime.js +9 -9
- package/jsx-runtime.d.ts +26 -26
- package/jsx-runtime.js +11 -11
- package/package.json +1 -1
package/hooks.js
CHANGED
|
@@ -1,397 +1,397 @@
|
|
|
1
|
-
// React's hooks, by React's names, with signals for state.
|
|
2
|
-
//
|
|
3
|
-
// `useState`, `useReducer`, `useMemo` ... take React's arguments, run at
|
|
4
|
-
// React's moments and return React's shapes, but the state they hold is a
|
|
5
|
-
// signal. Pass it as a prop or child (`<span>{count}</span>`) and a change
|
|
6
|
-
// updates that one node: the component doesn't re-render. Read it while
|
|
7
|
-
// rendering (`count.value`, `count + 1`, a condition) and the component
|
|
8
|
-
// re-renders when it changes, exactly as with React's own hooks.
|
|
9
|
-
//
|
|
10
|
-
// The second part needs to know which component is rendering: components
|
|
11
|
-
// created through this package's JSX runtime (`jsxImportSource:
|
|
12
|
-
// "@blender-ui/react"`) or `createElement` are wrapped with `track()`.
|
|
13
|
-
//
|
|
14
|
-
// Built only on React's public hooks (useState, useReducer, useRef, useMemo,
|
|
15
|
-
// the effect hooks, startTransition), so React's semantics (StrictMode,
|
|
16
|
-
// concurrent rendering) come from React itself. test/hooks.test.js runs each
|
|
17
|
-
// hook beside React's original and fails when React adds a hook that has
|
|
18
|
-
// neither a signal version here nor an entry in NO_TWIN.
|
|
19
|
-
//
|
|
20
|
-
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
21
|
-
'use strict'
|
|
22
|
-
|
|
23
|
-
const React = require('react')
|
|
24
|
-
const S = require('@blender-ui/native')
|
|
25
|
-
const { signal, memo, effect, batch, untrack, isSignal, Tracker, Ref } = S
|
|
26
|
-
|
|
27
|
-
/** React hooks re-exported unchanged, and why (the coverage test reads this). */
|
|
28
|
-
const NO_TWIN = {
|
|
29
|
-
use: 'suspends rendering: inherently a render-time read',
|
|
30
|
-
useContext: 'pass a signal as the context value: consumers bind to it without re-rendering',
|
|
31
|
-
useDebugValue: 'no state',
|
|
32
|
-
useId: 'no state',
|
|
33
|
-
useImperativeHandle: 'no state (works with useRef)',
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// The latest value, for callbacks that outlive the render that made them.
|
|
37
|
-
function useLatest (value) {
|
|
38
|
-
const ref = React.useRef(value)
|
|
39
|
-
ref.current = value
|
|
40
|
-
return ref
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** A component-owned value, created once. */
|
|
44
|
-
function useOnce (create) {
|
|
45
|
-
return React.useState(create)[0]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ---- render tracking -----------------------------------------------------------
|
|
49
|
-
|
|
50
|
-
const bump = (n) => n + 1
|
|
51
|
-
|
|
52
|
-
/** Re-render this component when a signal its render read changes. */
|
|
53
|
-
function useRenderTracker () {
|
|
54
|
-
const [, force] = React.useReducer(bump, 0)
|
|
55
|
-
const tracker = useOnce(() => new Tracker(() => force()))
|
|
56
|
-
const reads = React.useRef(null)
|
|
57
|
-
// after each commit: follow what that render read
|
|
58
|
-
React.useLayoutEffect(() => { tracker.commit(reads.current) })
|
|
59
|
-
// mount / unmount (StrictMode re-mounts: pause, then resume)
|
|
60
|
-
React.useLayoutEffect(() => {
|
|
61
|
-
tracker.resume()
|
|
62
|
-
return () => tracker.pause()
|
|
63
|
-
}, [tracker])
|
|
64
|
-
return { tracker, reads }
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const tracked = new WeakMap()
|
|
68
|
-
const MEMO = Symbol.for('react.memo')
|
|
69
|
-
const FORWARD_REF = Symbol.for('react.forward_ref')
|
|
70
|
-
|
|
71
|
-
/** `type` with render tracking: function components, `memo` and `forwardRef`. */
|
|
72
|
-
function track (type) {
|
|
73
|
-
if (type == null) return type
|
|
74
|
-
if (typeof type === 'function') {
|
|
75
|
-
if (type.prototype?.isReactComponent) return type // class components: no hooks
|
|
76
|
-
} else if (typeof type !== 'object' || (type.$$typeof !== MEMO && type.$$typeof !== FORWARD_REF)) {
|
|
77
|
-
return type // host components, fragments, providers, lazy ...
|
|
78
|
-
}
|
|
79
|
-
let t = tracked.get(type)
|
|
80
|
-
if (t !== undefined) return t
|
|
81
|
-
if (typeof type === 'function') {
|
|
82
|
-
t = function (props, secondArg) {
|
|
83
|
-
const { tracker, reads } = useRenderTracker()
|
|
84
|
-
const [result, r] = tracker.run(() => type(props, secondArg))
|
|
85
|
-
reads.current = r
|
|
86
|
-
return result
|
|
87
|
-
}
|
|
88
|
-
Object.defineProperty(t, 'name', { value: type.name })
|
|
89
|
-
t.displayName = type.displayName || type.name
|
|
90
|
-
} else if (type.$$typeof === MEMO) {
|
|
91
|
-
t = React.memo(track(type.type), type.compare)
|
|
92
|
-
t.displayName = type.displayName
|
|
93
|
-
} else {
|
|
94
|
-
t = React.forwardRef(track(type.render))
|
|
95
|
-
t.displayName = type.displayName
|
|
96
|
-
}
|
|
97
|
-
tracked.set(type, t)
|
|
98
|
-
tracked.set(t, t)
|
|
99
|
-
return t
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** React's `createElement`, with render tracking for components. */
|
|
103
|
-
function createElement (type, props, ...children) {
|
|
104
|
-
return React.createElement(track(type), props, ...children)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ---- state -----------------------------------------------------------------
|
|
108
|
-
|
|
109
|
-
/** Rust's `use_signal`: a signal owned by this component. */
|
|
110
|
-
function useSignal (initial) {
|
|
111
|
-
return useOnce(() => signal(typeof initial === 'function' ? initial() : initial))
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** `[signal, setState]`; `setState(v | prev => next)`; the same value: no update. */
|
|
115
|
-
function useState (initialState) {
|
|
116
|
-
const s = useSignal(initialState)
|
|
117
|
-
const set = React.useCallback((v) => s.set(typeof v === 'function' ? v(s.peek()) : v), [s])
|
|
118
|
-
return [s, set]
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** `[signal, dispatch]`, with the latest render's reducer (as React does). */
|
|
122
|
-
function useReducer (reducer, initialArg, init) {
|
|
123
|
-
const s = useOnce(() => signal(init !== undefined ? init(initialArg) : initialArg))
|
|
124
|
-
const latest = useLatest(reducer)
|
|
125
|
-
const dispatch = React.useCallback((action) => s.set(latest.current(s.peek(), action)), [s])
|
|
126
|
-
return [s, dispatch]
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** `{ current }`, observable by effects and memos (an element mounting re-runs
|
|
130
|
-
* them); reading it while rendering doesn't subscribe, as with React's refs. */
|
|
131
|
-
function useRef (initialValue) {
|
|
132
|
-
return useOnce(() => new Ref(initialValue))
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ---- derived values ----------------------------------------------------------
|
|
136
|
-
|
|
137
|
-
/** A memo: recomputed when the signals it reads change, and replaced (like
|
|
138
|
-
* useMemo's value) when `deps` change; without deps, every render. */
|
|
139
|
-
function useMemo (fn, deps) {
|
|
140
|
-
const latest = useLatest(fn)
|
|
141
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
142
|
-
return React.useMemo(() => memo(() => latest.current()), deps)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Rust's `Memo::new`: `useMemo` with `deps = []` by default. */
|
|
146
|
-
function useComputed (fn, deps = []) {
|
|
147
|
-
return useMemo(fn, deps)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** React's `useCallback`; calls are batched (their writes make one update) and
|
|
151
|
-
* untracked (calling it in an effect doesn't subscribe to what it reads). */
|
|
152
|
-
function useCallback (fn, deps) {
|
|
153
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
154
|
-
return React.useCallback((...args) => batch(() => untrack(() => fn(...args))), deps)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** React's `useEffectEvent`: a stable function that calls the latest render's
|
|
158
|
-
* `fn`; calls are batched and untracked (an effect calling it doesn't
|
|
159
|
-
* subscribe to the signals `fn` reads). */
|
|
160
|
-
function useEffectEvent (fn) {
|
|
161
|
-
const latest = useLatest(fn)
|
|
162
|
-
return React.useCallback((...args) => batch(() => untrack(() => latest.current(...args))), [latest])
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ---- effects -----------------------------------------------------------------
|
|
166
|
-
|
|
167
|
-
function effectHook (reactHook) {
|
|
168
|
-
return function (fn, deps) {
|
|
169
|
-
const latest = useLatest(fn)
|
|
170
|
-
// React's timing and deps; in between, re-runs when the signals it read change
|
|
171
|
-
reactHook(() => effect(() => latest.current()), deps)
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** React's `useEffect`, also re-run when the signals it read change. */
|
|
176
|
-
const useEffect = effectHook(React.useEffect)
|
|
177
|
-
/** React's `useLayoutEffect`, also re-run when the signals it read change. */
|
|
178
|
-
const useLayoutEffect = effectHook(React.useLayoutEffect)
|
|
179
|
-
/** React's `useInsertionEffect`, also re-run when the signals it read change. */
|
|
180
|
-
const useInsertionEffect = effectHook(React.useInsertionEffect)
|
|
181
|
-
|
|
182
|
-
// ---- external stores ---------------------------------------------------------
|
|
183
|
-
|
|
184
|
-
/** A signal following the store. */
|
|
185
|
-
function useSyncExternalStore (subscribe, getSnapshot) {
|
|
186
|
-
const s = useOnce(() => signal(getSnapshot()))
|
|
187
|
-
const latest = useLatest(getSnapshot)
|
|
188
|
-
React.useLayoutEffect(() => {
|
|
189
|
-
const check = () => s.set(latest.current())
|
|
190
|
-
check() // changed between render and subscribe
|
|
191
|
-
return subscribe(check)
|
|
192
|
-
}, [subscribe, s])
|
|
193
|
-
React.useLayoutEffect(() => { s.set(latest.current()) }) // a new getSnapshot
|
|
194
|
-
return s
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Re-render this component when `source` changes; returns its value (for
|
|
198
|
-
* components rendered without this package's JSX runtime). */
|
|
199
|
-
function useSignalValue (source) {
|
|
200
|
-
const subscribe = React.useCallback((onChange) => {
|
|
201
|
-
let first = true
|
|
202
|
-
return effect(() => {
|
|
203
|
-
source.value // subscribe
|
|
204
|
-
if (first) first = false
|
|
205
|
-
else onChange()
|
|
206
|
-
})
|
|
207
|
-
}, [source])
|
|
208
|
-
return React.useSyncExternalStore(subscribe, () => source.peek())
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// ---- transitions and actions -------------------------------------------------
|
|
212
|
-
|
|
213
|
-
// Run `action` as a BUI transition and a React transition at once: signal
|
|
214
|
-
// writes defer their effects, React state updates render at transition priority.
|
|
215
|
-
// React tracks the action's promise (its optimistic updates last until it
|
|
216
|
-
// settles) but never sees its rejection: each hook sends errors where React's does.
|
|
217
|
-
const quiet = () => {}
|
|
218
|
-
function transition (action) {
|
|
219
|
-
return S.startTransition(() => {
|
|
220
|
-
let result
|
|
221
|
-
let failure = null
|
|
222
|
-
React.startTransition(() => {
|
|
223
|
-
try {
|
|
224
|
-
result = action()
|
|
225
|
-
} catch (error) {
|
|
226
|
-
failure = { error } // React would report it globally and swallow it
|
|
227
|
-
return
|
|
228
|
-
}
|
|
229
|
-
return result && typeof result.then === 'function' ? result.then(quiet, quiet) : undefined
|
|
230
|
-
})
|
|
231
|
-
if (failure !== null) throw failure.error
|
|
232
|
-
return result
|
|
233
|
-
})
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** React's `startTransition`, for signal writes too. Returns a promise that
|
|
237
|
-
* settles when the action and the updates it caused are done. */
|
|
238
|
-
function startTransition (action) {
|
|
239
|
-
return transition(action)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** A setter that rethrows `error` during render: React's way to reach the
|
|
243
|
-
* nearest error boundary from an async callback. */
|
|
244
|
-
function useThrowToBoundary () {
|
|
245
|
-
const [, set] = React.useState(null)
|
|
246
|
-
return React.useCallback((error) => set(() => { throw error }), [])
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** `[isPending signal, startTransition(action)]`. `action` may be async;
|
|
250
|
-
* isPending stays true until it settles and its deferred updates have run.
|
|
251
|
-
* An error goes to the nearest error boundary. */
|
|
252
|
-
function useTransition () {
|
|
253
|
-
const isPending = useOnce(() => signal(false))
|
|
254
|
-
const fail = useThrowToBoundary()
|
|
255
|
-
const start = React.useCallback((action) => {
|
|
256
|
-
isPending._count = (isPending._count ?? 0) + 1
|
|
257
|
-
S.urgent(() => isPending.set(true))
|
|
258
|
-
const settle = () => {
|
|
259
|
-
if (--isPending._count === 0) S.urgent(() => isPending.set(false))
|
|
260
|
-
}
|
|
261
|
-
let done
|
|
262
|
-
try {
|
|
263
|
-
done = transition(action)
|
|
264
|
-
} catch (error) {
|
|
265
|
-
settle()
|
|
266
|
-
return fail(error)
|
|
267
|
-
}
|
|
268
|
-
done.then(settle, (error) => { settle(); fail(error) })
|
|
269
|
-
}, [isPending, fail])
|
|
270
|
-
return [isPending, start]
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** A signal following `value` (a signal or a value) at transition priority,
|
|
274
|
-
* after urgent updates; `initialValue` first, if given. */
|
|
275
|
-
function useDeferredValue (value, initialValue) {
|
|
276
|
-
const read = () => (isSignal(value) ? value.peek() : value)
|
|
277
|
-
const out = useOnce(() => signal(initialValue !== undefined ? initialValue : read()))
|
|
278
|
-
const latest = useLatest(value)
|
|
279
|
-
React.useEffect(() => {
|
|
280
|
-
let scheduled = false
|
|
281
|
-
let live = true
|
|
282
|
-
const push = () => {
|
|
283
|
-
if (scheduled) return
|
|
284
|
-
scheduled = true
|
|
285
|
-
S.scheduleDeferred(() => {
|
|
286
|
-
scheduled = false
|
|
287
|
-
if (!live) return
|
|
288
|
-
const v = latest.current
|
|
289
|
-
out.set(isSignal(v) ? v.peek() : v)
|
|
290
|
-
})
|
|
291
|
-
}
|
|
292
|
-
if (isSignal(value)) {
|
|
293
|
-
const stop = effect(() => { value.value; push() })
|
|
294
|
-
return () => { live = false; stop() }
|
|
295
|
-
}
|
|
296
|
-
push()
|
|
297
|
-
return () => { live = false }
|
|
298
|
-
}, [value, out])
|
|
299
|
-
return out
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** `[state signal, dispatch, isPending signal]`. Dispatches queue and run in
|
|
303
|
-
* order, each with the previous state; an action may be async. A thrown
|
|
304
|
-
* error goes to the nearest error boundary and drops the queue, as in React. */
|
|
305
|
-
function useActionState (action, initialState) {
|
|
306
|
-
const state = useOnce(() => signal(initialState))
|
|
307
|
-
const isPending = useOnce(() => signal(false))
|
|
308
|
-
const latest = useLatest(action)
|
|
309
|
-
const queue = useOnce(() => ({ items: [], running: false }))
|
|
310
|
-
const fail = useThrowToBoundary()
|
|
311
|
-
|
|
312
|
-
const runNext = React.useCallback(() => {
|
|
313
|
-
const payload = queue.items.shift()
|
|
314
|
-
queue.running = true
|
|
315
|
-
let result
|
|
316
|
-
try {
|
|
317
|
-
transition(() => {
|
|
318
|
-
result = latest.current(state.peek(), payload)
|
|
319
|
-
return result
|
|
320
|
-
}).catch(quiet) // the error is handled below, from `result`
|
|
321
|
-
} catch (error) {
|
|
322
|
-
result = Promise.reject(error)
|
|
323
|
-
}
|
|
324
|
-
Promise.resolve(result).then((next) => {
|
|
325
|
-
const more = queue.items.length > 0
|
|
326
|
-
batch(() => {
|
|
327
|
-
state.set(next)
|
|
328
|
-
if (!more) isPending.set(false)
|
|
329
|
-
})
|
|
330
|
-
if (more) runNext()
|
|
331
|
-
else queue.running = false
|
|
332
|
-
}, (err) => {
|
|
333
|
-
queue.items.length = 0
|
|
334
|
-
queue.running = false
|
|
335
|
-
isPending.set(false)
|
|
336
|
-
fail(err)
|
|
337
|
-
})
|
|
338
|
-
}, [state, isPending, queue, fail])
|
|
339
|
-
|
|
340
|
-
const dispatch = React.useCallback((payload) => {
|
|
341
|
-
queue.items.push(payload)
|
|
342
|
-
if (!queue.running) {
|
|
343
|
-
S.urgent(() => isPending.set(true))
|
|
344
|
-
runNext()
|
|
345
|
-
}
|
|
346
|
-
}, [queue, isPending, runNext])
|
|
347
|
-
|
|
348
|
-
return [state, dispatch, isPending]
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** `[optimistic signal, addOptimistic]`. Updates added during a transition or
|
|
352
|
-
* action show at once and are dropped when it ends; `passthrough` (a signal
|
|
353
|
-
* or a value) shows through otherwise. */
|
|
354
|
-
function useOptimistic (passthrough, reducer) {
|
|
355
|
-
const updates = useOnce(() => signal([]))
|
|
356
|
-
const latestReducer = useLatest(reducer)
|
|
357
|
-
const out = React.useMemo(() => memo(() => {
|
|
358
|
-
let v = isSignal(passthrough) ? passthrough.value : passthrough
|
|
359
|
-
for (const u of updates.value) v = latestReducer.current ? latestReducer.current(v, u.action) : u.action
|
|
360
|
-
return v
|
|
361
|
-
}), [passthrough, updates])
|
|
362
|
-
const add = React.useCallback((action) => {
|
|
363
|
-
const entry = { action }
|
|
364
|
-
const remove = () => S.urgent(() => updates.set(updates.peek().filter((e) => e !== entry)))
|
|
365
|
-
S.urgent(() => updates.set([...updates.peek(), entry]))
|
|
366
|
-
if (!S.onTransitionDone(remove)) {
|
|
367
|
-
console.error('An optimistic state update occurred outside a transition or action. ' +
|
|
368
|
-
'To fix, move the update to an action, or wrap it in startTransition.')
|
|
369
|
-
setTimeout(remove, 0)
|
|
370
|
-
}
|
|
371
|
-
}, [updates])
|
|
372
|
-
return [out, add]
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
module.exports = {
|
|
376
|
-
NO_TWIN,
|
|
377
|
-
track,
|
|
378
|
-
createElement,
|
|
379
|
-
startTransition,
|
|
380
|
-
useSignal,
|
|
381
|
-
useComputed,
|
|
382
|
-
useSignalValue,
|
|
383
|
-
useState,
|
|
384
|
-
useReducer,
|
|
385
|
-
useRef,
|
|
386
|
-
useMemo,
|
|
387
|
-
useCallback,
|
|
388
|
-
useEffectEvent,
|
|
389
|
-
useEffect,
|
|
390
|
-
useLayoutEffect,
|
|
391
|
-
useInsertionEffect,
|
|
392
|
-
useSyncExternalStore,
|
|
393
|
-
useTransition,
|
|
394
|
-
useDeferredValue,
|
|
395
|
-
useActionState,
|
|
396
|
-
useOptimistic,
|
|
397
|
-
}
|
|
1
|
+
// React's hooks, by React's names, with signals for state.
|
|
2
|
+
//
|
|
3
|
+
// `useState`, `useReducer`, `useMemo` ... take React's arguments, run at
|
|
4
|
+
// React's moments and return React's shapes, but the state they hold is a
|
|
5
|
+
// signal. Pass it as a prop or child (`<span>{count}</span>`) and a change
|
|
6
|
+
// updates that one node: the component doesn't re-render. Read it while
|
|
7
|
+
// rendering (`count.value`, `count + 1`, a condition) and the component
|
|
8
|
+
// re-renders when it changes, exactly as with React's own hooks.
|
|
9
|
+
//
|
|
10
|
+
// The second part needs to know which component is rendering: components
|
|
11
|
+
// created through this package's JSX runtime (`jsxImportSource:
|
|
12
|
+
// "@blender-ui/react"`) or `createElement` are wrapped with `track()`.
|
|
13
|
+
//
|
|
14
|
+
// Built only on React's public hooks (useState, useReducer, useRef, useMemo,
|
|
15
|
+
// the effect hooks, startTransition), so React's semantics (StrictMode,
|
|
16
|
+
// concurrent rendering) come from React itself. test/hooks.test.js runs each
|
|
17
|
+
// hook beside React's original and fails when React adds a hook that has
|
|
18
|
+
// neither a signal version here nor an entry in NO_TWIN.
|
|
19
|
+
//
|
|
20
|
+
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
21
|
+
'use strict'
|
|
22
|
+
|
|
23
|
+
const React = require('react')
|
|
24
|
+
const S = require('@blender-ui/native')
|
|
25
|
+
const { signal, memo, effect, batch, untrack, isSignal, Tracker, Ref } = S
|
|
26
|
+
|
|
27
|
+
/** React hooks re-exported unchanged, and why (the coverage test reads this). */
|
|
28
|
+
const NO_TWIN = {
|
|
29
|
+
use: 'suspends rendering: inherently a render-time read',
|
|
30
|
+
useContext: 'pass a signal as the context value: consumers bind to it without re-rendering',
|
|
31
|
+
useDebugValue: 'no state',
|
|
32
|
+
useId: 'no state',
|
|
33
|
+
useImperativeHandle: 'no state (works with useRef)',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The latest value, for callbacks that outlive the render that made them.
|
|
37
|
+
function useLatest (value) {
|
|
38
|
+
const ref = React.useRef(value)
|
|
39
|
+
ref.current = value
|
|
40
|
+
return ref
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A component-owned value, created once. */
|
|
44
|
+
function useOnce (create) {
|
|
45
|
+
return React.useState(create)[0]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- render tracking -----------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const bump = (n) => n + 1
|
|
51
|
+
|
|
52
|
+
/** Re-render this component when a signal its render read changes. */
|
|
53
|
+
function useRenderTracker () {
|
|
54
|
+
const [, force] = React.useReducer(bump, 0)
|
|
55
|
+
const tracker = useOnce(() => new Tracker(() => force()))
|
|
56
|
+
const reads = React.useRef(null)
|
|
57
|
+
// after each commit: follow what that render read
|
|
58
|
+
React.useLayoutEffect(() => { tracker.commit(reads.current) })
|
|
59
|
+
// mount / unmount (StrictMode re-mounts: pause, then resume)
|
|
60
|
+
React.useLayoutEffect(() => {
|
|
61
|
+
tracker.resume()
|
|
62
|
+
return () => tracker.pause()
|
|
63
|
+
}, [tracker])
|
|
64
|
+
return { tracker, reads }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const tracked = new WeakMap()
|
|
68
|
+
const MEMO = Symbol.for('react.memo')
|
|
69
|
+
const FORWARD_REF = Symbol.for('react.forward_ref')
|
|
70
|
+
|
|
71
|
+
/** `type` with render tracking: function components, `memo` and `forwardRef`. */
|
|
72
|
+
function track (type) {
|
|
73
|
+
if (type == null) return type
|
|
74
|
+
if (typeof type === 'function') {
|
|
75
|
+
if (type.prototype?.isReactComponent) return type // class components: no hooks
|
|
76
|
+
} else if (typeof type !== 'object' || (type.$$typeof !== MEMO && type.$$typeof !== FORWARD_REF)) {
|
|
77
|
+
return type // host components, fragments, providers, lazy ...
|
|
78
|
+
}
|
|
79
|
+
let t = tracked.get(type)
|
|
80
|
+
if (t !== undefined) return t
|
|
81
|
+
if (typeof type === 'function') {
|
|
82
|
+
t = function (props, secondArg) {
|
|
83
|
+
const { tracker, reads } = useRenderTracker()
|
|
84
|
+
const [result, r] = tracker.run(() => type(props, secondArg))
|
|
85
|
+
reads.current = r
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
Object.defineProperty(t, 'name', { value: type.name })
|
|
89
|
+
t.displayName = type.displayName || type.name
|
|
90
|
+
} else if (type.$$typeof === MEMO) {
|
|
91
|
+
t = React.memo(track(type.type), type.compare)
|
|
92
|
+
t.displayName = type.displayName
|
|
93
|
+
} else {
|
|
94
|
+
t = React.forwardRef(track(type.render))
|
|
95
|
+
t.displayName = type.displayName
|
|
96
|
+
}
|
|
97
|
+
tracked.set(type, t)
|
|
98
|
+
tracked.set(t, t)
|
|
99
|
+
return t
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** React's `createElement`, with render tracking for components. */
|
|
103
|
+
function createElement (type, props, ...children) {
|
|
104
|
+
return React.createElement(track(type), props, ...children)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- state -----------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/** Rust's `use_signal`: a signal owned by this component. */
|
|
110
|
+
function useSignal (initial) {
|
|
111
|
+
return useOnce(() => signal(typeof initial === 'function' ? initial() : initial))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `[signal, setState]`; `setState(v | prev => next)`; the same value: no update. */
|
|
115
|
+
function useState (initialState) {
|
|
116
|
+
const s = useSignal(initialState)
|
|
117
|
+
const set = React.useCallback((v) => s.set(typeof v === 'function' ? v(s.peek()) : v), [s])
|
|
118
|
+
return [s, set]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `[signal, dispatch]`, with the latest render's reducer (as React does). */
|
|
122
|
+
function useReducer (reducer, initialArg, init) {
|
|
123
|
+
const s = useOnce(() => signal(init !== undefined ? init(initialArg) : initialArg))
|
|
124
|
+
const latest = useLatest(reducer)
|
|
125
|
+
const dispatch = React.useCallback((action) => s.set(latest.current(s.peek(), action)), [s])
|
|
126
|
+
return [s, dispatch]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `{ current }`, observable by effects and memos (an element mounting re-runs
|
|
130
|
+
* them); reading it while rendering doesn't subscribe, as with React's refs. */
|
|
131
|
+
function useRef (initialValue) {
|
|
132
|
+
return useOnce(() => new Ref(initialValue))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- derived values ----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** A memo: recomputed when the signals it reads change, and replaced (like
|
|
138
|
+
* useMemo's value) when `deps` change; without deps, every render. */
|
|
139
|
+
function useMemo (fn, deps) {
|
|
140
|
+
const latest = useLatest(fn)
|
|
141
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
142
|
+
return React.useMemo(() => memo(() => latest.current()), deps)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Rust's `Memo::new`: `useMemo` with `deps = []` by default. */
|
|
146
|
+
function useComputed (fn, deps = []) {
|
|
147
|
+
return useMemo(fn, deps)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** React's `useCallback`; calls are batched (their writes make one update) and
|
|
151
|
+
* untracked (calling it in an effect doesn't subscribe to what it reads). */
|
|
152
|
+
function useCallback (fn, deps) {
|
|
153
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
154
|
+
return React.useCallback((...args) => batch(() => untrack(() => fn(...args))), deps)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** React's `useEffectEvent`: a stable function that calls the latest render's
|
|
158
|
+
* `fn`; calls are batched and untracked (an effect calling it doesn't
|
|
159
|
+
* subscribe to the signals `fn` reads). */
|
|
160
|
+
function useEffectEvent (fn) {
|
|
161
|
+
const latest = useLatest(fn)
|
|
162
|
+
return React.useCallback((...args) => batch(() => untrack(() => latest.current(...args))), [latest])
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- effects -----------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function effectHook (reactHook) {
|
|
168
|
+
return function (fn, deps) {
|
|
169
|
+
const latest = useLatest(fn)
|
|
170
|
+
// React's timing and deps; in between, re-runs when the signals it read change
|
|
171
|
+
reactHook(() => effect(() => latest.current()), deps)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** React's `useEffect`, also re-run when the signals it read change. */
|
|
176
|
+
const useEffect = effectHook(React.useEffect)
|
|
177
|
+
/** React's `useLayoutEffect`, also re-run when the signals it read change. */
|
|
178
|
+
const useLayoutEffect = effectHook(React.useLayoutEffect)
|
|
179
|
+
/** React's `useInsertionEffect`, also re-run when the signals it read change. */
|
|
180
|
+
const useInsertionEffect = effectHook(React.useInsertionEffect)
|
|
181
|
+
|
|
182
|
+
// ---- external stores ---------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** A signal following the store. */
|
|
185
|
+
function useSyncExternalStore (subscribe, getSnapshot) {
|
|
186
|
+
const s = useOnce(() => signal(getSnapshot()))
|
|
187
|
+
const latest = useLatest(getSnapshot)
|
|
188
|
+
React.useLayoutEffect(() => {
|
|
189
|
+
const check = () => s.set(latest.current())
|
|
190
|
+
check() // changed between render and subscribe
|
|
191
|
+
return subscribe(check)
|
|
192
|
+
}, [subscribe, s])
|
|
193
|
+
React.useLayoutEffect(() => { s.set(latest.current()) }) // a new getSnapshot
|
|
194
|
+
return s
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Re-render this component when `source` changes; returns its value (for
|
|
198
|
+
* components rendered without this package's JSX runtime). */
|
|
199
|
+
function useSignalValue (source) {
|
|
200
|
+
const subscribe = React.useCallback((onChange) => {
|
|
201
|
+
let first = true
|
|
202
|
+
return effect(() => {
|
|
203
|
+
source.value // subscribe
|
|
204
|
+
if (first) first = false
|
|
205
|
+
else onChange()
|
|
206
|
+
})
|
|
207
|
+
}, [source])
|
|
208
|
+
return React.useSyncExternalStore(subscribe, () => source.peek())
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- transitions and actions -------------------------------------------------
|
|
212
|
+
|
|
213
|
+
// Run `action` as a BUI transition and a React transition at once: signal
|
|
214
|
+
// writes defer their effects, React state updates render at transition priority.
|
|
215
|
+
// React tracks the action's promise (its optimistic updates last until it
|
|
216
|
+
// settles) but never sees its rejection: each hook sends errors where React's does.
|
|
217
|
+
const quiet = () => {}
|
|
218
|
+
function transition (action) {
|
|
219
|
+
return S.startTransition(() => {
|
|
220
|
+
let result
|
|
221
|
+
let failure = null
|
|
222
|
+
React.startTransition(() => {
|
|
223
|
+
try {
|
|
224
|
+
result = action()
|
|
225
|
+
} catch (error) {
|
|
226
|
+
failure = { error } // React would report it globally and swallow it
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
return result && typeof result.then === 'function' ? result.then(quiet, quiet) : undefined
|
|
230
|
+
})
|
|
231
|
+
if (failure !== null) throw failure.error
|
|
232
|
+
return result
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** React's `startTransition`, for signal writes too. Returns a promise that
|
|
237
|
+
* settles when the action and the updates it caused are done. */
|
|
238
|
+
function startTransition (action) {
|
|
239
|
+
return transition(action)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A setter that rethrows `error` during render: React's way to reach the
|
|
243
|
+
* nearest error boundary from an async callback. */
|
|
244
|
+
function useThrowToBoundary () {
|
|
245
|
+
const [, set] = React.useState(null)
|
|
246
|
+
return React.useCallback((error) => set(() => { throw error }), [])
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `[isPending signal, startTransition(action)]`. `action` may be async;
|
|
250
|
+
* isPending stays true until it settles and its deferred updates have run.
|
|
251
|
+
* An error goes to the nearest error boundary. */
|
|
252
|
+
function useTransition () {
|
|
253
|
+
const isPending = useOnce(() => signal(false))
|
|
254
|
+
const fail = useThrowToBoundary()
|
|
255
|
+
const start = React.useCallback((action) => {
|
|
256
|
+
isPending._count = (isPending._count ?? 0) + 1
|
|
257
|
+
S.urgent(() => isPending.set(true))
|
|
258
|
+
const settle = () => {
|
|
259
|
+
if (--isPending._count === 0) S.urgent(() => isPending.set(false))
|
|
260
|
+
}
|
|
261
|
+
let done
|
|
262
|
+
try {
|
|
263
|
+
done = transition(action)
|
|
264
|
+
} catch (error) {
|
|
265
|
+
settle()
|
|
266
|
+
return fail(error)
|
|
267
|
+
}
|
|
268
|
+
done.then(settle, (error) => { settle(); fail(error) })
|
|
269
|
+
}, [isPending, fail])
|
|
270
|
+
return [isPending, start]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A signal following `value` (a signal or a value) at transition priority,
|
|
274
|
+
* after urgent updates; `initialValue` first, if given. */
|
|
275
|
+
function useDeferredValue (value, initialValue) {
|
|
276
|
+
const read = () => (isSignal(value) ? value.peek() : value)
|
|
277
|
+
const out = useOnce(() => signal(initialValue !== undefined ? initialValue : read()))
|
|
278
|
+
const latest = useLatest(value)
|
|
279
|
+
React.useEffect(() => {
|
|
280
|
+
let scheduled = false
|
|
281
|
+
let live = true
|
|
282
|
+
const push = () => {
|
|
283
|
+
if (scheduled) return
|
|
284
|
+
scheduled = true
|
|
285
|
+
S.scheduleDeferred(() => {
|
|
286
|
+
scheduled = false
|
|
287
|
+
if (!live) return
|
|
288
|
+
const v = latest.current
|
|
289
|
+
out.set(isSignal(v) ? v.peek() : v)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
if (isSignal(value)) {
|
|
293
|
+
const stop = effect(() => { value.value; push() })
|
|
294
|
+
return () => { live = false; stop() }
|
|
295
|
+
}
|
|
296
|
+
push()
|
|
297
|
+
return () => { live = false }
|
|
298
|
+
}, [value, out])
|
|
299
|
+
return out
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** `[state signal, dispatch, isPending signal]`. Dispatches queue and run in
|
|
303
|
+
* order, each with the previous state; an action may be async. A thrown
|
|
304
|
+
* error goes to the nearest error boundary and drops the queue, as in React. */
|
|
305
|
+
function useActionState (action, initialState) {
|
|
306
|
+
const state = useOnce(() => signal(initialState))
|
|
307
|
+
const isPending = useOnce(() => signal(false))
|
|
308
|
+
const latest = useLatest(action)
|
|
309
|
+
const queue = useOnce(() => ({ items: [], running: false }))
|
|
310
|
+
const fail = useThrowToBoundary()
|
|
311
|
+
|
|
312
|
+
const runNext = React.useCallback(() => {
|
|
313
|
+
const payload = queue.items.shift()
|
|
314
|
+
queue.running = true
|
|
315
|
+
let result
|
|
316
|
+
try {
|
|
317
|
+
transition(() => {
|
|
318
|
+
result = latest.current(state.peek(), payload)
|
|
319
|
+
return result
|
|
320
|
+
}).catch(quiet) // the error is handled below, from `result`
|
|
321
|
+
} catch (error) {
|
|
322
|
+
result = Promise.reject(error)
|
|
323
|
+
}
|
|
324
|
+
Promise.resolve(result).then((next) => {
|
|
325
|
+
const more = queue.items.length > 0
|
|
326
|
+
batch(() => {
|
|
327
|
+
state.set(next)
|
|
328
|
+
if (!more) isPending.set(false)
|
|
329
|
+
})
|
|
330
|
+
if (more) runNext()
|
|
331
|
+
else queue.running = false
|
|
332
|
+
}, (err) => {
|
|
333
|
+
queue.items.length = 0
|
|
334
|
+
queue.running = false
|
|
335
|
+
isPending.set(false)
|
|
336
|
+
fail(err)
|
|
337
|
+
})
|
|
338
|
+
}, [state, isPending, queue, fail])
|
|
339
|
+
|
|
340
|
+
const dispatch = React.useCallback((payload) => {
|
|
341
|
+
queue.items.push(payload)
|
|
342
|
+
if (!queue.running) {
|
|
343
|
+
S.urgent(() => isPending.set(true))
|
|
344
|
+
runNext()
|
|
345
|
+
}
|
|
346
|
+
}, [queue, isPending, runNext])
|
|
347
|
+
|
|
348
|
+
return [state, dispatch, isPending]
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** `[optimistic signal, addOptimistic]`. Updates added during a transition or
|
|
352
|
+
* action show at once and are dropped when it ends; `passthrough` (a signal
|
|
353
|
+
* or a value) shows through otherwise. */
|
|
354
|
+
function useOptimistic (passthrough, reducer) {
|
|
355
|
+
const updates = useOnce(() => signal([]))
|
|
356
|
+
const latestReducer = useLatest(reducer)
|
|
357
|
+
const out = React.useMemo(() => memo(() => {
|
|
358
|
+
let v = isSignal(passthrough) ? passthrough.value : passthrough
|
|
359
|
+
for (const u of updates.value) v = latestReducer.current ? latestReducer.current(v, u.action) : u.action
|
|
360
|
+
return v
|
|
361
|
+
}), [passthrough, updates])
|
|
362
|
+
const add = React.useCallback((action) => {
|
|
363
|
+
const entry = { action }
|
|
364
|
+
const remove = () => S.urgent(() => updates.set(updates.peek().filter((e) => e !== entry)))
|
|
365
|
+
S.urgent(() => updates.set([...updates.peek(), entry]))
|
|
366
|
+
if (!S.onTransitionDone(remove)) {
|
|
367
|
+
console.error('An optimistic state update occurred outside a transition or action. ' +
|
|
368
|
+
'To fix, move the update to an action, or wrap it in startTransition.')
|
|
369
|
+
setTimeout(remove, 0)
|
|
370
|
+
}
|
|
371
|
+
}, [updates])
|
|
372
|
+
return [out, add]
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = {
|
|
376
|
+
NO_TWIN,
|
|
377
|
+
track,
|
|
378
|
+
createElement,
|
|
379
|
+
startTransition,
|
|
380
|
+
useSignal,
|
|
381
|
+
useComputed,
|
|
382
|
+
useSignalValue,
|
|
383
|
+
useState,
|
|
384
|
+
useReducer,
|
|
385
|
+
useRef,
|
|
386
|
+
useMemo,
|
|
387
|
+
useCallback,
|
|
388
|
+
useEffectEvent,
|
|
389
|
+
useEffect,
|
|
390
|
+
useLayoutEffect,
|
|
391
|
+
useInsertionEffect,
|
|
392
|
+
useSyncExternalStore,
|
|
393
|
+
useTransition,
|
|
394
|
+
useDeferredValue,
|
|
395
|
+
useActionState,
|
|
396
|
+
useOptimistic,
|
|
397
|
+
}
|