embedded-react 0.1.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.
@@ -0,0 +1,196 @@
1
+ /*
2
+ * Copyright 2026 Cory Lamming
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // react-reconciler host config: translates React's mutation API into NativeUI.* calls.
18
+ //
19
+ // Instances ARE the integer node handles returned by NativeUI.createNode(). We keep no JS-side
20
+ // wrapper objects — the engine owns the scene graph, the handle is the identity.
21
+ import { DefaultEventPriority } from 'react-reconciler/constants';
22
+ import { NativeUI } from './native-ui.js';
23
+ import { buildProps, buildTextSpans, isEventProp, isTextContent } from './props.js';
24
+ import { flattenSvg } from './embedded-react/svg-ops.js';
25
+ import { splitAnimatedStyle } from './embedded-react/split-style.js';
26
+
27
+ /**
28
+ * Applies a node's resolved props, binding any Animated.Value found in its `style` to the matching
29
+ * node prop (native driver). This makes animated styles work on ANY host element — `<Pressable
30
+ * style={{ transform: [{ scale: v }] }}>` binds without an Animated.* wrapper — which is what the
31
+ * Flow B AOT compiler does too, so the two render paths stay in parity. An Animated.* wrapper has
32
+ * already stripped its bindings into a ref, so splitAnimatedStyle finds none here (no double bind).
33
+ */
34
+ function applyProps(type, handle, props) {
35
+ const { staticStyle, bindings } = splitAnimatedStyle(props.style);
36
+ NativeUI.setProps(handle, buildProps(type, bindings.length ? { ...props, style: staticStyle } : props));
37
+ for (const b of bindings) b.value.__bind(handle, b.prop);
38
+ }
39
+
40
+ /**
41
+ * Applies inline-styled text spans for a <Text> node (no-op for other types). buildTextSpans returns
42
+ * [] for uniform text, which reverts the node to plain-text rendering — so this also clears stale
43
+ * spans when a Text changes from styled-runs to a single style across renders.
44
+ */
45
+ function applyTextSpans(type, handle, props) {
46
+ if (type === 'Text') NativeUI.setTextSpans(handle, buildTextSpans(props));
47
+ }
48
+
49
+ /**
50
+ * Compiles an <Svg>'s declarative children (Path/Circle/G/...) into the node's vector op-tape. Like
51
+ * text spans, the Svg owns its subtree — React does not mount the shape children (see
52
+ * shouldSetTextContent), so we flatten props.children here on create and every update.
53
+ */
54
+ function applyVectorOps(type, handle, props) {
55
+ if (type !== 'Svg') return;
56
+ const { ops, paints } = flattenSvg(props);
57
+ NativeUI.setVectorOps(handle, ops, paints);
58
+ }
59
+
60
+ /**
61
+ * Registers/clears on* event handlers. A handler present in old but not new props is cleared.
62
+ */
63
+ function applyEvents(handle, prevProps, nextProps) {
64
+ if (prevProps) {
65
+ for (const key in prevProps) {
66
+ if (isEventProp(key, prevProps[key]) && !(nextProps && isEventProp(key, nextProps[key]))) {
67
+ NativeUI.setEvent(handle, key, null);
68
+ }
69
+ }
70
+ }
71
+ for (const key in nextProps) {
72
+ if (isEventProp(key, nextProps[key])) {
73
+ NativeUI.setEvent(handle, key, nextProps[key]);
74
+ }
75
+ }
76
+ }
77
+
78
+ export const hostConfig = {
79
+ supportsMutation: true,
80
+ supportsPersistence: false,
81
+ supportsHydration: false,
82
+ isPrimaryRenderer: true,
83
+ noTimeout: -1,
84
+ warnsIfNotActing: false,
85
+
86
+ // --- Context (we carry none) ---
87
+ getRootHostContext() {
88
+ return {};
89
+ },
90
+ getChildHostContext(parentContext) {
91
+ return parentContext;
92
+ },
93
+ getPublicInstance(instance) {
94
+ return instance;
95
+ },
96
+
97
+ // --- Commit lifecycle ---
98
+ prepareForCommit() {
99
+ return null;
100
+ },
101
+ resetAfterCommit() {
102
+ NativeUI.commit();
103
+ },
104
+
105
+ // --- Creation ---
106
+ createInstance(type, props) {
107
+ const handle = NativeUI.createNode(type);
108
+ applyProps(type, handle, props);
109
+ applyTextSpans(type, handle, props);
110
+ applyVectorOps(type, handle, props);
111
+ applyEvents(handle, null, props);
112
+ return handle;
113
+ },
114
+ createTextInstance(text) {
115
+ // Raw text is only legal inside <Text> (handled via shouldSetTextContent). This fallback
116
+ // wraps stray text in a Text node so it still renders rather than crashing.
117
+ const handle = NativeUI.createNode('Text');
118
+ NativeUI.setProps(handle, { text: String(text) });
119
+ return handle;
120
+ },
121
+ appendInitialChild(parent, child) {
122
+ NativeUI.appendChild(parent, child);
123
+ },
124
+ finalizeInitialChildren() {
125
+ return false;
126
+ },
127
+ shouldSetTextContent(type, props) {
128
+ // Own the whole subtree for any flattenable <Text> (strings, interpolation, nested <Text>): React
129
+ // skips mounting children and we render them via the node's text + spans. Non-flattenable content
130
+ // (e.g. a <View> inside <Text>) returns false, falling back to mounted child instances.
131
+ // <Svg> also owns its subtree: the shape children are flattened into the vector op-tape
132
+ // (applyVectorOps), never mounted as host nodes.
133
+ return type === 'Svg' || (type === 'Text' && isTextContent(props.children));
134
+ },
135
+
136
+ // --- Mutation ---
137
+ appendChild(parent, child) {
138
+ NativeUI.appendChild(parent, child);
139
+ },
140
+ appendChildToContainer(container, child) {
141
+ NativeUI.appendChild(container, child);
142
+ },
143
+ insertBefore(parent, child, beforeChild) {
144
+ NativeUI.insertBefore(parent, child, beforeChild);
145
+ },
146
+ insertInContainerBefore(container, child, beforeChild) {
147
+ NativeUI.insertBefore(container, child, beforeChild);
148
+ },
149
+ removeChild(parent, child) {
150
+ NativeUI.removeChild(parent, child);
151
+ NativeUI.destroyNode(child);
152
+ },
153
+ removeChildFromContainer(container, child) {
154
+ NativeUI.removeChild(container, child);
155
+ NativeUI.destroyNode(child);
156
+ },
157
+ clearContainer() {
158
+ // Children are removed individually via removeChildFromContainer.
159
+ },
160
+ prepareUpdate() {
161
+ // Always re-apply: NativeUI.setProps is fully declarative, so a non-null payload is enough.
162
+ return true;
163
+ },
164
+ commitUpdate(instance, _payload, type, prevProps, nextProps) {
165
+ applyProps(type, instance, nextProps);
166
+ applyTextSpans(type, instance, nextProps);
167
+ applyVectorOps(type, instance, nextProps);
168
+ applyEvents(instance, prevProps, nextProps);
169
+ },
170
+ commitTextUpdate(textInstance, _oldText, newText) {
171
+ NativeUI.setProps(textInstance, { text: String(newText) });
172
+ },
173
+
174
+ // --- Misc required hooks (no-ops for our renderer) ---
175
+ detachDeletedInstance() {},
176
+ getCurrentEventPriority() {
177
+ return DefaultEventPriority;
178
+ },
179
+ getInstanceFromNode() {
180
+ return null;
181
+ },
182
+ beforeActiveInstanceBlur() {},
183
+ afterActiveInstanceBlur() {},
184
+ prepareScopeUpdate() {},
185
+ getInstanceFromScope() {
186
+ return null;
187
+ },
188
+
189
+ // --- Scheduling ---
190
+ scheduleTimeout: (fn, delay) => setTimeout(fn, delay),
191
+ cancelTimeout: (id) => clearTimeout(id),
192
+ supportsMicrotasks: true,
193
+ scheduleMicrotask:
194
+ typeof queueMicrotask === 'function' ? queueMicrotask : (fn) => Promise.resolve().then(fn),
195
+ now: () => NativeUI.now(),
196
+ };
@@ -0,0 +1,24 @@
1
+ /*
2
+ * Copyright 2026 Cory Lamming
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // The QuickJS C bridge (native_ui_bridge.c) installs a global `NativeUI` object before the
18
+ // bundle runs. Re-export it so the rest of the JS imports it cleanly instead of touching the
19
+ // global directly.
20
+ export const NativeUI = globalThis.NativeUI;
21
+
22
+ if (!NativeUI) {
23
+ throw new Error('NativeUI global is missing — the QuickJS bridge must be installed before the bundle runs.');
24
+ }
package/src/props.js ADDED
@@ -0,0 +1,183 @@
1
+ /*
2
+ * Copyright 2026 Cory Lamming
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // Pure prop-marshalling helpers. No engine / NativeUI dependency, so these are unit-testable in
18
+ // plain Node (see src/__tests__/props.unit.test.js). The host config wires them to NativeUI.
19
+
20
+ // Top-level props NativeUI understands directly (not part of `style`). Everything else style-ish is
21
+ // expected inside props.style. Event handlers (on*) are routed separately via setEvent.
22
+ export const PASSTHROUGH = [
23
+ 'numberOfLines',
24
+ 'ellipsizeMode',
25
+ 'value',
26
+ 'placeholder',
27
+ 'placeholderTextColor',
28
+ 'editable',
29
+ 'animating',
30
+ 'visible',
31
+ 'resizeMode',
32
+ 'tintColor',
33
+ 'imageName',
34
+ ];
35
+
36
+ /**
37
+ * Flattens RN-style `style` (object or nested array) into the `out` object.
38
+ */
39
+ export function flattenStyle(style, out) {
40
+ if (!style) return;
41
+ if (Array.isArray(style)) {
42
+ for (const s of style) flattenStyle(s, out);
43
+ } else {
44
+ Object.assign(out, style);
45
+ }
46
+ }
47
+
48
+ /** Flattens a style (object/array) into a fresh plain object. */
49
+ export function flattenStyleObj(style) {
50
+ const out = {};
51
+ flattenStyle(style, out);
52
+ return out;
53
+ }
54
+
55
+ // Style fields the engine honors per inline text span (everything else inherits from the node).
56
+ const SPAN_STYLE_KEYS = ['color', 'fontSize', 'fontWeight', 'fontStyle', 'textDecorationLine', 'letterSpacing'];
57
+
58
+ /**
59
+ * True when a <Text>'s children can be flattened inline — i.e. every leaf is a string/number or a
60
+ * nested <Text> element. A non-Text element (View, a component) makes it false, so the reconciler
61
+ * falls back to mounting children as separate instances.
62
+ */
63
+ export function isTextContent(children) {
64
+ if (children == null || children === false || children === true) return true;
65
+ if (typeof children === 'string' || typeof children === 'number') return true;
66
+ if (Array.isArray(children)) return children.every(isTextContent);
67
+ // A React element: only nested <Text> participates in inline flattening (type is the 'Text' tag).
68
+ if (children && children.props !== undefined) return children.type === 'Text';
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * Walks a <Text>'s children into an ordered list of { text, style } segments. Primitive siblings
74
+ * share the parent's (base) style object by reference; a nested <Text> with its own style produces
75
+ * a new merged style object. Adjacent segments with the same style reference are coalesced — so
76
+ * plain interpolation like `Hi {name}` collapses to a single segment (no spans needed).
77
+ */
78
+ export function flattenTextChildren(children, baseStyle) {
79
+ const segments = [];
80
+ const push = (text, style) => {
81
+ if (text === '') return;
82
+ const last = segments[segments.length - 1];
83
+ if (last && last.style === style) last.text += text;
84
+ else segments.push({ text, style });
85
+ };
86
+ const walk = (node, style) => {
87
+ if (node == null || node === false || node === true) return;
88
+ if (typeof node === 'string' || typeof node === 'number') {
89
+ push(String(node), style);
90
+ return;
91
+ }
92
+ if (Array.isArray(node)) {
93
+ for (const c of node) walk(c, style);
94
+ return;
95
+ }
96
+ if (node && node.props !== undefined) {
97
+ // Nested <Text>: merge its style over the inherited one (new object only when it has style,
98
+ // so an unstyled nested <Text> keeps the same reference and still coalesces with siblings).
99
+ const merged = node.props.style ? Object.assign({}, style, flattenStyleObj(node.props.style)) : style;
100
+ walk(node.props.children, merged);
101
+ }
102
+ };
103
+ walk(children, baseStyle);
104
+ return segments;
105
+ }
106
+
107
+ /**
108
+ * Builds the inline-span array for a <Text> (for NativeUI.setTextSpans). Returns [] when the content
109
+ * is uniformly the base style (plain text — no spans needed); otherwise one span per styled segment,
110
+ * each carrying only the span-relevant style keys (the engine inherits the rest from the node).
111
+ */
112
+ export function buildTextSpans(props) {
113
+ if (!isTextContent(props.children)) return [];
114
+ const baseStyle = flattenStyleObj(props.style);
115
+ const segments = flattenTextChildren(props.children, baseStyle);
116
+ if (!segments.some((s) => s.style !== baseStyle)) return [];
117
+ return segments.map((s) => {
118
+ const span = { text: s.text };
119
+ for (const k of SPAN_STYLE_KEYS) {
120
+ if (s.style && s.style[k] !== undefined) span[k] = s.style[k];
121
+ }
122
+ return span;
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Builds the flat prop bag NativeUI.setProps expects: resolved style + passthrough props + text.
128
+ */
129
+ /**
130
+ * Resolves an <Image source> to the engine asset name (the string registered via er_image_load and
131
+ * stored on the node as imageName). Accepts a bare name string — which is also what the bundler's
132
+ * image plugin turns `import logo from './logo.png'` into (the file's basename) — or an RN-style
133
+ * { uri } object. Returns null for anything unresolvable (e.g., a numeric require() id).
134
+ */
135
+ export function resolveImageSource(source) {
136
+ if (typeof source === 'string') return source;
137
+ if (source && typeof source === 'object' && typeof source.uri === 'string') return source.uri;
138
+ return null;
139
+ }
140
+
141
+ export function buildProps(type, props) {
142
+ const flat = {};
143
+ flattenStyle(props.style, flat);
144
+ for (const k of PASSTHROUGH) {
145
+ if (props[k] !== undefined) flat[k] = props[k];
146
+ }
147
+ // <Svg> takes width/height as DIRECT props (the react-native-svg convention) — the engine sizes the
148
+ // vector node from them. Fold them into the resolved style (an explicit style width/height still wins)
149
+ // so Flow A matches the Flow B AOT, which reads svg.props.width/height directly (compile.mjs emitSvgBox).
150
+ if (type === 'Svg') {
151
+ if (flat.width === undefined && props.width !== undefined) flat.width = props.width;
152
+ if (flat.height === undefined && props.height !== undefined) flat.height = props.height;
153
+ }
154
+ // <Image source={...}> resolves to the engine asset name unless imageName was set explicitly.
155
+ if (flat.imageName === undefined && props.source != null) {
156
+ const name = resolveImageSource(props.source);
157
+ if (name != null) flat.imageName = name;
158
+ }
159
+ // <Text> content: flatten string/number/nested-<Text> children into the node's full-text string.
160
+ // The engine renders this when no spans are set; with spans (buildTextSpans) it carries the same
161
+ // text for the plain-text fallback. Non-flattenable children fall back to mounted child instances.
162
+ if (type === 'Text' && isTextContent(props.children)) {
163
+ const base = flattenStyleObj(props.style);
164
+ flat.text = flattenTextChildren(props.children, base)
165
+ .map((s) => s.text)
166
+ .join('');
167
+ }
168
+ return flat;
169
+ }
170
+
171
+ /**
172
+ * True for an `onXxx` prop whose value is a function (an event handler to route via setEvent).
173
+ */
174
+ export function isEventProp(key, value) {
175
+ return (
176
+ key.length > 2 &&
177
+ key[0] === 'o' &&
178
+ key[1] === 'n' &&
179
+ key[2] >= 'A' &&
180
+ key[2] <= 'Z' &&
181
+ typeof value === 'function'
182
+ );
183
+ }
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Copyright 2026 Cory Lamming
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // Public renderer API: createRoot(...).render(<App/>).
18
+ //
19
+ // The container is a real engine View node set as the scene root; React children mount into it.
20
+ // We use LegacyRoot (synchronous) mode so the initial render flushes without depending on the
21
+ // async scheduler/timers — the host's frame loop then paints whatever the tree became.
22
+ import Reconciler from 'react-reconciler';
23
+ import { hostConfig } from './host-config.js';
24
+ import { NativeUI } from './native-ui.js';
25
+
26
+ const reconciler = Reconciler(hostConfig);
27
+
28
+ const LegacyRoot = 0;
29
+
30
+ /**
31
+ * Creates a root bound to a screen-sized container node.
32
+ *
33
+ * @param {object} containerProps Props for the container View (e.g. width/height/backgroundColor).
34
+ * @returns {{ render: (element: any) => void }}
35
+ */
36
+ export function createRoot(containerProps) {
37
+ const container = NativeUI.createNode('View');
38
+ NativeUI.setProps(container, containerProps || {});
39
+ NativeUI.setRoot(container);
40
+
41
+ const fiberRoot = reconciler.createContainer(
42
+ container, // containerInfo — our root node handle
43
+ LegacyRoot,
44
+ null, // hydration callbacks
45
+ false, // isStrictMode
46
+ null, // concurrentUpdatesByDefaultOverride
47
+ '', // identifierPrefix
48
+ (error) => console.error('react recoverable error:', error),
49
+ null, // transitionCallbacks
50
+ );
51
+
52
+ return {
53
+ render(element) {
54
+ reconciler.updateContainer(element, fiberRoot, null, null);
55
+ },
56
+ };
57
+ }