@blender-ui/react 0.0.0-stage → 0.2.0
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/LICENSE +674 -0
- package/README.md +170 -2
- package/hooks.js +397 -0
- package/index.d.ts +110 -0
- package/index.js +370 -0
- package/jsx-dev-runtime.d.ts +2 -0
- package/jsx-dev-runtime.js +9 -0
- package/jsx-runtime.d.ts +26 -0
- package/jsx-runtime.js +11 -0
- package/package.json +74 -6
package/README.md
CHANGED
|
@@ -1,3 +1,171 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @blender-ui/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@blender-ui/react)
|
|
4
|
+
[](https://github.com/ppmpreetham/BUI/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
React 19 for the desktop, drawn by [BUI](https://github.com/ppmpreetham/BUI),
|
|
7
|
+
Blender's UI toolkit. You write JSX with HTML elements, CSS and Tailwind
|
|
8
|
+
classes, and BUI renders it in native windows on the CPU or the GPU. There's
|
|
9
|
+
no browser, no webview and no Electron.
|
|
10
|
+
|
|
11
|
+
- **React's hooks, by React's names, holding signals.** A change updates the
|
|
12
|
+
one node that shows it, without re-rendering the component.
|
|
13
|
+
- **Node.js and Bun.**
|
|
14
|
+
- **One small executable:** `bui build` makes an app of about 5 MB that
|
|
15
|
+
reaches its first frame in about 70 ms.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @blender-ui/react react # or: bun add @blender-ui/react react
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```jsx
|
|
22
|
+
/** @jsxImportSource @blender-ui/react */
|
|
23
|
+
import { render, useState } from '@blender-ui/react'
|
|
24
|
+
|
|
25
|
+
function Counter () {
|
|
26
|
+
const [count, setCount] = useState(0)
|
|
27
|
+
return (
|
|
28
|
+
<main className="p-6 flex flex-col gap-3 bg-[#2b2b2b] text-[#e6e6e6]">
|
|
29
|
+
<span>Clicks: {count}</span>
|
|
30
|
+
<button onClick={() => setCount(c => c + 1)}>Add one</button>
|
|
31
|
+
</main>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
render(<Counter />, { title: 'Counter', width: 360, height: 200, gpu: true })
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bun app.jsx # run it (Bun reads JSX as is)
|
|
40
|
+
npx bui build app.jsx # ship it: app.exe, one ~5 MB file
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Put `"jsxImportSource": "@blender-ui/react"` in your `tsconfig.json`, or in
|
|
44
|
+
your bundler's JSX options; the `/** @jsxImportSource */` comment works per
|
|
45
|
+
file. Without JSX, use this package's `createElement`.
|
|
46
|
+
|
|
47
|
+
## Hooks
|
|
48
|
+
|
|
49
|
+
The hooks have React's names, arguments and timing, but the state they hold
|
|
50
|
+
is a **signal**:
|
|
51
|
+
|
|
52
|
+
- **Passed into JSX** (`<span>{count}</span>`, `style={styleMemo}`,
|
|
53
|
+
`style={{ color: colorSignal }}`): a change updates that node. The
|
|
54
|
+
component doesn't re-render.
|
|
55
|
+
- **Read while rendering** (`count.value`; in plain JavaScript also
|
|
56
|
+
`count + 1` or `count > 5`): the component re-renders when it changes, as
|
|
57
|
+
with React.
|
|
58
|
+
- **Conditions and `===` need `.value`.** A signal is an object, so it's
|
|
59
|
+
always truthy: write `{isPending.value && <Spinner />}` and
|
|
60
|
+
`count.value === 0`. TypeScript also needs `.value` for arithmetic.
|
|
61
|
+
|
|
62
|
+
| Hook | Returns |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `useState(init)` | `[signal, setState]` |
|
|
65
|
+
| `useReducer(reducer, arg, init?)` | `[signal, dispatch]` |
|
|
66
|
+
| `useMemo(fn, deps?)` | a memo, recomputed when the signals it reads change |
|
|
67
|
+
| `useRef(v)` | `{ current }`, observable by effects and memos |
|
|
68
|
+
| `useCallback(fn, deps?)` / `useEffectEvent(fn)` | a function whose calls are batched |
|
|
69
|
+
| `useEffect` / `useLayoutEffect` / `useInsertionEffect` | React's timing and deps, plus a re-run when the signals the effect read change |
|
|
70
|
+
| `useSyncExternalStore(subscribe, get)` | a signal following the store |
|
|
71
|
+
| `useTransition()` | `[isPending, startTransition]`; signal updates run after urgent ones |
|
|
72
|
+
| `useDeferredValue(value, initial?)` | a signal that follows `value` at low priority |
|
|
73
|
+
| `useActionState(action, initial)` | `[state, dispatch, isPending]` |
|
|
74
|
+
| `useOptimistic(state, reducer?)` | `[optimistic, addOptimistic]` |
|
|
75
|
+
| `use`, `useContext`, `useId`, `useDebugValue`, `useImperativeHandle` | React's own |
|
|
76
|
+
|
|
77
|
+
Also exported:
|
|
78
|
+
|
|
79
|
+
- **Rust-style names:** `useSignal(v)` and `useComputed(fn)`.
|
|
80
|
+
- **`useSignalValue(sig)`:** re-renders its component when `sig` changes.
|
|
81
|
+
- **Plain signals:** `signal`, `computed`, `effect`, `batch`, `untrack`,
|
|
82
|
+
`startTransition`.
|
|
83
|
+
|
|
84
|
+
React's plain-value hooks still work: import them from `react`.
|
|
85
|
+
|
|
86
|
+
Every hook is built from React's public hooks. A conformance suite runs each
|
|
87
|
+
one beside React's original, in development and production builds and under
|
|
88
|
+
StrictMode. It fails when a React release adds a hook this package doesn't
|
|
89
|
+
cover yet.
|
|
90
|
+
|
|
91
|
+
## The renderer
|
|
92
|
+
|
|
93
|
+
- **Host components are HTML elements.** `className` takes Tailwind classes,
|
|
94
|
+
`style` takes strings, objects or signals, and the mouse, keyboard and form
|
|
95
|
+
events work (`onClick`, `onChange` ...). Events bubble, and
|
|
96
|
+
`stopPropagation` works.
|
|
97
|
+
- **One native batch per commit:** only what changed crosses to BUI's UI
|
|
98
|
+
thread.
|
|
99
|
+
- **React 19.3:** `<Suspense>`, `<Activity>`, transitions, actions and
|
|
100
|
+
`<ViewTransition>`, which commits without animating.
|
|
101
|
+
- **API:** `render(element, options)` opens a window. `createRoot(window)`
|
|
102
|
+
renders into one you opened with
|
|
103
|
+
[`@blender-ui/native`](https://www.npmjs.com/package/@blender-ui/native),
|
|
104
|
+
and `act(fn)` is for tests.
|
|
105
|
+
- Window options: `title`, `width`, `height`, `resizable`, `gpu`,
|
|
106
|
+
`slug`, `headless`, and `theme` (`'dark'`, the default, or `'light'`:
|
|
107
|
+
Blender's Light theme; `BUI_THEME` applies otherwise).
|
|
108
|
+
|
|
109
|
+
## Standalone apps
|
|
110
|
+
|
|
111
|
+
`bui` comes with this package, through `@blender-ui/native`:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
npx bui build app.jsx # app.exe: BUI + embedded QuickJS, ~5 MB
|
|
115
|
+
npx bui build app.jsx --bun # app.exe: Bun, with Node's APIs and a JIT, ~115 MB
|
|
116
|
+
npx bui run app.jsx # run on the QuickJS runtime, no build
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Options:
|
|
120
|
+
|
|
121
|
+
- `-o <file>`: the output file.
|
|
122
|
+
- `--console`: keep a console window for logs.
|
|
123
|
+
- `--dev`: React's development build.
|
|
124
|
+
|
|
125
|
+
Bundling uses [Bun](https://bun.sh), so it must be installed.
|
|
126
|
+
|
|
127
|
+
The default build runs your app on QuickJS, a small JavaScript engine embedded
|
|
128
|
+
in BUI. It has no Node modules (`fs` and the like) and no JIT, so keep large
|
|
129
|
+
lists updating through signals, or use `--bun`.
|
|
130
|
+
|
|
131
|
+
## Benchmarks
|
|
132
|
+
|
|
133
|
+
Ryzen 7 5800U laptop, Windows 11, GPU renderer, React's production build,
|
|
134
|
+
measured before the latest features landed.
|
|
135
|
+
The full method is in the [guide](https://github.com/ppmpreetham/BUI/blob/main/docs/javascript.md#performance).
|
|
136
|
+
|
|
137
|
+
**Startup:** a small React app that quits at its first frame; medians of 12
|
|
138
|
+
launches.
|
|
139
|
+
|
|
140
|
+
| | First frame | Launch to exit | RAM | Size |
|
|
141
|
+
|---|---:|---:|---:|---:|
|
|
142
|
+
| **`bui build`** (QuickJS) | **68 ms** | **104 ms** | **19 MB** | **4.9 MB** |
|
|
143
|
+
| `bui build --bun` | 79 ms | 147 ms | 35 MB | 114 MB |
|
|
144
|
+
| `bun app.js` | 80 ms | 152 ms | 35 MB | |
|
|
145
|
+
| `node app.js` | 108 ms | 187 ms | 45 MB | |
|
|
146
|
+
|
|
147
|
+
**Animation:** 3,000 cells, one changing per frame.
|
|
148
|
+
|
|
149
|
+
- **With signals:** the changing values are signals, so the grid renders once.
|
|
150
|
+
- **With state:** the grid re-renders every frame.
|
|
151
|
+
|
|
152
|
+
| 3,000 cells | FPS | CPU per frame | RAM |
|
|
153
|
+
|---|---:|---:|---:|
|
|
154
|
+
| Bun: with signals / with state | 58.4 / 55.8 | 13.3 / 20.8 ms | 85 / 118 MB |
|
|
155
|
+
| Node: with signals / with state | 56.7 / 54.8 | 14.0 / 18.9 ms | 93 / 121 MB |
|
|
156
|
+
| QuickJS: with signals / with state | 58.7 / 19.7 | 14.9 / 51.0 ms | 61 / 67 MB |
|
|
157
|
+
| *Rust, for reference* | 59.6 | 12.0 ms | 43 MB |
|
|
158
|
+
|
|
159
|
+
## Requirements
|
|
160
|
+
|
|
161
|
+
- **Windows x64** prebuilt. On **Linux**, build the `@blender-ui/native`
|
|
162
|
+
addon from source (`npm run build` in `bindings/node`) and windows are
|
|
163
|
+
native X11 windows. On **macOS** it's headless for now. `headless: true`
|
|
164
|
+
renders off-screen anywhere.
|
|
165
|
+
- **Node.js 18** or newer, or Bun.
|
|
166
|
+
- **React 19.3.**
|
|
167
|
+
|
|
168
|
+
Guide: [docs/javascript.md](https://github.com/ppmpreetham/BUI/blob/main/docs/javascript.md) ·
|
|
169
|
+
Website: [bui.preetham.top](https://bui.preetham.top) ·
|
|
170
|
+
Source: [github.com/ppmpreetham/BUI](https://github.com/ppmpreetham/BUI) ·
|
|
171
|
+
License: GPL-2.0-or-later
|
package/hooks.js
ADDED
|
@@ -0,0 +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
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Types for @blender-ui/react.
|
|
2
|
+
import type { ReactNode, ReactElement, DependencyList, EffectCallback, Dispatch, SetStateAction, ElementType } from 'react'
|
|
3
|
+
import type { App, Window, AppOptions, WindowOptions, Signal, Memo, ReadSignal, Ref } from '@blender-ui/native'
|
|
4
|
+
|
|
5
|
+
export interface Root {
|
|
6
|
+
render (element: ReactNode): void
|
|
7
|
+
unmount (): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A React root rendering into `window`'s body (unmounted when the window closes). */
|
|
11
|
+
export function createRoot (window: Window): Root
|
|
12
|
+
|
|
13
|
+
/** Open a window (and an App, unless `app` is given) and render `element` into it. */
|
|
14
|
+
export function render (element: ReactNode, options?: WindowOptions & AppOptions & { app?: App }):
|
|
15
|
+
{ app: App, window: Window, root: Root }
|
|
16
|
+
|
|
17
|
+
/** Run `fn` and flush React's work synchronously (tests). */
|
|
18
|
+
export function act<R> (fn: () => R): R
|
|
19
|
+
|
|
20
|
+
/** React's `createElement`, with render tracking for components. */
|
|
21
|
+
export const createElement: typeof import('react').createElement
|
|
22
|
+
/** A component type with render tracking (re-renders when a signal its render read changes). */
|
|
23
|
+
export function track<T extends ElementType> (type: T): T
|
|
24
|
+
|
|
25
|
+
// React's hooks that hold no state, as React exports them.
|
|
26
|
+
export { use, useContext, useDebugValue, useId, useImperativeHandle } from 'react'
|
|
27
|
+
|
|
28
|
+
// Signals.
|
|
29
|
+
export { signal, effect, batch, untrack, createSignal, isSignal, Signal, Memo, ReadSignal, Ref } from '@blender-ui/native'
|
|
30
|
+
/** A derived, cached signal (`memo` in @blender-ui/native; here `memo` stays React's). */
|
|
31
|
+
export { memo as computed, ref } from '@blender-ui/native'
|
|
32
|
+
|
|
33
|
+
/** React hooks re-exported unchanged, and why. */
|
|
34
|
+
export const NO_TWIN: Record<string, string>
|
|
35
|
+
|
|
36
|
+
// React's hooks by React's names, with signals for state. Same arguments and
|
|
37
|
+
// timing as React's. A signal passed as a prop or child updates that node
|
|
38
|
+
// alone; read while rendering (`.value`, `count + 1`), it re-renders the
|
|
39
|
+
// component when it changes. Conditions and `===` need `.value`: a signal
|
|
40
|
+
// object is always truthy.
|
|
41
|
+
|
|
42
|
+
/** `[signal, setState]`: `setState(v)` or `setState(prev => next)`. */
|
|
43
|
+
export function useState<T> (initialState: T | (() => T)): [Signal<T>, Dispatch<SetStateAction<T>>]
|
|
44
|
+
export function useState<T = undefined> (): [Signal<T | undefined>, Dispatch<SetStateAction<T | undefined>>]
|
|
45
|
+
/** `[signal, dispatch]`, with the latest render's reducer. */
|
|
46
|
+
export function useReducer<S, A> (reducer: (state: S, action: A) => S, initialArg: S): [Signal<S>, Dispatch<A>]
|
|
47
|
+
export function useReducer<S, A, I> (reducer: (state: S, action: A) => S, initialArg: I, init: (arg: I) => S): [Signal<S>, Dispatch<A>]
|
|
48
|
+
/** `{ current }`, observable by effects and memos; reading it while rendering doesn't subscribe. */
|
|
49
|
+
export function useRef<T> (initialValue: T): Ref<T>
|
|
50
|
+
export function useRef<T = undefined> (): Ref<T | undefined>
|
|
51
|
+
/** A memo following its signals, replaced when `deps` change (without deps: every render). */
|
|
52
|
+
export function useMemo<T> (fn: () => T, deps?: DependencyList): Memo<T>
|
|
53
|
+
/** React's `useCallback`; calls are batched and untracked. */
|
|
54
|
+
export function useCallback<F extends (...args: any[]) => any> (fn: F, deps?: DependencyList): F
|
|
55
|
+
/** React's `useEffectEvent`; calls are batched and untracked. */
|
|
56
|
+
export function useEffectEvent<F extends (...args: any[]) => any> (fn: F): F
|
|
57
|
+
/** React's `useEffect`, also re-run when the signals it read change. */
|
|
58
|
+
export function useEffect (effect: EffectCallback, deps?: DependencyList): void
|
|
59
|
+
/** React's `useLayoutEffect`, also re-run when the signals it read change. */
|
|
60
|
+
export function useLayoutEffect (effect: EffectCallback, deps?: DependencyList): void
|
|
61
|
+
/** React's `useInsertionEffect`, also re-run when the signals it read change. */
|
|
62
|
+
export function useInsertionEffect (effect: EffectCallback, deps?: DependencyList): void
|
|
63
|
+
/** A signal following the store. */
|
|
64
|
+
export function useSyncExternalStore<T> (subscribe: (onChange: () => void) => () => void, getSnapshot: () => T,
|
|
65
|
+
getServerSnapshot?: () => T): Signal<T>
|
|
66
|
+
/** `[isPending, startTransition]`: what the action's writes trigger runs after urgent updates. */
|
|
67
|
+
export function useTransition (): [Signal<boolean>, (action: () => void | Promise<void>) => void]
|
|
68
|
+
/** React's `startTransition`, for signal writes too; settles when the action and its updates are done. */
|
|
69
|
+
export function startTransition (action: () => void | Promise<void>): Promise<void>
|
|
70
|
+
/** A signal following `value` (a signal or a value) at transition priority. */
|
|
71
|
+
export function useDeferredValue<T> (value: T | ReadSignal<T>, initialValue?: T): Signal<T>
|
|
72
|
+
/** `[state, dispatch, isPending]`: actions run in order, each with the previous state. */
|
|
73
|
+
export function useActionState<S, P = void> (action: (state: S, payload: P) => S | Promise<S>, initialState: S,
|
|
74
|
+
permalink?: string): [Signal<S>, (payload: P) => void, Signal<boolean>]
|
|
75
|
+
/** `[optimistic, addOptimistic]`: updates last until their transition or action ends. */
|
|
76
|
+
export function useOptimistic<S> (passthrough: S | ReadSignal<S>): [Memo<S>, (value: S) => void]
|
|
77
|
+
export function useOptimistic<S, A> (passthrough: S | ReadSignal<S>, reducer: (state: S, action: A) => S):
|
|
78
|
+
[Memo<S>, (action: A) => void]
|
|
79
|
+
|
|
80
|
+
/** Rust's `use_signal`: a signal owned by this component. */
|
|
81
|
+
export function useSignal<T> (initial: T | (() => T)): Signal<T>
|
|
82
|
+
/** Rust's `Memo::new`: `useMemo(fn, deps = [])`. */
|
|
83
|
+
export function useComputed<T> (fn: () => T, deps?: DependencyList): Memo<T>
|
|
84
|
+
/** Re-render this component when `source` changes; returns its value (components
|
|
85
|
+
* rendered without this package's JSX runtime or createElement). */
|
|
86
|
+
export function useSignalValue<T> (source: ReadSignal<T>): T
|
|
87
|
+
|
|
88
|
+
export type { ReactElement }
|
|
89
|
+
|
|
90
|
+
// A signal is a valid child: `<span>Clicks: {count}</span>`.
|
|
91
|
+
declare module 'react' {
|
|
92
|
+
interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES {
|
|
93
|
+
buiSignal: ReadSignal<unknown>
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Blender's widgets as components: always drawn and handled like Blender.
|
|
98
|
+
* Their lowercase HTML counterparts look like a browser's form controls
|
|
99
|
+
* (and like Blender inside an element with the `styled` attribute). */
|
|
100
|
+
type BlenderProps = Record<string, unknown> & { children?: ReactNode }
|
|
101
|
+
export function Button (props: BlenderProps): ReactNode
|
|
102
|
+
export function Checkbox (props: BlenderProps): ReactNode
|
|
103
|
+
export function Radio (props: BlenderProps): ReactNode
|
|
104
|
+
export function NumberField (props: BlenderProps): ReactNode
|
|
105
|
+
export function NumberSlider (props: BlenderProps): ReactNode
|
|
106
|
+
export function TextField (props: BlenderProps): ReactNode
|
|
107
|
+
export function TextArea (props: BlenderProps): ReactNode
|
|
108
|
+
export function ColorSwatch (props: BlenderProps): ReactNode
|
|
109
|
+
export function Dropdown (props: BlenderProps): ReactNode
|
|
110
|
+
export function ProgressBar (props: BlenderProps): ReactNode
|