@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/index.js CHANGED
@@ -1,370 +1,370 @@
1
- // @blender-ui/react: a React renderer for BUI (react-reconciler 0.34 / React 19.3).
2
- //
3
- // const { render } = require('@blender-ui/react')
4
- // render(<div className="p-4 bg-[#4772b3]" onClick={...}>Hello</div>,
5
- // { title: 'App', width: 400, height: 300 })
6
- //
7
- // Host components are HTML elements: React mutates @blender-ui/native's
8
- // retained tree, which sends only the changes of each commit to the native
9
- // UI thread. Events arrive as BUI events (onClick, onChange, onKeyDown ...).
10
- //
11
- // SPDX-License-Identifier: GPL-2.0-or-later
12
- 'use strict'
13
-
14
- const React = require('react')
15
- const Reconciler = require('react-reconciler')
16
- const { ConcurrentRoot, DefaultEventPriority, DiscreteEventPriority, NoEventPriority } =
17
- require('react-reconciler/constants')
18
- const native = require('@blender-ui/native')
19
- const { App, TextNode, Signal, Memo, isSignal } = native
20
- const hooks = require('./hooks')
21
-
22
- // A signal rendered as a child (`<span>{count}</span>`) becomes this host
23
- // type: a text node bound to the signal, updated without re-rendering.
24
- const SIGNAL_TEXT = '#bui-signal-text'
25
- for (const C of [Signal, Memo]) {
26
- Object.defineProperties(C.prototype, {
27
- $$typeof: { value: Symbol.for('react.transitional.element'), configurable: true },
28
- type: { value: SIGNAL_TEXT, configurable: true },
29
- key: { value: null, configurable: true },
30
- ref: { value: null, configurable: true },
31
- _owner: { value: null, configurable: true },
32
- // React (dev) marks list children validated here: a fresh object each time
33
- _store: { get () { return { validated: 1 } }, configurable: true },
34
- props: { get () { return (this._reactProps ??= { signal: this }) }, configurable: true },
35
- })
36
- }
37
-
38
- // React prop -> BUI event; onChange is per keystroke on text fields, as in React DOM.
39
- const EVENTS = {
40
- onClick: 'click', onMouseDown: 'mousedown', onMouseUp: 'mouseup', onMouseMove: 'mousemove',
41
- onMouseEnter: 'mouseenter', onMouseLeave: 'mouseleave', onWheel: 'wheel',
42
- onKeyDown: 'keydown', onKeyUp: 'keyup', onInput: 'input', onChange: 'change',
43
- }
44
- const TEXT_INPUTS = new Set(['textarea'])
45
- const ATTR_ALIASES = { className: 'class', htmlFor: 'for', tabIndex: 'tabindex' }
46
- const SKIP = new Set(['children', 'key', 'ref', 'dangerouslySetInnerHTML', 'suppressHydrationWarning',
47
- 'suppressContentEditableWarning'])
48
- const NO_PROPS = {}
49
-
50
- function eventName (el, prop) {
51
- if (prop === 'onChange') {
52
- // text fields report every edit as `input`; checkboxes, selects: `change`
53
- const type = el.getAttribute('type')
54
- if (TEXT_INPUTS.has(el.localName) || (el.localName === 'input' &&
55
- !['checkbox', 'radio', 'range', 'color', 'file'].includes(type))) return 'input'
56
- }
57
- return EVENTS[prop]
58
- }
59
-
60
- /** Listen with `fn` (or stop, when it isn't a function) for React prop `k`.
61
- * The DOM event is remembered with it: onChange's depends on `type`. */
62
- function listen (el, k, fn) {
63
- const cur = el._react[k]
64
- const type = typeof fn === 'function' ? eventName(el, k) : null
65
- if (cur !== undefined && cur.fn === fn && cur.type === type) return
66
- if (cur !== undefined) el.removeEventListener(cur.type, cur.fn)
67
- if (type === null) delete el._react[k]
68
- else {
69
- el.addEventListener(type, fn)
70
- el._react[k] = { type, fn }
71
- }
72
- }
73
-
74
- function isText (children) {
75
- return typeof children === 'string' || typeof children === 'number' || typeof children === 'bigint'
76
- }
77
-
78
- /** Apply the difference between two prop objects to an element. Hot: React
79
- * calls it for every element of a re-rendered list, mostly with equal props. */
80
- function applyProps (el, prev, next) {
81
- if (prev !== NO_PROPS) {
82
- for (const k in prev) {
83
- if (k in next || SKIP.has(k)) continue
84
- if (k in EVENTS) {
85
- listen(el, k, null)
86
- } else {
87
- el.removeAttribute(ATTR_ALIASES[k] ?? k)
88
- }
89
- }
90
- }
91
- let events = false
92
- for (const k in next) {
93
- const v = next[k]
94
- if (prev[k] === v || SKIP.has(k)) continue
95
- if (k in EVENTS) {
96
- events = true // after the attributes: onChange's event depends on `type`
97
- } else if (k === 'style') {
98
- if (v == null) el.removeAttribute('style')
99
- else el.style = v // a string, an object or a signal of either
100
- } else if (v == null || v === false) {
101
- el.removeAttribute(ATTR_ALIASES[k] ?? k)
102
- } else if (isSignal(v)) {
103
- el.setAttribute(ATTR_ALIASES[k] ?? k, v) // bound: updates without a re-render
104
- } else {
105
- el.setAttribute(ATTR_ALIASES[k] ?? k, v === true ? '' : v)
106
- }
107
- }
108
- if (events || (next.onChange !== undefined && prev.type !== next.type)) {
109
- for (const k in next) if (k in EVENTS) listen(el, k, next[k])
110
- }
111
- // text children; text -> elements is React's resetTextContent (before it
112
- // inserts them), so nothing to clear here
113
- const c = next.children
114
- if (c !== prev.children && isText(c)) el.textContent = String(c)
115
- }
116
-
117
- // Nodes a render created. React may throw a render away (an interrupted or
118
- // suspended concurrent render); its nodes are then never attached, and are
119
- // freed after the next commit.
120
- function created (container, node) {
121
- container.created.push(node)
122
- return node
123
- }
124
-
125
- function sweep (container) {
126
- const body = container.window.body
127
- for (const node of container.created) {
128
- let top = node
129
- while (top.parentNode !== null) top = top.parentNode
130
- if (top !== body && container.window._nodes.has(top.id)) top.destroy()
131
- }
132
- container.created.length = 0
133
- }
134
-
135
- let updatePriority = NoEventPriority
136
- const ROOT_CONTEXT = {}
137
-
138
- const host = {
139
- rendererPackageName: '@blender-ui/react',
140
- rendererVersion: require('./package.json').version,
141
- supportsMutation: true,
142
- supportsPersistence: false,
143
- supportsHydration: false,
144
- supportsMicrotasks: true,
145
- isPrimaryRenderer: true,
146
- warnsIfNotActing: false,
147
- noTimeout: -1,
148
- scheduleTimeout: setTimeout,
149
- cancelTimeout: clearTimeout,
150
- scheduleMicrotask: queueMicrotask,
151
-
152
- getRootHostContext: () => ROOT_CONTEXT, // must not be null (React checks)
153
- getChildHostContext: (parent) => parent,
154
- getPublicInstance: (instance) => instance,
155
- prepareForCommit: () => null,
156
- resetAfterCommit (container) {
157
- if (container.created.length > 0) sweep(container)
158
- container.window.flush() // one native call per commit
159
- },
160
- preparePortalMount () {},
161
-
162
- createInstance (type, props, container) {
163
- if (type === SIGNAL_TEXT) return created(container, container.window.createTextNode(props.signal))
164
- const el = container.window.createElement(type)
165
- el._react = {}
166
- applyProps(el, NO_PROPS, props)
167
- return created(container, el)
168
- },
169
- createTextInstance: (text, container) => created(container, container.window.createTextNode(text)),
170
- appendInitialChild: (parent, child) => parent.appendChild(child),
171
- finalizeInitialChildren: () => false,
172
- shouldSetTextContent: (type, props) => isText(props.children),
173
-
174
- appendChild: (parent, child) => parent.appendChild(child),
175
- appendChildToContainer: (container, child) => container.window.body.appendChild(child),
176
- insertBefore: (parent, child, before) => parent.insertBefore(child, before),
177
- insertInContainerBefore: (container, child, before) => container.window.body.insertBefore(child, before),
178
- removeChild (parent, child) {
179
- parent.removeChild(child)
180
- child.destroy()
181
- },
182
- removeChildFromContainer (container, child) {
183
- container.window.body.removeChild(child)
184
- child.destroy()
185
- },
186
- clearContainer (container) {
187
- for (const c of [...container.window.body.childNodes]) container.window.body.removeChild(c)
188
- },
189
- // the subtree: text set through textContent has no fiber of its own (freeing twice is a no-op)
190
- detachDeletedInstance: (node) => node.destroy(),
191
-
192
- commitUpdate (el, type, prev, next) {
193
- if (type === SIGNAL_TEXT) {
194
- if (prev.signal !== next.signal) el.data = next.signal
195
- } else {
196
- applyProps(el, prev, next)
197
- }
198
- },
199
- commitTextUpdate: (text, prev, next) => { text.data = next },
200
- commitMount () {},
201
- resetTextContent: (el) => { el.textContent = '' },
202
- hideInstance (el) {
203
- if (el instanceof TextNode) el.data = '' // a signal's text: unbound while hidden
204
- else el.setAttribute('hidden', '')
205
- },
206
- unhideInstance (el, props) {
207
- if (el instanceof TextNode) el.data = props.signal
208
- else if (!props.hidden) el.removeAttribute('hidden')
209
- },
210
- hideTextInstance: (text) => { text._hidden = text.data; text.data = '' },
211
- unhideTextInstance: (text, value) => { text.data = value },
212
-
213
- setCurrentUpdatePriority: (p) => { updatePriority = p },
214
- getCurrentUpdatePriority: () => updatePriority,
215
- resolveUpdatePriority: () => updatePriority || DefaultEventPriority,
216
- resolveEventType: () => null,
217
- resolveEventTimeStamp: () => -1.1,
218
- shouldAttemptEagerTransition: () => false,
219
- trackSchedulerEvent () {},
220
- requestPostPaintCallback () {},
221
- maySuspendCommit: () => false,
222
- preloadInstance: () => true,
223
- startSuspendingCommit () {},
224
- suspendInstance () {},
225
- waitForCommitToBeReady: () => null,
226
- NotPendingTransition: null,
227
- HostTransitionContext: React.createContext(null),
228
- resetFormInstance () {},
229
- getInstanceFromNode: () => null,
230
- getInstanceFromScope: () => null,
231
- beforeActiveInstanceBlur () {},
232
- afterActiveInstanceBlur () {},
233
- prepareScopeUpdate () {},
234
- bindToConsole: (method, args) => Function.prototype.bind.apply(console[method], [console, ...args]),
235
-
236
- // Commits that wait on resources: none here.
237
- maySuspendCommitOnUpdate: () => false,
238
- maySuspendCommitInSyncRender: () => false,
239
- getSuspendedCommitReason: () => null,
240
-
241
- // View transitions (<ViewTransition>): not animated here. As React DOM does
242
- // without the browser API: run the commit's phases now and report none.
243
- startViewTransition (suspendedState, container, types, mutation, layout, afterMutation, spawnedWork) {
244
- mutation()
245
- layout()
246
- spawnedWork()
247
- return null
248
- },
249
- startGestureTransition: () => null,
250
- stopViewTransition () {},
251
- suspendOnActiveViewTransition () {},
252
- addViewTransitionFinishedListener (transition, callback) { callback() },
253
- createViewTransitionInstance: (name) => ({ name, group: null, imagePair: null, old: null, new: null }),
254
- applyViewTransitionName () {},
255
- restoreViewTransitionName () {},
256
- cancelViewTransitionName () {},
257
- cancelRootViewTransitionName () {},
258
- restoreRootViewTransitionName () {},
259
- cloneRootViewTransitionContainer: (container) => container,
260
- removeRootViewTransitionClone () {},
261
- measureInstance: () => null,
262
- measureClonedInstance: () => null,
263
- wasInstanceInViewport: () => true,
264
- hasInstanceChanged: () => false,
265
- hasInstanceAffectedParent: () => false,
266
- getCurrentGestureOffset: () => 0,
267
-
268
- // <Fragment ref>: the fragment's fiber, nothing more (no DOM-like API yet).
269
- createFragmentInstance: (fiber) => ({ _fragmentFiber: fiber }),
270
- updateFragmentInstanceFiber (fiber, instance) { instance._fragmentFiber = fiber },
271
- commitNewChildToFragmentInstance () {},
272
- deleteChildFromFragmentInstance () {},
273
- }
274
-
275
- const reconciler = Reconciler(host)
276
-
277
- // Events: run handlers as discrete updates (React DOM does the same), so a
278
- // click's state change commits right away.
279
- const nativeDispatch = native.Element.prototype._dispatch
280
- native.Element.prototype._dispatch = function (raw) {
281
- const prev = updatePriority
282
- updatePriority = DiscreteEventPriority
283
- try {
284
- nativeDispatch.call(this, raw)
285
- } finally {
286
- updatePriority = prev
287
- }
288
- }
289
-
290
- function report (kind) {
291
- return (err) => console.error(`@blender-ui/react: ${kind}`, err)
292
- }
293
-
294
- // The root component gets render tracking even when created with React's
295
- // own createElement (components below need this package's JSX runtime or
296
- // createElement).
297
- function trackRoot (element) {
298
- if (!React.isValidElement(element)) return element
299
- const type = hooks.track(element.type)
300
- if (type === element.type) return element
301
- return React.createElement(type, element.key != null ? { ...element.props, key: element.key } : element.props)
302
- }
303
-
304
- /** A React root rendering into `window`'s body. */
305
- function createRoot (window) {
306
- const container = { window, created: [] }
307
- const root = reconciler.createContainer(container, ConcurrentRoot, null, false, null, 'bui',
308
- report('uncaught error'), report('caught error'), report('recoverable error'), null)
309
- window.on('close', () => reconciler.updateContainer(null, root, null, null))
310
- return {
311
- render (element) { reconciler.updateContainer(trackRoot(element), root, null, null) },
312
- unmount () { reconciler.updateContainer(null, root, null, null) },
313
- }
314
- }
315
-
316
- /**
317
- * Open a window and render `element` into it.
318
- * @param {React.ReactNode} element
319
- * @param {{ title?: string, width?: number, height?: number, resizable?: boolean,
320
- * gpu?: boolean, slug?: boolean, headless?: boolean, app?: App }} [options]
321
- * @returns {{ app: App, window: import('@blender-ui/native').Window, root: ReturnType<typeof createRoot> }}
322
- */
323
- function render (element, options = {}) {
324
- const app = options.app ?? new App({ gpu: options.gpu, slug: options.slug, headless: options.headless, theme: options.theme })
325
- const window = app.window(options)
326
- const root = createRoot(window)
327
- root.render(element)
328
- return { app, window, root }
329
- }
330
-
331
- /** Run `fn` and flush React's work synchronously (tests). */
332
- function act (fn) {
333
- const flushSync = reconciler.flushSyncFromReconciler ?? reconciler.flushSync
334
- return typeof flushSync === 'function' ? flushSync(fn) : fn()
335
- }
336
-
337
- // React's hooks by React's names: those that hold or react to state are the
338
- // signal versions (hooks.js), the others are React's own.
339
- const reactHooks = {}
340
- for (const k of Object.keys(React)) if (/^use([A-Z]|$)/.test(k)) reactHooks[k] = React[k]
341
-
342
- // Blender's widgets as components: `<NumberSlider min={0} max={10} />`,
343
- // `<Button onClick={..}>Apply</Button>`. Always Blender widgets; their
344
- // lowercase HTML counterparts look like a browser's (Blender's inside `styled`).
345
- const blenderComponents = {}
346
- for (const name of Object.keys(native.BLENDER_COMPONENTS)) {
347
- const C = (props) => React.createElement(name, props)
348
- Object.defineProperty(C, 'name', { value: name })
349
- blenderComponents[name] = C
350
- }
351
-
352
- module.exports = {
353
- ...blenderComponents,
354
- ...reactHooks,
355
- ...hooks,
356
- render,
357
- createRoot,
358
- act,
359
- reconciler,
360
- signal: native.signal,
361
- computed: native.memo, // `memo` stays React's (components)
362
- effect: native.effect,
363
- batch: native.batch,
364
- untrack: native.untrack,
365
- createSignal: native.createSignal,
366
- ref: native.ref,
367
- isSignal,
368
- Signal,
369
- Memo,
370
- }
1
+ // @blender-ui/react: a React renderer for BUI (react-reconciler 0.34 / React 19.3).
2
+ //
3
+ // const { render } = require('@blender-ui/react')
4
+ // render(<div className="p-4 bg-[#4772b3]" onClick={...}>Hello</div>,
5
+ // { title: 'App', width: 400, height: 300 })
6
+ //
7
+ // Host components are HTML elements: React mutates @blender-ui/native's
8
+ // retained tree, which sends only the changes of each commit to the native
9
+ // UI thread. Events arrive as BUI events (onClick, onChange, onKeyDown ...).
10
+ //
11
+ // SPDX-License-Identifier: GPL-2.0-or-later
12
+ 'use strict'
13
+
14
+ const React = require('react')
15
+ const Reconciler = require('react-reconciler')
16
+ const { ConcurrentRoot, DefaultEventPriority, DiscreteEventPriority, NoEventPriority } =
17
+ require('react-reconciler/constants')
18
+ const native = require('@blender-ui/native')
19
+ const { App, TextNode, Signal, Memo, isSignal } = native
20
+ const hooks = require('./hooks')
21
+
22
+ // A signal rendered as a child (`<span>{count}</span>`) becomes this host
23
+ // type: a text node bound to the signal, updated without re-rendering.
24
+ const SIGNAL_TEXT = '#bui-signal-text'
25
+ for (const C of [Signal, Memo]) {
26
+ Object.defineProperties(C.prototype, {
27
+ $$typeof: { value: Symbol.for('react.transitional.element'), configurable: true },
28
+ type: { value: SIGNAL_TEXT, configurable: true },
29
+ key: { value: null, configurable: true },
30
+ ref: { value: null, configurable: true },
31
+ _owner: { value: null, configurable: true },
32
+ // React (dev) marks list children validated here: a fresh object each time
33
+ _store: { get () { return { validated: 1 } }, configurable: true },
34
+ props: { get () { return (this._reactProps ??= { signal: this }) }, configurable: true },
35
+ })
36
+ }
37
+
38
+ // React prop -> BUI event; onChange is per keystroke on text fields, as in React DOM.
39
+ const EVENTS = {
40
+ onClick: 'click', onMouseDown: 'mousedown', onMouseUp: 'mouseup', onMouseMove: 'mousemove',
41
+ onMouseEnter: 'mouseenter', onMouseLeave: 'mouseleave', onWheel: 'wheel',
42
+ onKeyDown: 'keydown', onKeyUp: 'keyup', onInput: 'input', onChange: 'change',
43
+ }
44
+ const TEXT_INPUTS = new Set(['textarea'])
45
+ const ATTR_ALIASES = { className: 'class', htmlFor: 'for', tabIndex: 'tabindex' }
46
+ const SKIP = new Set(['children', 'key', 'ref', 'dangerouslySetInnerHTML', 'suppressHydrationWarning',
47
+ 'suppressContentEditableWarning'])
48
+ const NO_PROPS = {}
49
+
50
+ function eventName (el, prop) {
51
+ if (prop === 'onChange') {
52
+ // text fields report every edit as `input`; checkboxes, selects: `change`
53
+ const type = el.getAttribute('type')
54
+ if (TEXT_INPUTS.has(el.localName) || (el.localName === 'input' &&
55
+ !['checkbox', 'radio', 'range', 'color', 'file'].includes(type))) return 'input'
56
+ }
57
+ return EVENTS[prop]
58
+ }
59
+
60
+ /** Listen with `fn` (or stop, when it isn't a function) for React prop `k`.
61
+ * The DOM event is remembered with it: onChange's depends on `type`. */
62
+ function listen (el, k, fn) {
63
+ const cur = el._react[k]
64
+ const type = typeof fn === 'function' ? eventName(el, k) : null
65
+ if (cur !== undefined && cur.fn === fn && cur.type === type) return
66
+ if (cur !== undefined) el.removeEventListener(cur.type, cur.fn)
67
+ if (type === null) delete el._react[k]
68
+ else {
69
+ el.addEventListener(type, fn)
70
+ el._react[k] = { type, fn }
71
+ }
72
+ }
73
+
74
+ function isText (children) {
75
+ return typeof children === 'string' || typeof children === 'number' || typeof children === 'bigint'
76
+ }
77
+
78
+ /** Apply the difference between two prop objects to an element. Hot: React
79
+ * calls it for every element of a re-rendered list, mostly with equal props. */
80
+ function applyProps (el, prev, next) {
81
+ if (prev !== NO_PROPS) {
82
+ for (const k in prev) {
83
+ if (k in next || SKIP.has(k)) continue
84
+ if (k in EVENTS) {
85
+ listen(el, k, null)
86
+ } else {
87
+ el.removeAttribute(ATTR_ALIASES[k] ?? k)
88
+ }
89
+ }
90
+ }
91
+ let events = false
92
+ for (const k in next) {
93
+ const v = next[k]
94
+ if (prev[k] === v || SKIP.has(k)) continue
95
+ if (k in EVENTS) {
96
+ events = true // after the attributes: onChange's event depends on `type`
97
+ } else if (k === 'style') {
98
+ if (v == null) el.removeAttribute('style')
99
+ else el.style = v // a string, an object or a signal of either
100
+ } else if (v == null || v === false) {
101
+ el.removeAttribute(ATTR_ALIASES[k] ?? k)
102
+ } else if (isSignal(v)) {
103
+ el.setAttribute(ATTR_ALIASES[k] ?? k, v) // bound: updates without a re-render
104
+ } else {
105
+ el.setAttribute(ATTR_ALIASES[k] ?? k, v === true ? '' : v)
106
+ }
107
+ }
108
+ if (events || (next.onChange !== undefined && prev.type !== next.type)) {
109
+ for (const k in next) if (k in EVENTS) listen(el, k, next[k])
110
+ }
111
+ // text children; text -> elements is React's resetTextContent (before it
112
+ // inserts them), so nothing to clear here
113
+ const c = next.children
114
+ if (c !== prev.children && isText(c)) el.textContent = String(c)
115
+ }
116
+
117
+ // Nodes a render created. React may throw a render away (an interrupted or
118
+ // suspended concurrent render); its nodes are then never attached, and are
119
+ // freed after the next commit.
120
+ function created (container, node) {
121
+ container.created.push(node)
122
+ return node
123
+ }
124
+
125
+ function sweep (container) {
126
+ const body = container.window.body
127
+ for (const node of container.created) {
128
+ let top = node
129
+ while (top.parentNode !== null) top = top.parentNode
130
+ if (top !== body && container.window._nodes.has(top.id)) top.destroy()
131
+ }
132
+ container.created.length = 0
133
+ }
134
+
135
+ let updatePriority = NoEventPriority
136
+ const ROOT_CONTEXT = {}
137
+
138
+ const host = {
139
+ rendererPackageName: '@blender-ui/react',
140
+ rendererVersion: require('./package.json').version,
141
+ supportsMutation: true,
142
+ supportsPersistence: false,
143
+ supportsHydration: false,
144
+ supportsMicrotasks: true,
145
+ isPrimaryRenderer: true,
146
+ warnsIfNotActing: false,
147
+ noTimeout: -1,
148
+ scheduleTimeout: setTimeout,
149
+ cancelTimeout: clearTimeout,
150
+ scheduleMicrotask: queueMicrotask,
151
+
152
+ getRootHostContext: () => ROOT_CONTEXT, // must not be null (React checks)
153
+ getChildHostContext: (parent) => parent,
154
+ getPublicInstance: (instance) => instance,
155
+ prepareForCommit: () => null,
156
+ resetAfterCommit (container) {
157
+ if (container.created.length > 0) sweep(container)
158
+ container.window.flush() // one native call per commit
159
+ },
160
+ preparePortalMount () {},
161
+
162
+ createInstance (type, props, container) {
163
+ if (type === SIGNAL_TEXT) return created(container, container.window.createTextNode(props.signal))
164
+ const el = container.window.createElement(type)
165
+ el._react = {}
166
+ applyProps(el, NO_PROPS, props)
167
+ return created(container, el)
168
+ },
169
+ createTextInstance: (text, container) => created(container, container.window.createTextNode(text)),
170
+ appendInitialChild: (parent, child) => parent.appendChild(child),
171
+ finalizeInitialChildren: () => false,
172
+ shouldSetTextContent: (type, props) => isText(props.children),
173
+
174
+ appendChild: (parent, child) => parent.appendChild(child),
175
+ appendChildToContainer: (container, child) => container.window.body.appendChild(child),
176
+ insertBefore: (parent, child, before) => parent.insertBefore(child, before),
177
+ insertInContainerBefore: (container, child, before) => container.window.body.insertBefore(child, before),
178
+ removeChild (parent, child) {
179
+ parent.removeChild(child)
180
+ child.destroy()
181
+ },
182
+ removeChildFromContainer (container, child) {
183
+ container.window.body.removeChild(child)
184
+ child.destroy()
185
+ },
186
+ clearContainer (container) {
187
+ for (const c of [...container.window.body.childNodes]) container.window.body.removeChild(c)
188
+ },
189
+ // the subtree: text set through textContent has no fiber of its own (freeing twice is a no-op)
190
+ detachDeletedInstance: (node) => node.destroy(),
191
+
192
+ commitUpdate (el, type, prev, next) {
193
+ if (type === SIGNAL_TEXT) {
194
+ if (prev.signal !== next.signal) el.data = next.signal
195
+ } else {
196
+ applyProps(el, prev, next)
197
+ }
198
+ },
199
+ commitTextUpdate: (text, prev, next) => { text.data = next },
200
+ commitMount () {},
201
+ resetTextContent: (el) => { el.textContent = '' },
202
+ hideInstance (el) {
203
+ if (el instanceof TextNode) el.data = '' // a signal's text: unbound while hidden
204
+ else el.setAttribute('hidden', '')
205
+ },
206
+ unhideInstance (el, props) {
207
+ if (el instanceof TextNode) el.data = props.signal
208
+ else if (!props.hidden) el.removeAttribute('hidden')
209
+ },
210
+ hideTextInstance: (text) => { text._hidden = text.data; text.data = '' },
211
+ unhideTextInstance: (text, value) => { text.data = value },
212
+
213
+ setCurrentUpdatePriority: (p) => { updatePriority = p },
214
+ getCurrentUpdatePriority: () => updatePriority,
215
+ resolveUpdatePriority: () => updatePriority || DefaultEventPriority,
216
+ resolveEventType: () => null,
217
+ resolveEventTimeStamp: () => -1.1,
218
+ shouldAttemptEagerTransition: () => false,
219
+ trackSchedulerEvent () {},
220
+ requestPostPaintCallback () {},
221
+ maySuspendCommit: () => false,
222
+ preloadInstance: () => true,
223
+ startSuspendingCommit () {},
224
+ suspendInstance () {},
225
+ waitForCommitToBeReady: () => null,
226
+ NotPendingTransition: null,
227
+ HostTransitionContext: React.createContext(null),
228
+ resetFormInstance () {},
229
+ getInstanceFromNode: () => null,
230
+ getInstanceFromScope: () => null,
231
+ beforeActiveInstanceBlur () {},
232
+ afterActiveInstanceBlur () {},
233
+ prepareScopeUpdate () {},
234
+ bindToConsole: (method, args) => Function.prototype.bind.apply(console[method], [console, ...args]),
235
+
236
+ // Commits that wait on resources: none here.
237
+ maySuspendCommitOnUpdate: () => false,
238
+ maySuspendCommitInSyncRender: () => false,
239
+ getSuspendedCommitReason: () => null,
240
+
241
+ // View transitions (<ViewTransition>): not animated here. As React DOM does
242
+ // without the browser API: run the commit's phases now and report none.
243
+ startViewTransition (suspendedState, container, types, mutation, layout, afterMutation, spawnedWork) {
244
+ mutation()
245
+ layout()
246
+ spawnedWork()
247
+ return null
248
+ },
249
+ startGestureTransition: () => null,
250
+ stopViewTransition () {},
251
+ suspendOnActiveViewTransition () {},
252
+ addViewTransitionFinishedListener (transition, callback) { callback() },
253
+ createViewTransitionInstance: (name) => ({ name, group: null, imagePair: null, old: null, new: null }),
254
+ applyViewTransitionName () {},
255
+ restoreViewTransitionName () {},
256
+ cancelViewTransitionName () {},
257
+ cancelRootViewTransitionName () {},
258
+ restoreRootViewTransitionName () {},
259
+ cloneRootViewTransitionContainer: (container) => container,
260
+ removeRootViewTransitionClone () {},
261
+ measureInstance: () => null,
262
+ measureClonedInstance: () => null,
263
+ wasInstanceInViewport: () => true,
264
+ hasInstanceChanged: () => false,
265
+ hasInstanceAffectedParent: () => false,
266
+ getCurrentGestureOffset: () => 0,
267
+
268
+ // <Fragment ref>: the fragment's fiber, nothing more (no DOM-like API yet).
269
+ createFragmentInstance: (fiber) => ({ _fragmentFiber: fiber }),
270
+ updateFragmentInstanceFiber (fiber, instance) { instance._fragmentFiber = fiber },
271
+ commitNewChildToFragmentInstance () {},
272
+ deleteChildFromFragmentInstance () {},
273
+ }
274
+
275
+ const reconciler = Reconciler(host)
276
+
277
+ // Events: run handlers as discrete updates (React DOM does the same), so a
278
+ // click's state change commits right away.
279
+ const nativeDispatch = native.Element.prototype._dispatch
280
+ native.Element.prototype._dispatch = function (raw) {
281
+ const prev = updatePriority
282
+ updatePriority = DiscreteEventPriority
283
+ try {
284
+ nativeDispatch.call(this, raw)
285
+ } finally {
286
+ updatePriority = prev
287
+ }
288
+ }
289
+
290
+ function report (kind) {
291
+ return (err) => console.error(`@blender-ui/react: ${kind}`, err)
292
+ }
293
+
294
+ // The root component gets render tracking even when created with React's
295
+ // own createElement (components below need this package's JSX runtime or
296
+ // createElement).
297
+ function trackRoot (element) {
298
+ if (!React.isValidElement(element)) return element
299
+ const type = hooks.track(element.type)
300
+ if (type === element.type) return element
301
+ return React.createElement(type, element.key != null ? { ...element.props, key: element.key } : element.props)
302
+ }
303
+
304
+ /** A React root rendering into `window`'s body. */
305
+ function createRoot (window) {
306
+ const container = { window, created: [] }
307
+ const root = reconciler.createContainer(container, ConcurrentRoot, null, false, null, 'bui',
308
+ report('uncaught error'), report('caught error'), report('recoverable error'), null)
309
+ window.on('close', () => reconciler.updateContainer(null, root, null, null))
310
+ return {
311
+ render (element) { reconciler.updateContainer(trackRoot(element), root, null, null) },
312
+ unmount () { reconciler.updateContainer(null, root, null, null) },
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Open a window and render `element` into it.
318
+ * @param {React.ReactNode} element
319
+ * @param {{ title?: string, width?: number, height?: number, resizable?: boolean,
320
+ * gpu?: boolean, slug?: boolean, headless?: boolean, app?: App }} [options]
321
+ * @returns {{ app: App, window: import('@blender-ui/native').Window, root: ReturnType<typeof createRoot> }}
322
+ */
323
+ function render (element, options = {}) {
324
+ const app = options.app ?? new App({ gpu: options.gpu, slug: options.slug, headless: options.headless, theme: options.theme })
325
+ const window = app.window(options)
326
+ const root = createRoot(window)
327
+ root.render(element)
328
+ return { app, window, root }
329
+ }
330
+
331
+ /** Run `fn` and flush React's work synchronously (tests). */
332
+ function act (fn) {
333
+ const flushSync = reconciler.flushSyncFromReconciler ?? reconciler.flushSync
334
+ return typeof flushSync === 'function' ? flushSync(fn) : fn()
335
+ }
336
+
337
+ // React's hooks by React's names: those that hold or react to state are the
338
+ // signal versions (hooks.js), the others are React's own.
339
+ const reactHooks = {}
340
+ for (const k of Object.keys(React)) if (/^use([A-Z]|$)/.test(k)) reactHooks[k] = React[k]
341
+
342
+ // Blender's widgets as components: `<NumberSlider min={0} max={10} />`,
343
+ // `<Button onClick={..}>Apply</Button>`. Always Blender widgets; their
344
+ // lowercase HTML counterparts look like a browser's (Blender's inside `styled`).
345
+ const blenderComponents = {}
346
+ for (const name of Object.keys(native.BLENDER_COMPONENTS)) {
347
+ const C = (props) => React.createElement(name, props)
348
+ Object.defineProperty(C, 'name', { value: name })
349
+ blenderComponents[name] = C
350
+ }
351
+
352
+ module.exports = {
353
+ ...blenderComponents,
354
+ ...reactHooks,
355
+ ...hooks,
356
+ render,
357
+ createRoot,
358
+ act,
359
+ reconciler,
360
+ signal: native.signal,
361
+ computed: native.memo, // `memo` stays React's (components)
362
+ effect: native.effect,
363
+ batch: native.batch,
364
+ untrack: native.untrack,
365
+ createSignal: native.createSignal,
366
+ ref: native.ref,
367
+ isSignal,
368
+ Signal,
369
+ Memo,
370
+ }