embedded-react 0.3.0 → 0.4.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.
@@ -18,11 +18,21 @@
18
18
  //
19
19
  // Instances ARE the integer node handles returned by NativeUI.createNode(). We keep no JS-side
20
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';
21
+ import {DefaultEventPriority} from 'react-reconciler/constants';
22
+ import {NativeUI} from './native-ui.js';
23
+ import {
24
+ buildProps,
25
+ buildTextSpans,
26
+ isEventProp,
27
+ isTextContent,
28
+ } from './props.js';
29
+ import {
30
+ flattenSvg,
31
+ warnVectorCaps,
32
+ scaleVectorArtifact,
33
+ encodeVectorGradients,
34
+ } from './embedded-react/svg-ops.js';
35
+ import {splitAnimatedStyle} from './embedded-react/split-style.js';
26
36
 
27
37
  /**
28
38
  * Applies a node's resolved props, binding any Animated.Value found in its `style` to the matching
@@ -32,8 +42,11 @@ import { splitAnimatedStyle } from './embedded-react/split-style.js';
32
42
  * already stripped its bindings into a ref, so splitAnimatedStyle finds none here (no double bind).
33
43
  */
34
44
  function applyProps(type, handle, props) {
35
- const { staticStyle, bindings } = splitAnimatedStyle(props.style);
36
- NativeUI.setProps(handle, buildProps(type, bindings.length ? { ...props, style: staticStyle } : props));
45
+ const {staticStyle, bindings} = splitAnimatedStyle(props.style);
46
+ NativeUI.setProps(
47
+ handle,
48
+ buildProps(type, bindings.length ? {...props, style: staticStyle} : props),
49
+ );
37
50
  for (const b of bindings) b.value.__bind(handle, b.prop);
38
51
  }
39
52
 
@@ -46,15 +59,100 @@ function applyTextSpans(type, handle, props) {
46
59
  if (type === 'Text') NativeUI.setTextSpans(handle, buildTextSpans(props));
47
60
  }
48
61
 
62
+ /** Resolves an <Svg>'s render-box dimension from style/props, falling back to the source's intrinsic size. */
63
+ function svgBoxSize(props, dim, intrinsic) {
64
+ const s =
65
+ props.style && typeof props.style[dim] === 'number'
66
+ ? props.style[dim]
67
+ : undefined;
68
+ const p = typeof props[dim] === 'number' ? props[dim] : undefined;
69
+ return s ?? p ?? intrinsic;
70
+ }
71
+
72
+ /**
73
+ * A `<Svg source={imported}>` whose imported .svg fell back to a RASTER image at build time (the SVG used
74
+ * features the vector baker can't represent). Returns the artifact, or null for a vector/declarative
75
+ * Svg. Such a Svg is rendered as an IMAGE node, not a vector node — `props.source.kind` is the discriminator
76
+ * (an imported artifact's kind is fixed at build time, so it never flips for a given element).
77
+ */
78
+ function rasterSvgArtifact(type, props) {
79
+ const src = props && props.source;
80
+ return type === 'Svg' && src && src.kind === 'raster' ? src : null;
81
+ }
82
+
83
+ /** Maps a raster `<Svg source>` to equivalent `<Image>` props: the baked asset name + the resolved box size. */
84
+ function rasterImageProps(props, art) {
85
+ const width = svgBoxSize(props, 'width', art.width);
86
+ const height = svgBoxSize(props, 'height', art.height);
87
+ return {
88
+ ...props,
89
+ source: art.name,
90
+ style: {...(props.style || {}), width, height},
91
+ };
92
+ }
93
+
49
94
  /**
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.
95
+ * Sets an <Svg>'s vector op-tape. Two sources, source prop wins:
96
+ * <Svg source={imported}> — an imported .svg's baked artifact ({kind:'vector', ops, paints, width,
97
+ * height}); we scale its op-tape from intrinsic px to the node's box.
98
+ * <Svg><Path/>...</Svg> — declarative children flattened here (the Svg owns its subtree; React does
99
+ * not mount the shape children, so we compile them on create + every update).
53
100
  */
