@bun-native/react 0.0.0-stage → 0.33.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/LICENSE +21 -0
- package/README.md +4 -2
- package/bun-native-source.json +59 -0
- package/package.json +40 -4
- package/src/commit-barrier.ts +31 -0
- package/src/index.ts +11 -0
- package/src/jsx-dev-runtime.ts +2 -0
- package/src/jsx-runtime.ts +2 -0
- package/src/jsx.ts +74 -0
- package/src/mount.ts +198 -0
- package/src/reconciler.ts +228 -0
- package/src/root-errors.ts +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bunly contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bun-native/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React renderer for Bun Native desktop windows.
|
|
4
|
+
|
|
5
|
+
Part of [Bun Native](https://github.com/0xPlayerOne/bun-native), an experimental developer preview. Version 0.33.1 was built from [c56fdd675365](https://github.com/0xPlayerOne/bun-native/tree/c56fdd6753653a10144a4167c1d3dfb3c38a0647) and published from GitHub Actions with npm provenance. Use the Bun revision pinned in that commit's toolchain.json. Start with the [getting-started guide](https://github.com/0xPlayerOne/bun-native/blob/c56fdd6753653a10144a4167c1d3dfb3c38a0647/docs/getting-started.md); `bun-native host install` fetches the matching release host. The license notice is in LICENSE.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"commit": "c56fdd6753653a10144a4167c1d3dfb3c38a0647",
|
|
4
|
+
"tree": "c6e81b3f9d041e11dd0413b5fdd7a50910383854",
|
|
5
|
+
"files": {
|
|
6
|
+
"package.json": {
|
|
7
|
+
"bytes": 2115,
|
|
8
|
+
"sha256": "caf866d56f3cd7530d5735ecbf62512e9e22526bc44c75a4b48a18c26352c0b8"
|
|
9
|
+
},
|
|
10
|
+
"packages/react/package.json": {
|
|
11
|
+
"bytes": 402,
|
|
12
|
+
"sha256": "18ac27eaf168a16c333e2b79e9aa7fa9ff9caf272fbf27523d865848946503ec"
|
|
13
|
+
},
|
|
14
|
+
"packages/react/src/commit-barrier.ts": {
|
|
15
|
+
"bytes": 987,
|
|
16
|
+
"sha256": "139f9f2827d3363642fee801e128dd1abf2a754d04550aca7d878493ef19bfd0"
|
|
17
|
+
},
|
|
18
|
+
"packages/react/src/index.ts": {
|
|
19
|
+
"bytes": 338,
|
|
20
|
+
"sha256": "211d06eb159c3afa4ceac566538e7cbc7ad4d106a4c58d05d7bb0cd842e2acda"
|
|
21
|
+
},
|
|
22
|
+
"packages/react/src/jsx-dev-runtime.ts": {
|
|
23
|
+
"bytes": 93,
|
|
24
|
+
"sha256": "bc127e1290abc7a6ba83fac0647b9f8ecca35c7aaa025a9ad25e35289a93bd85"
|
|
25
|
+
},
|
|
26
|
+
"packages/react/src/jsx-runtime.ts": {
|
|
27
|
+
"bytes": 92,
|
|
28
|
+
"sha256": "df1c78b5c254c4ccfa226d9b3d0c2b2a9dbf0733d40deac4f45cb47692a3763d"
|
|
29
|
+
},
|
|
30
|
+
"packages/react/src/jsx.ts": {
|
|
31
|
+
"bytes": 3189,
|
|
32
|
+
"sha256": "4458ba8196591d768d0c7d1a333e47fb2902f44b3e848b9fd7289f68092b8502"
|
|
33
|
+
},
|
|
34
|
+
"packages/react/src/mount.ts": {
|
|
35
|
+
"bytes": 7595,
|
|
36
|
+
"sha256": "259ed7e24ef6f2943c889deadea3c337e627c3633c514a62dd2c1de781c3097f"
|
|
37
|
+
},
|
|
38
|
+
"packages/react/src/reconciler.ts": {
|
|
39
|
+
"bytes": 8349,
|
|
40
|
+
"sha256": "6036ac4f997f965f0bfbcf73719d6cdf8a59e6706bc9f25e1e3d3db1777e8a6f"
|
|
41
|
+
},
|
|
42
|
+
"packages/react/src/root-errors.ts": {
|
|
43
|
+
"bytes": 1234,
|
|
44
|
+
"sha256": "963071b9e1086f551fef172ee6d53a78d07aeaa674f6d3bf746f8774fabfe780"
|
|
45
|
+
},
|
|
46
|
+
"LICENSE": {
|
|
47
|
+
"bytes": 1075,
|
|
48
|
+
"sha256": "86673fb485f12fa9aaa3941dfccc9f69a7f8322f8141d0d5c73db4323de10ac5"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"toolchain": {
|
|
52
|
+
"bytes": 3234,
|
|
53
|
+
"sha256": "9f4d9362cb4cc38c0f4e705295789a0cbfec46ffdd007e0541b9441a7c3003a7"
|
|
54
|
+
},
|
|
55
|
+
"dependencyLock": {
|
|
56
|
+
"bytes": 28357,
|
|
57
|
+
"sha256": "65e3dbd01be4a1889367690faf1a9c3f08fb31d22dbd273d9bb9f39dd6baa6fb"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bun-native/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.33.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"LICENSE",
|
|
9
|
+
"README.md",
|
|
10
|
+
"bun-native-source.json"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"bun": "1.4.4-canary.1"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts",
|
|
17
|
+
"./jsx-runtime": "./src/jsx-runtime.ts",
|
|
18
|
+
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts"
|
|
19
|
+
},
|
|
20
|
+
"description": "React renderer for Bun Native desktop windows.",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@bun-native/core": "0.33.1",
|
|
23
|
+
"@bun-native/renderer": "0.33.1",
|
|
24
|
+
"react-reconciler": "0.34.0",
|
|
25
|
+
"@types/react-reconciler": "0.34.1"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"react": "19.3.1",
|
|
29
|
+
"@types/react": "19.3.0"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/0xPlayerOne/bun-native.git",
|
|
34
|
+
"directory": "packages/react"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/0xPlayerOne/bun-native#readme",
|
|
37
|
+
"bugs": "https://github.com/0xPlayerOne/bun-native/issues",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"provenance": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Revocable ownership for a callback retained by an opaque reconciler update. */
|
|
2
|
+
export interface CommitBarrierBindings {
|
|
3
|
+
release: (() => void) | undefined
|
|
4
|
+
resolve: (() => void) | undefined
|
|
5
|
+
reject: ((cause: unknown) => void) | undefined
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function settle(bindings: CommitBarrierBindings, failed: boolean, cause?: unknown): void {
|
|
9
|
+
const release = bindings.release
|
|
10
|
+
const resolve = bindings.resolve
|
|
11
|
+
const reject = bindings.reject
|
|
12
|
+
// Revoke the actual root/promise bindings before callbacks can reenter.
|
|
13
|
+
bindings.release = undefined
|
|
14
|
+
bindings.resolve = undefined
|
|
15
|
+
bindings.reject = undefined
|
|
16
|
+
release?.()
|
|
17
|
+
if (failed) reject?.(cause)
|
|
18
|
+
else resolve?.()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Keep this factory outside Root's scope; retained callbacks own only this cell. */
|
|
22
|
+
export function createCommitBarrier(bindings: CommitBarrierBindings) {
|
|
23
|
+
return {
|
|
24
|
+
complete(): void {
|
|
25
|
+
settle(bindings, false)
|
|
26
|
+
},
|
|
27
|
+
reject(cause: unknown): void {
|
|
28
|
+
settle(bindings, true, cause)
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
focusNode,
|
|
3
|
+
inputState,
|
|
4
|
+
registerDropTarget,
|
|
5
|
+
scrollNodeIntoView,
|
|
6
|
+
scrollNodeTo,
|
|
7
|
+
} from '@bun-native/renderer'
|
|
8
|
+
export type { RendererNode as NativeRef } from '@bun-native/renderer'
|
|
9
|
+
export { render } from './mount.ts'
|
|
10
|
+
export type { ReactRenderOptions, ReactRenderRoot } from './mount.ts'
|
|
11
|
+
export type { JSX } from './jsx.ts'
|
package/src/jsx.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Element as NativeElement, InputState } from '@bun-native/core'
|
|
2
|
+
import type { EventHandlers, RendererNode } from '@bun-native/renderer'
|
|
3
|
+
import type { JSX as ReactJSX, ReactNode, Ref } from 'react'
|
|
4
|
+
|
|
5
|
+
export namespace JSX {
|
|
6
|
+
export type Element = ReactJSX.Element
|
|
7
|
+
export type ElementType = ReactJSX.ElementType
|
|
8
|
+
export type ElementClass = ReactJSX.ElementClass
|
|
9
|
+
export type ElementAttributesProperty = ReactJSX.ElementAttributesProperty
|
|
10
|
+
export type ElementChildrenAttribute = ReactJSX.ElementChildrenAttribute
|
|
11
|
+
export type LibraryManagedAttributes<C, P> = ReactJSX.LibraryManagedAttributes<C, P>
|
|
12
|
+
export type IntrinsicAttributes = ReactJSX.IntrinsicAttributes
|
|
13
|
+
export type IntrinsicClassAttributes<T> = ReactJSX.IntrinsicClassAttributes<T>
|
|
14
|
+
|
|
15
|
+
type Base<K extends NativeElement['kind']> = EventHandlers &
|
|
16
|
+
ReactJSX.IntrinsicAttributes & {
|
|
17
|
+
style?: NonNullable<Extract<NativeElement, { kind: K }>['style']>
|
|
18
|
+
ref?: Ref<RendererNode>
|
|
19
|
+
}
|
|
20
|
+
type TextContent = { text?: string; children?: ReactNode }
|
|
21
|
+
export interface IntrinsicElements {
|
|
22
|
+
/**
|
|
23
|
+
* Requires `accessibility_lists_v1` for the accessible members. A list's
|
|
24
|
+
* `accessibleSetSize` counts every item, mounted or not; an item's
|
|
25
|
+
* `accessiblePosition` is its 1-based place in that full set.
|
|
26
|
+
*/
|
|
27
|
+
box: Base<'box'> & { children?: ReactNode } & {
|
|
28
|
+
accessibleRole?: 'list' | 'list_item'
|
|
29
|
+
/** Only with `accessibleRole`. */
|
|
30
|
+
accessibleLabel?: string
|
|
31
|
+
/** Only on a `list`. */
|
|
32
|
+
accessibleSetSize?: number
|
|
33
|
+
/** Only on a `list_item`; 1-based. */
|
|
34
|
+
accessiblePosition?: number
|
|
35
|
+
/** Only on a `list_item`. */
|
|
36
|
+
accessibleSelected?: boolean
|
|
37
|
+
}
|
|
38
|
+
text: Base<'text'> &
|
|
39
|
+
TextContent & {
|
|
40
|
+
/** Requires `accessibility_lists_v1`: announce the text when it appears and changes. */
|
|
41
|
+
accessibleLive?: 'polite' | 'assertive'
|
|
42
|
+
}
|
|
43
|
+
button: Base<'button'> & TextContent & { disabled?: boolean }
|
|
44
|
+
input: Base<'input'> & {
|
|
45
|
+
value?: string | InputState
|
|
46
|
+
placeholder?: string
|
|
47
|
+
/** Requires the host's `input_states_v1` capability. */
|
|
48
|
+
disabled?: boolean
|
|
49
|
+
/** Requires the host's `input_states_v1` capability. */
|
|
50
|
+
readOnly?: boolean
|
|
51
|
+
}
|
|
52
|
+
/** At most two pane children; the host owns the divider drag and keyboard steps. */
|
|
53
|
+
split: Base<'split'> & {
|
|
54
|
+
children?: ReactNode
|
|
55
|
+
ratio?: number
|
|
56
|
+
min_size?: number
|
|
57
|
+
/** Requires `accessibility_v1`: the divider's accessible name. */
|
|
58
|
+
accessibleLabel?: string
|
|
59
|
+
}
|
|
60
|
+
/** Painted above the ordinary tree; only window-anchored overlays may be modal. */
|
|
61
|
+
overlay: Base<'overlay'> & {
|
|
62
|
+
children?: ReactNode
|
|
63
|
+
/** Requires `overlay_keyboard_v1`: focus the first enabled control on open. */
|
|
64
|
+
autofocus?: boolean
|
|
65
|
+
/** Requires `overlay_keyboard_v1`: `menu` adds arrow-key roving focus and type-ahead. */
|
|
66
|
+
navigation?: 'tab' | 'menu'
|
|
67
|
+
/** Requires `accessibility_v1`: the dialog's accessible name. */
|
|
68
|
+
accessibleLabel?: string
|
|
69
|
+
} & (
|
|
70
|
+
| { anchor?: 'window'; modal?: boolean; surface?: 'layer' }
|
|
71
|
+
| { anchor: 'parent'; modal?: false; surface?: 'layer' | 'window' }
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/mount.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { NativeStateError, type NativeWindow, type Style } from '@bun-native/core'
|
|
2
|
+
import { createRenderRoot, type RenderOptions, type RenderRoot } from '@bun-native/renderer'
|
|
3
|
+
import type { ErrorInfo, ReactNode } from 'react'
|
|
4
|
+
import { ConcurrentRoot } from 'react-reconciler/constants'
|
|
5
|
+
import { dispatchDiscrete, reconciler } from './reconciler.ts'
|
|
6
|
+
import { createRootErrors } from './root-errors.ts'
|
|
7
|
+
import { createCommitBarrier } from './commit-barrier.ts'
|
|
8
|
+
|
|
9
|
+
type FiberRoot = ReturnType<typeof reconciler.createContainer>
|
|
10
|
+
|
|
11
|
+
export interface ReactRenderRoot extends RenderRoot {
|
|
12
|
+
/** Submit a new React tree; resolves after its native acknowledgement. */
|
|
13
|
+
render(children: ReactNode): Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReactRenderOptions extends RenderOptions {
|
|
17
|
+
/** Synchronously observe errors React recovered from; defaults to console.error. A thrown reporter error terminates this root. */
|
|
18
|
+
onRecoverableError?: (error: unknown, info: Readonly<ErrorInfo>) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class Root implements ReactRenderRoot {
|
|
22
|
+
readonly ready: Promise<void>
|
|
23
|
+
readonly closed: Promise<void>
|
|
24
|
+
private readonly native: RenderRoot
|
|
25
|
+
private fiber!: FiberRoot
|
|
26
|
+
private errors: ReturnType<typeof createRootErrors> | undefined
|
|
27
|
+
private element: ReactNode
|
|
28
|
+
private phase: 'active' | 'disposing' | 'closed' = 'active'
|
|
29
|
+
private failure: Error | undefined
|
|
30
|
+
private disposal: Promise<void> | undefined
|
|
31
|
+
private readonly barriers = new Set<ReturnType<typeof createCommitBarrier>>()
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
children: ReactNode,
|
|
35
|
+
window: NativeWindow,
|
|
36
|
+
onRecoverableError: NonNullable<ReactRenderOptions['onRecoverableError']>,
|
|
37
|
+
style: Style | undefined
|
|
38
|
+
) {
|
|
39
|
+
this.element = children
|
|
40
|
+
this.native = createRenderRoot(
|
|
41
|
+
window,
|
|
42
|
+
({ container, onCleanup, fail }) => {
|
|
43
|
+
const terminate = (error: unknown) => {
|
|
44
|
+
this.failure ??= error instanceof Error ? error : new Error(String(error))
|
|
45
|
+
// React invokes this inside its commit. Teardown must start after that
|
|
46
|
+
// stack returns, rather than recursively entering Fiber's commit.
|
|
47
|
+
queueMicrotask(() => fail(this.failure))
|
|
48
|
+
}
|
|
49
|
+
this.errors = createRootErrors({ terminate, report: onRecoverableError })
|
|
50
|
+
this.fiber = reconciler.createContainer(
|
|
51
|
+
container,
|
|
52
|
+
ConcurrentRoot,
|
|
53
|
+
null,
|
|
54
|
+
false,
|
|
55
|
+
null,
|
|
56
|
+
'',
|
|
57
|
+
this.errors.uncaught,
|
|
58
|
+
this.errors.caught,
|
|
59
|
+
this.errors.recoverable,
|
|
60
|
+
this.errors.transitionIndicator,
|
|
61
|
+
null
|
|
62
|
+
)
|
|
63
|
+
onCleanup(() => {
|
|
64
|
+
this.phase = 'disposing'
|
|
65
|
+
try {
|
|
66
|
+
reconciler.updateContainerSync(null, this.fiber, null)
|
|
67
|
+
reconciler.flushSyncWork()
|
|
68
|
+
reconciler.flushPassiveEffects()
|
|
69
|
+
// React reports unmount effect/ref errors through onUncaughtError.
|
|
70
|
+
// The shared root is already disposing, so forward the synchronous
|
|
71
|
+
// result here instead of relying on a later terminal-error request.
|
|
72
|
+
if (this.failure) throw this.failure
|
|
73
|
+
} finally {
|
|
74
|
+
// Framework teardown is terminal even while native removal awaits
|
|
75
|
+
// acknowledgement. Keep unmount errors active until this boundary.
|
|
76
|
+
this.errors?.release()
|
|
77
|
+
this.errors = undefined
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
reconciler.updateContainerSync(children, this.fiber, null)
|
|
81
|
+
reconciler.flushSyncWork()
|
|
82
|
+
if (this.failure !== undefined) throw this.failure
|
|
83
|
+
reconciler.flushPassiveEffects()
|
|
84
|
+
},
|
|
85
|
+
style === undefined ? {} : { style },
|
|
86
|
+
{ dispatch: dispatchDiscrete }
|
|
87
|
+
)
|
|
88
|
+
this.ready = this.native.ready.then(() => {
|
|
89
|
+
if (this.phase !== 'active')
|
|
90
|
+
throw new NativeStateError(
|
|
91
|
+
'invalid_handle',
|
|
92
|
+
'React root closed before initial native acknowledgement'
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
void this.ready.catch(() => {})
|
|
96
|
+
this.closed = this.native.closed
|
|
97
|
+
void this.closed.then(
|
|
98
|
+
() => this.finish(),
|
|
99
|
+
(error) => {
|
|
100
|
+
this.failure = error
|
|
101
|
+
this.finish()
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get diagnostics() {
|
|
107
|
+
return this.native.diagnostics
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private finish(): void {
|
|
111
|
+
this.phase = 'closed'
|
|
112
|
+
this.errors?.release()
|
|
113
|
+
this.errors = undefined
|
|
114
|
+
const error = this.failure ?? new NativeStateError('invalid_state', 'React root is closed')
|
|
115
|
+
for (const barrier of this.barriers) barrier.reject(error)
|
|
116
|
+
this.barriers.clear()
|
|
117
|
+
this.element = null
|
|
118
|
+
this.fiber = null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private assertActive(): void {
|
|
122
|
+
if (this.failure !== undefined) throw this.failure
|
|
123
|
+
if (this.phase !== 'active') throw new NativeStateError('invalid_state', 'React root is closed')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async render(children: ReactNode): Promise<void> {
|
|
127
|
+
this.assertActive()
|
|
128
|
+
this.element = children
|
|
129
|
+
reconciler.updateContainerSync(children, this.fiber, null)
|
|
130
|
+
reconciler.flushSyncWork()
|
|
131
|
+
await this.flush()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async flush(): Promise<void> {
|
|
135
|
+
this.assertActive()
|
|
136
|
+
// A normal-priority root update is a Fiber commit barrier for already
|
|
137
|
+
// scheduled ordinary hook updates. It does not wait for future async work,
|
|
138
|
+
// unresolved Suspense, or lower-priority transitions.
|
|
139
|
+
reconciler.flushPassiveEffects()
|
|
140
|
+
await new Promise<void>((resolve, reject) => {
|
|
141
|
+
if (this.barriers.size >= 128) {
|
|
142
|
+
reject(new NativeStateError('limit_exceeded', 'React commit barrier queue is full'))
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const barrier = createCommitBarrier({
|
|
146
|
+
release: () => this.barriers.delete(barrier),
|
|
147
|
+
resolve,
|
|
148
|
+
reject,
|
|
149
|
+
})
|
|
150
|
+
this.barriers.add(barrier)
|
|
151
|
+
try {
|
|
152
|
+
reconciler.updateContainer(this.element, this.fiber, null, barrier.complete)
|
|
153
|
+
} catch (cause) {
|
|
154
|
+
// A rejected submission owns no future commit. Release its capacity and
|
|
155
|
+
// callback bindings now instead of poisoning all later flushes.
|
|
156
|
+
barrier.reject(cause)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
// Native failure cleanup may already have entered disposal. Join it before
|
|
160
|
+
// checking local lifetime so the triggering operation preserves its cause.
|
|
161
|
+
await this.native.flush()
|
|
162
|
+
this.assertActive()
|
|
163
|
+
// Effect code may schedule future work; its mere execution is not evidence
|
|
164
|
+
// that another render is pending. Re-enqueuing barriers until this returns
|
|
165
|
+
// false would itself keep producing commits indefinitely.
|
|
166
|
+
reconciler.flushPassiveEffects()
|
|
167
|
+
await this.native.flush()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
dispose(): Promise<void> {
|
|
171
|
+
if (!this.disposal) {
|
|
172
|
+
this.phase = 'disposing'
|
|
173
|
+
// A listener/layout effect may request disposal. Never reenter Fiber's
|
|
174
|
+
// render or commit from the current application callback stack.
|
|
175
|
+
this.disposal = reconciler.isAlreadyRendering()
|
|
176
|
+
? Promise.resolve().then(() => this.native.dispose())
|
|
177
|
+
: this.native.dispose()
|
|
178
|
+
}
|
|
179
|
+
return this.disposal
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Mount React into one independently disposable shared native subtree. */
|
|
184
|
+
export function render(
|
|
185
|
+
children: ReactNode,
|
|
186
|
+
window: NativeWindow,
|
|
187
|
+
options: ReactRenderOptions = {}
|
|
188
|
+
): ReactRenderRoot {
|
|
189
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
190
|
+
throw new TypeError('React render options must be an object')
|
|
191
|
+
for (const name of Object.keys(options))
|
|
192
|
+
if (name !== 'onRecoverableError' && name !== 'style')
|
|
193
|
+
throw new TypeError('Unknown React render option: ' + name)
|
|
194
|
+
const reporter = options.onRecoverableError
|
|
195
|
+
if (reporter !== undefined && typeof reporter !== 'function')
|
|
196
|
+
throw new TypeError('onRecoverableError must be a function')
|
|
197
|
+
return new Root(children, window, reporter ?? console.error, options.style)
|
|
198
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { UnsupportedFeatureError, type Listener, type NativeUIEvent } from '@bun-native/core'
|
|
2
|
+
import {
|
|
3
|
+
createElement,
|
|
4
|
+
createTextNode,
|
|
5
|
+
insertNode,
|
|
6
|
+
isEventProperty,
|
|
7
|
+
removeNode,
|
|
8
|
+
replaceText,
|
|
9
|
+
setProperty,
|
|
10
|
+
type ElementTag,
|
|
11
|
+
type Node,
|
|
12
|
+
type PropertyName,
|
|
13
|
+
type RendererNode,
|
|
14
|
+
} from '@bun-native/renderer'
|
|
15
|
+
import { createContext } from 'react'
|
|
16
|
+
import Reconciler from 'react-reconciler'
|
|
17
|
+
import { DefaultEventPriority, NoEventPriority } from 'react-reconciler/constants'
|
|
18
|
+
|
|
19
|
+
type Props = Readonly<Record<string, unknown>>
|
|
20
|
+
type HostContext = Readonly<Record<string, never>>
|
|
21
|
+
type Timer = ReturnType<typeof setTimeout>
|
|
22
|
+
|
|
23
|
+
// Unsupported host surfaces use never; no React Fiber fields are inspected.
|
|
24
|
+
type Config = Reconciler.HostConfig<
|
|
25
|
+
string,
|
|
26
|
+
Props,
|
|
27
|
+
Node,
|
|
28
|
+
Node,
|
|
29
|
+
Node,
|
|
30
|
+
never,
|
|
31
|
+
never,
|
|
32
|
+
never,
|
|
33
|
+
never,
|
|
34
|
+
RendererNode,
|
|
35
|
+
HostContext,
|
|
36
|
+
never,
|
|
37
|
+
Timer,
|
|
38
|
+
null,
|
|
39
|
+
null,
|
|
40
|
+
null,
|
|
41
|
+
never,
|
|
42
|
+
never,
|
|
43
|
+
never,
|
|
44
|
+
never
|
|
45
|
+
>
|
|
46
|
+
|
|
47
|
+
const context: HostContext = Object.freeze({})
|
|
48
|
+
let updatePriority: Reconciler.EventPriority = NoEventPriority
|
|
49
|
+
let activeEvent: { type: string; observedAt: number } | undefined
|
|
50
|
+
|
|
51
|
+
function unsupported(feature: string): never {
|
|
52
|
+
throw new UnsupportedFeatureError('react-' + feature)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// React requires these hooks even when a host has no corresponding resource.
|
|
56
|
+
// Reuse their identical semantics instead of maintaining separate empty bodies.
|
|
57
|
+
const noOp = (): void => {}
|
|
58
|
+
const noInstance = () => null
|
|
59
|
+
const returnFalse = () => false
|
|
60
|
+
const hostContext = () => context
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run a native callback as a discrete React update. The shared renderer keeps
|
|
64
|
+
* one registration per node/type/phase and calls the latest committed handler,
|
|
65
|
+
* so inline closures replaced on ordinary rerenders keep queued events.
|
|
66
|
+
*/
|
|
67
|
+
export function dispatchDiscrete(
|
|
68
|
+
listener: Listener<NativeUIEvent>,
|
|
69
|
+
event: NativeUIEvent
|
|
70
|
+
): void | Promise<void> {
|
|
71
|
+
const previous = activeEvent
|
|
72
|
+
activeEvent = { type: event.type, observedAt: performance.now() }
|
|
73
|
+
try {
|
|
74
|
+
return reconciler.discreteUpdates(listener, event, undefined, undefined, undefined)
|
|
75
|
+
} finally {
|
|
76
|
+
activeEvent = previous
|
|
77
|
+
reconciler.flushSyncWork()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function updateProperty(node: Node, name: string, value: unknown): void {
|
|
82
|
+
if (isEventProperty(name) && value !== undefined && typeof value !== 'function')
|
|
83
|
+
throw new TypeError('Native event listeners must be functions')
|
|
84
|
+
// React props arrive as strings; the renderer rejects unknown names at runtime.
|
|
85
|
+
setProperty(node, name as PropertyName, value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const reserved = (name: string) => name === 'children' || name === 'ref' || name === 'key'
|
|
89
|
+
|
|
90
|
+
type StyleObject = Readonly<Record<string, unknown>>
|
|
91
|
+
const isStyleObject = (value: unknown): value is StyleObject =>
|
|
92
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Inline `style={{ ... }}` is a new object on every render. The renderer keeps
|
|
96
|
+
* a shallow copy, so an object with the same own keys, in the same order, and
|
|
97
|
+
* the same values commits the identical element and sends no native set;
|
|
98
|
+
* setting it anyway would only recapture the node and its ancestors.
|
|
99
|
+
*/
|
|
100
|
+
function sameStyle(previous: unknown, next: unknown): boolean {
|
|
101
|
+
if (!isStyleObject(previous) || !isStyleObject(next)) return false
|
|
102
|
+
const names = Object.keys(previous)
|
|
103
|
+
const nextNames = Object.keys(next)
|
|
104
|
+
if (names.length !== nextNames.length) return false
|
|
105
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
106
|
+
const name = names[index]!
|
|
107
|
+
if (name !== nextNames[index] || !Object.is(previous[name], next[name])) return false
|
|
108
|
+
}
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// React commits every rerendered host fiber, so this runs once per row of a
|
|
113
|
+
// rerendered list: compare in place rather than allocating a merged key set.
|
|
114
|
+
function updateProperties(node: Node, previous: Props, next: Props): void {
|
|
115
|
+
for (const name of Object.keys(previous))
|
|
116
|
+
if (!reserved(name) && !Object.hasOwn(next, name)) updateProperty(node, name, undefined)
|
|
117
|
+
for (const name of Object.keys(next)) {
|
|
118
|
+
if (reserved(name)) continue
|
|
119
|
+
const value = next[name]
|
|
120
|
+
if (Object.hasOwn(previous, name)) {
|
|
121
|
+
const old = previous[name]
|
|
122
|
+
if (Object.is(old, value) || (name === 'style' && sameStyle(old, value))) continue
|
|
123
|
+
}
|
|
124
|
+
updateProperty(node, name, value)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createProperties(node: Node, props: Props): void {
|
|
129
|
+
for (const name of Object.keys(props))
|
|
130
|
+
if (!reserved(name)) updateProperty(node, name, props[name])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const host: Config = {
|
|
134
|
+
supportsMutation: true,
|
|
135
|
+
supportsPersistence: false,
|
|
136
|
+
supportsHydration: false,
|
|
137
|
+
supportsResources: false,
|
|
138
|
+
supportsSingletons: false,
|
|
139
|
+
supportsTestSelectors: false,
|
|
140
|
+
isPrimaryRenderer: true,
|
|
141
|
+
rendererVersion: '0.0.0',
|
|
142
|
+
rendererPackageName: '@bun-native/react',
|
|
143
|
+
extraDevToolsConfig: null,
|
|
144
|
+
bindToConsole(method, args) {
|
|
145
|
+
const log = method === 'warn' ? console.warn : method === 'error' ? console.error : console.log
|
|
146
|
+
return log.bind(console, ...args)
|
|
147
|
+
},
|
|
148
|
+
createInstance(type, props) {
|
|
149
|
+
// The host config types an element as any string; the renderer rejects unknown tags.
|
|
150
|
+
const node = createElement(type as ElementTag)
|
|
151
|
+
createProperties(node, props)
|
|
152
|
+
return node
|
|
153
|
+
},
|
|
154
|
+
createTextInstance: createTextNode,
|
|
155
|
+
appendInitialChild: insertNode,
|
|
156
|
+
finalizeInitialChildren: returnFalse,
|
|
157
|
+
shouldSetTextContent: returnFalse,
|
|
158
|
+
getRootHostContext: hostContext,
|
|
159
|
+
getChildHostContext: hostContext,
|
|
160
|
+
getPublicInstance: (node) => node,
|
|
161
|
+
prepareForCommit: noInstance,
|
|
162
|
+
resetAfterCommit: noOp,
|
|
163
|
+
preparePortalMount: unsupported.bind(null, 'portals'),
|
|
164
|
+
scheduleTimeout: setTimeout,
|
|
165
|
+
cancelTimeout: clearTimeout,
|
|
166
|
+
noTimeout: null,
|
|
167
|
+
supportsMicrotasks: true,
|
|
168
|
+
scheduleMicrotask: queueMicrotask,
|
|
169
|
+
getInstanceFromNode: noInstance,
|
|
170
|
+
beforeActiveInstanceBlur: noOp,
|
|
171
|
+
afterActiveInstanceBlur: noOp,
|
|
172
|
+
prepareScopeUpdate: unsupported.bind(null, 'scope'),
|
|
173
|
+
getInstanceFromScope: noInstance,
|
|
174
|
+
detachDeletedInstance(node) {
|
|
175
|
+
node.children.length = 0
|
|
176
|
+
node.parent = undefined
|
|
177
|
+
for (const name of Object.keys(node.properties)) {
|
|
178
|
+
if (isEventProperty(name)) delete node.properties[name]
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
appendChild: insertNode,
|
|
182
|
+
appendChildToContainer: insertNode,
|
|
183
|
+
insertBefore: insertNode,
|
|
184
|
+
insertInContainerBefore: insertNode,
|
|
185
|
+
removeChild: removeNode,
|
|
186
|
+
removeChildFromContainer: removeNode,
|
|
187
|
+
commitTextUpdate: (node, _previous, next) => replaceText(node, next),
|
|
188
|
+
commitUpdate: (node, _type, previous, next) => updateProperties(node, previous, next),
|
|
189
|
+
hideInstance: unsupported.bind(null, 'hidden-subtrees'),
|
|
190
|
+
hideTextInstance: unsupported.bind(null, 'hidden-subtrees'),
|
|
191
|
+
unhideInstance: unsupported.bind(null, 'hidden-subtrees'),
|
|
192
|
+
unhideTextInstance: unsupported.bind(null, 'hidden-subtrees'),
|
|
193
|
+
clearContainer(container) {
|
|
194
|
+
// Removing the last child pops it; removing the first would leave a hole
|
|
195
|
+
// that the next `children` read compacts, making the loop quadratic.
|
|
196
|
+
const children = container.children
|
|
197
|
+
while (children.length) removeNode(container, children[children.length - 1]!)
|
|
198
|
+
},
|
|
199
|
+
NotPendingTransition: null,
|
|
200
|
+
HostTransitionContext: createContext(null),
|
|
201
|
+
setCurrentUpdatePriority(priority) {
|
|
202
|
+
updatePriority = priority
|
|
203
|
+
},
|
|
204
|
+
getCurrentUpdatePriority: () => updatePriority,
|
|
205
|
+
resolveUpdatePriority: () => updatePriority || DefaultEventPriority,
|
|
206
|
+
resetFormInstance: unsupported.bind(null, 'forms'),
|
|
207
|
+
requestPostPaintCallback: unsupported.bind(null, 'presentation-timing'),
|
|
208
|
+
shouldAttemptEagerTransition: returnFalse,
|
|
209
|
+
trackSchedulerEvent: noOp,
|
|
210
|
+
resolveEventType: () => activeEvent?.type ?? null,
|
|
211
|
+
resolveEventTimeStamp: () => activeEvent?.observedAt ?? -1,
|
|
212
|
+
maySuspendCommit: returnFalse,
|
|
213
|
+
maySuspendCommitOnUpdate: returnFalse,
|
|
214
|
+
maySuspendCommitInSyncRender: returnFalse,
|
|
215
|
+
preloadInstance: () => true,
|
|
216
|
+
startSuspendingCommit: noInstance,
|
|
217
|
+
suspendInstance: unsupported.bind(null, 'suspended-host-commit'),
|
|
218
|
+
// React 19.3 calls this for ordinary transition lanes too. This host has no
|
|
219
|
+
// active view-transition animation whose completion could delay a commit.
|
|
220
|
+
suspendOnActiveViewTransition: noOp,
|
|
221
|
+
createViewTransitionInstance: unsupported.bind(null, 'view-transitions'),
|
|
222
|
+
applyViewTransitionName: unsupported.bind(null, 'view-transitions'),
|
|
223
|
+
startViewTransition: unsupported.bind(null, 'view-transitions'),
|
|
224
|
+
waitForCommitToBeReady: noInstance,
|
|
225
|
+
getSuspendedCommitReason: noInstance,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const reconciler = Reconciler(host)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ErrorInfo } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Owned callbacks must be revocable even while React retains an opaque root. */
|
|
4
|
+
export interface RootErrorOwner {
|
|
5
|
+
terminate: ((error: unknown) => void) | undefined
|
|
6
|
+
report: ((error: unknown, info: Readonly<ErrorInfo>) => void) | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Created outside the Root constructor so callback scopes cannot capture its arguments. */
|
|
10
|
+
export function createRootErrors(owner: RootErrorOwner) {
|
|
11
|
+
return {
|
|
12
|
+
uncaught(error: unknown): void {
|
|
13
|
+
owner.terminate?.(error)
|
|
14
|
+
},
|
|
15
|
+
/**
|
|
16
|
+
* An error a boundary handled is logged like React DOM's default instead
|
|
17
|
+
* of disappearing; it does not end the root.
|
|
18
|
+
*/
|
|
19
|
+
caught(error: unknown): void {
|
|
20
|
+
if (owner.terminate) console.error(error)
|
|
21
|
+
},
|
|
22
|
+
/** React's default transition indicator: native views show none. */
|
|
23
|
+
transitionIndicator(): void {},
|
|
24
|
+
recoverable(error: unknown, info: ErrorInfo): void {
|
|
25
|
+
const report = owner.report
|
|
26
|
+
if (!report) return
|
|
27
|
+
try {
|
|
28
|
+
report(error, Object.freeze({ ...info }))
|
|
29
|
+
} catch (reporterError) {
|
|
30
|
+
owner.terminate?.(reporterError)
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
release(): void {
|
|
34
|
+
owner.terminate = undefined
|
|
35
|
+
owner.report = undefined
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|