54
101
  function applyVectorOps(type, handle, props) {
55
102
  if (type !== 'Svg') return;
56
- const { ops, paints } = flattenSvg(props);
57
- NativeUI.setVectorOps(handle, ops, paints);
103
+ let ops;
104
+ let paints;
105
+ let gradients;
106
+ const src = props.source;
107
+ if (src && src.kind === 'vector' && Array.isArray(src.ops)) {
108
+ ({ops, paints, gradients} = scaleVectorArtifact(
109
+ src,
110
+ svgBoxSize(props, 'width', src.width),
111
+ svgBoxSize(props, 'height', src.height),
112
+ ));
113
+ } else {
114
+ ({ops, paints} = flattenSvg(props));
115
+ }
116
+ warnVectorCaps(
117
+ ops.length,
118
+ paints.length,
119
+ NativeUI.maxVectorOps,
120
+ NativeUI.maxVectorPaints,
121
+ gradients ? gradients.length : 0,
122
+ NativeUI.maxVectorGrads,
123
+ );
124
+ NativeUI.setVectorOps(
125
+ handle,
126
+ ops,
127
+ paints,
128
+ gradients && gradients.length
129
+ ? encodeVectorGradients(gradients)
130
+ : undefined,
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Decides whether a committed <Svg> update actually needs its op-tape re-uploaded.
136
+ *
137
+ * Re-marshaling a baked vector op-tape across the JS->C bridge every frame is expensive (it dominates an
138
+ * interactive drag on PSRAM-QuickJS), and it's pure waste when only the node's POSITION changed: a
139
+ * `<Svg source>` whose imported artifact and resolved box are unchanged renders identical geometry, and its
140
+ * on-screen movement is already handled by the layout props (left/top/width/height) via applyProps. So we
141
+ * re-upload only when the source artifact reference changes or the resolved box size changes. Declarative
142
+ * `<Svg><Path/></Svg>` has no `source` — its shapes live in props/children, which we can't cheaply diff
143
+ * here, so it always re-flattens (unchanged behavior).
144
+ */
145
+ function vectorNeedsUpload(type, prevProps, nextProps) {
146
+ if (type !== 'Svg') return false;
147
+ if (!nextProps.source) return true; // declarative children: re-flatten as before
148
+ if (!prevProps || prevProps.source !== nextProps.source) return true;
149
+ const src = nextProps.source;
150
+ const iw = src && src.kind === 'vector' ? src.width : undefined;
151
+ const ih = src && src.kind === 'vector' ? src.height : undefined;
152
+ return (
153
+ svgBoxSize(prevProps, 'width', iw) !== svgBoxSize(nextProps, 'width', iw) ||
154
+ svgBoxSize(prevProps, 'height', ih) !== svgBoxSize(nextProps, 'height', ih)
155
+ );
58
156
  }
59
157
 
60
158
  /**
@@ -63,7 +161,10 @@ function applyVectorOps(type, handle, props) {
63
161
  function applyEvents(handle, prevProps, nextProps) {
64
162
  if (prevProps) {
65
163
  for (const key in prevProps) {
66
- if (isEventProp(key, prevProps[key]) && !(nextProps && isEventProp(key, nextProps[key]))) {
164
+ if (
165
+ isEventProp(key, prevProps[key]) &&
166
+ !(nextProps && isEventProp(key, nextProps[key]))
167
+ ) {
67
168
  NativeUI.setEvent(handle, key, null);
68
169
  }
69
170
  }
@@ -104,6 +205,14 @@ export const hostConfig = {
104
205
 
105
206
  // --- Creation ---
106
207
  createInstance(type, props) {
208
+ // A raster-fallback <Svg source> becomes a real Image node (the SVG was rasterized at build time).
209
+ const raster = rasterSvgArtifact(type, props);
210
+ if (raster) {
211
+ const handle = NativeUI.createNode('Image');
212
+ applyProps('Image', handle, rasterImageProps(props, raster));
213
+ applyEvents(handle, null, props);
214
+ return handle;
215
+ }
107
216
  const handle = NativeUI.createNode(type);
108
217
  applyProps(type, handle, props);
109
218
  applyTextSpans(type, handle, props);
@@ -115,7 +224,7 @@ export const hostConfig = {
115
224
  // Raw text is only legal inside <Text> (handled via shouldSetTextContent). This fallback
116
225
  // wraps stray text in a Text node so it still renders rather than crashing.
117
226
  const handle = NativeUI.createNode('Text');
118
- NativeUI.setProps(handle, { text: String(text) });
227
+ NativeUI.setProps(handle, {text: String(text)});
119
228
  return handle;
120
229
  },
121
230
  appendInitialChild(parent, child) {
@@ -162,13 +271,21 @@ export const hostConfig = {
162
271
  return true;
163
272
  },
164
273
  commitUpdate(instance, _payload, type, prevProps, nextProps) {
274
+ const raster = rasterSvgArtifact(type, nextProps);
275
+ if (raster) {
276
+ // The Svg instance is an Image node (raster fallback); re-apply as image props, never vector ops.
277
+ applyProps('Image', instance, rasterImageProps(nextProps, raster));
278
+ applyEvents(instance, prevProps, nextProps);
279
+ return;
280
+ }
165
281
  applyProps(type, instance, nextProps);
166
282
  applyTextSpans(type, instance, nextProps);
167
- applyVectorOps(type, instance, nextProps);
283
+ if (vectorNeedsUpload(type, prevProps, nextProps))
284
+ applyVectorOps(type, instance, nextProps);
168
285
  applyEvents(instance, prevProps, nextProps);
169
286
  },
170
287
  commitTextUpdate(textInstance, _oldText, newText) {
171
- NativeUI.setProps(textInstance, { text: String(newText) });
288
+ NativeUI.setProps(textInstance, {text: String(newText)});
172
289
  },
173
290
 
174
291
  // --- Misc required hooks (no-ops for our renderer) ---
@@ -188,9 +305,11 @@ export const hostConfig = {
188
305
 
189
306
  // --- Scheduling ---
190
307
  scheduleTimeout: (fn, delay) => setTimeout(fn, delay),
191
- cancelTimeout: (id) => clearTimeout(id),
308
+ cancelTimeout: id => clearTimeout(id),
192
309
  supportsMicrotasks: true,
193
310
  scheduleMicrotask:
194
- typeof queueMicrotask === 'function' ? queueMicrotask : (fn) => Promise.resolve().then(fn),
311
+ typeof queueMicrotask === 'function'
312
+ ? queueMicrotask
313
+ : fn => Promise.resolve().then(fn),
195
314
  now: () => NativeUI.now(),
196
315
  };
package/src/native-ui.js CHANGED
@@ -20,5 +20,7 @@
20
20
  export const NativeUI = globalThis.NativeUI;
21
21
 
22
22
  if (!NativeUI) {
23
- throw new Error('NativeUI global is missing — the QuickJS bridge must be installed before the bundle runs.');
23
+ throw new Error(
24
+ 'NativeUI global is missing — the QuickJS bridge must be installed before the bundle runs.',
25
+ );
24
26
  }
package/src/props.js CHANGED
@@ -53,7 +53,14 @@ export function flattenStyleObj(style) {
53
53
  }
54
54
 
55
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'];
56
+ const SPAN_STYLE_KEYS = [
57
+ 'color',
58
+ 'fontSize',
59
+ 'fontWeight',
60
+ 'fontStyle',
61
+ 'textDecorationLine',
62
+ 'letterSpacing',
63
+ ];
57
64
 
58
65
  /**
59
66
  * True when a <Text>'s children can be flattened inline — i.e. every leaf is a string/number or a
@@ -81,7 +88,7 @@ export function flattenTextChildren(children, baseStyle) {
81
88
  if (text === '') return;
82
89
  const last = segments[segments.length - 1];
83
90
  if (last && last.style === style) last.text += text;
84
- else segments.push({ text, style });
91
+ else segments.push({text, style});
85
92
  };
86
93
  const walk = (node, style) => {
87
94
  if (node == null || node === false || node === true) return;
@@ -96,7 +103,9 @@ export function flattenTextChildren(children, baseStyle) {
96
103
  if (node && node.props !== undefined) {
97
104
  // Nested <Text>: merge its style over the inherited one (new object only when it has style,
98
105
  // 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;
106
+ const merged = node.props.style
107
+ ? Object.assign({}, style, flattenStyleObj(node.props.style))
108
+ : style;
100
109
  walk(node.props.children, merged);
101
110
  }
102
111
  };
@@ -113,9 +122,9 @@ export function buildTextSpans(props) {
113
122
  if (!isTextContent(props.children)) return [];
114
123
  const baseStyle = flattenStyleObj(props.style);
115
124
  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 };
125
+ if (!segments.some(s => s.style !== baseStyle)) return [];
126
+ return segments.map(s => {
127
+ const span = {text: s.text};
119
128
  for (const k of SPAN_STYLE_KEYS) {
120
129
  if (s.style && s.style[k] !== undefined) span[k] = s.style[k];
121
130
  }
@@ -134,7 +143,8 @@ export function buildTextSpans(props) {
134
143
  */
135
144
  export function resolveImageSource(source) {
136
145
  if (typeof source === 'string') return source;
137
- if (source && typeof source === 'object' && typeof source.uri === 'string') return source.uri;
146
+ if (source && typeof source === 'object' && typeof source.uri === 'string')
147
+ return source.uri;
138
148
  return null;
139
149
  }
140
150
 
@@ -148,8 +158,10 @@ export function buildProps(type, props) {
148
158
  // vector node from them. Fold them into the resolved style (an explicit style width/height still wins)
149
159
  // so Flow A matches the Flow B AOT, which reads svg.props.width/height directly (compile.mjs emitSvgBox).
150
160
  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;
161
+ if (flat.width === undefined && props.width !== undefined)
162
+ flat.width = props.width;
163
+ if (flat.height === undefined && props.height !== undefined)
164
+ flat.height = props.height;
153
165
  }
154
166
  // <Image source={...}> resolves to the engine asset name unless imageName was set explicitly.
155
167
  if (flat.imageName === undefined && props.source != null) {
@@ -162,7 +174,7 @@ export function buildProps(type, props) {
162
174
  if (type === 'Text' && isTextContent(props.children)) {
163
175
  const base = flattenStyleObj(props.style);
164
176
  flat.text = flattenTextChildren(props.children, base)
165
- .map((s) => s.text)
177
+ .map(s => s.text)
166
178
  .join('');
167
179
  }
168
180
  return flat;
package/src/renderer.js CHANGED
@@ -20,8 +20,8 @@
20
20
  // We use LegacyRoot (synchronous) mode so the initial render flushes without depending on the
21
21
  // async scheduler/timers — the host's frame loop then paints whatever the tree became.
22
22
  import Reconciler from 'react-reconciler';
23
- import { hostConfig } from './host-config.js';
24
- import { NativeUI } from './native-ui.js';
23
+ import {hostConfig} from './host-config.js';
24
+ import {NativeUI} from './native-ui.js';
25
25
 
26
26
  const reconciler = Reconciler(hostConfig);
27
27
 
@@ -45,7 +45,7 @@ export function createRoot(containerProps) {
45
45
  false, // isStrictMode
46
46
  null, // concurrentUpdatesByDefaultOverride
47
47
  '', // identifierPrefix
48
- (error) => console.error('react recoverable error:', error),
48
+ error => console.error('react recoverable error:', error),
49
49
  null, // transitionCallbacks
50
50
  );
51
51