@use-voltra/core 2.0.0-rc.4 → 2.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.
- package/build/cjs/index.js +1 -0
- package/build/cjs/index.js.map +1 -1
- package/build/cjs/payload/short-names.js +4 -0
- package/build/cjs/payload/short-names.js.map +1 -1
- package/build/cjs/widget-environment.js +26 -0
- package/build/cjs/widget-environment.js.map +1 -0
- package/build/esm/index.js +1 -0
- package/build/esm/index.js.map +1 -1
- package/build/esm/payload/short-names.js +4 -0
- package/build/esm/payload/short-names.js.map +1 -1
- package/build/esm/widget-environment.js +22 -0
- package/build/esm/widget-environment.js.map +1 -0
- package/build/types/index.d.ts +1 -0
- package/build/types/index.d.ts.map +1 -1
- package/build/types/payload/short-names.d.ts.map +1 -1
- package/build/types/widget-environment.d.ts +85 -0
- package/build/types/widget-environment.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/index.ts +6 -0
- package/src/jsx/createVoltraComponent.ts +36 -0
- package/src/payload/short-names.ts +379 -0
- package/src/payload.ts +23 -0
- package/src/renderer/context-registry.ts +40 -0
- package/src/renderer/dispatcher.ts +105 -0
- package/src/renderer/element-registry.ts +69 -0
- package/src/renderer/flatten-styles.ts +24 -0
- package/src/renderer/index.ts +14 -0
- package/src/renderer/render-cache.ts +27 -0
- package/src/renderer/renderer.ts +491 -0
- package/src/renderer/stylesheet-registry.ts +54 -0
- package/src/renderer/types.ts +5 -0
- package/src/types.ts +14 -0
- package/src/widget-environment.ts +114 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ComponentType,
|
|
3
|
+
ConsumerProps,
|
|
4
|
+
Context,
|
|
5
|
+
ForwardRefExoticComponent,
|
|
6
|
+
FunctionComponent,
|
|
7
|
+
LazyExoticComponent,
|
|
8
|
+
MemoExoticComponent,
|
|
9
|
+
ProviderProps,
|
|
10
|
+
ReactElement,
|
|
11
|
+
ReactNode,
|
|
12
|
+
} from 'react'
|
|
13
|
+
import {
|
|
14
|
+
isContextConsumer,
|
|
15
|
+
isContextProvider,
|
|
16
|
+
isForwardRef,
|
|
17
|
+
isFragment,
|
|
18
|
+
isLazy,
|
|
19
|
+
isMemo,
|
|
20
|
+
isPortal,
|
|
21
|
+
isProfiler,
|
|
22
|
+
isStrictMode,
|
|
23
|
+
isSuspense,
|
|
24
|
+
} from 'react-is'
|
|
25
|
+
|
|
26
|
+
import { isVoltraComponent } from '../jsx/createVoltraComponent.js'
|
|
27
|
+
import { shorten } from '../payload/short-names.js'
|
|
28
|
+
import { VoltraElementRef, VoltraNodeJson, VoltraPropValue } from '../types.js'
|
|
29
|
+
import { ContextRegistry, getContextRegistry } from './context-registry.js'
|
|
30
|
+
import { getHooksDispatcher, getReactCurrentDispatcher } from './dispatcher.js'
|
|
31
|
+
import { createElementRegistry, type ElementRegistry, preScanForDuplicates } from './element-registry.js'
|
|
32
|
+
import { flattenStyle } from './flatten-styles.js'
|
|
33
|
+
import { getRenderCache, type RenderCache } from './render-cache.js'
|
|
34
|
+
import { createStylesheetRegistry, type StylesheetRegistry } from './stylesheet-registry.js'
|
|
35
|
+
|
|
36
|
+
export type ComponentRegistry = {
|
|
37
|
+
getComponentId: (name: string) => number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type VoltraRenderingContext = {
|
|
41
|
+
registry: ContextRegistry
|
|
42
|
+
stylesheetRegistry?: StylesheetRegistry
|
|
43
|
+
elementRegistry?: ElementRegistry
|
|
44
|
+
duplicates?: Set<ReactNode>
|
|
45
|
+
inStringOnlyContext?: boolean
|
|
46
|
+
componentRegistry: ComponentRegistry
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderNode(element: ReactNode, context: VoltraRenderingContext): VoltraNodeJson {
|
|
50
|
+
if (element === null || element === undefined) {
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof element === 'boolean') {
|
|
55
|
+
if (context.inStringOnlyContext) {
|
|
56
|
+
return ''
|
|
57
|
+
}
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof element === 'string') {
|
|
62
|
+
if (context.inStringOnlyContext) {
|
|
63
|
+
return element
|
|
64
|
+
}
|
|
65
|
+
throw new Error(
|
|
66
|
+
'Expected a React element, but got "string". Strings are only allowed as children of Text components.'
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (typeof element === 'number' || typeof element === 'bigint') {
|
|
71
|
+
if (context.inStringOnlyContext) {
|
|
72
|
+
return String(element)
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`Expected a React element, but got "${typeof element}".`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (Array.isArray(element)) {
|
|
78
|
+
if (context.inStringOnlyContext) {
|
|
79
|
+
if (element.length === 0) {
|
|
80
|
+
throw new Error('Text component must have at least one child that resolves to a string.')
|
|
81
|
+
}
|
|
82
|
+
const results: string[] = []
|
|
83
|
+
for (const child of element) {
|
|
84
|
+
const result = renderNode(child, context)
|
|
85
|
+
if (Array.isArray(result) && result.length === 0) {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (typeof result !== 'string') {
|
|
89
|
+
throw new Error('Text component children must resolve to strings.')
|
|
90
|
+
}
|
|
91
|
+
results.push(result)
|
|
92
|
+
}
|
|
93
|
+
return results.join('')
|
|
94
|
+
}
|
|
95
|
+
return element.map((child) => renderNode(child, context)).flat() as VoltraNodeJson
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (context.duplicates?.has(element) && context.elementRegistry) {
|
|
99
|
+
const existingIndex = context.elementRegistry.isRegistered(element)
|
|
100
|
+
if (existingIndex !== undefined) {
|
|
101
|
+
return { $r: existingIndex } as VoltraElementRef
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rendered = renderNodeInternal(element, context)
|
|
105
|
+
const index = context.elementRegistry.register(element, rendered)
|
|
106
|
+
return { $r: index } as VoltraElementRef
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return renderNodeInternal(element, context)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderNodeInternal(element: ReactNode, context: VoltraRenderingContext): VoltraNodeJson {
|
|
113
|
+
if (typeof element !== 'object' || element === null) {
|
|
114
|
+
throw new Error(`Expected element-like object with type and props, got ${typeof element}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!('type' in element) || !('props' in element)) {
|
|
118
|
+
throw new Error(`Expected element-like object with type and props, got ${typeof element}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof element.type === 'string') {
|
|
122
|
+
throw new Error(`Host component "${element.type}" is not supported in Voltra.`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isStrictMode(element)) {
|
|
126
|
+
throw new Error('Strict mode is not supported in Voltra.')
|
|
127
|
+
}
|
|
128
|
+
if (isProfiler(element)) {
|
|
129
|
+
throw new Error('Profiler is not supported in Voltra.')
|
|
130
|
+
}
|
|
131
|
+
if (isSuspense(element)) {
|
|
132
|
+
throw new Error('Suspense is not supported in Voltra.')
|
|
133
|
+
}
|
|
134
|
+
if (isPortal(element) || (element as { type?: unknown }).type === Symbol.for('react.portal')) {
|
|
135
|
+
throw new Error('Portal is not supported in Voltra.')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (isFragment(element) || (element as { type?: unknown }).type === Symbol.for('react.fragment')) {
|
|
139
|
+
const fragmentElement = element as ReactElement<{ children?: ReactNode }>
|
|
140
|
+
return renderNode(fragmentElement.props.children, context)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isMemo(element)) {
|
|
144
|
+
const memoElement = element as ReactElement<unknown, MemoExoticComponent<ComponentType<unknown>>>
|
|
145
|
+
const { type: memoizedComponent } = memoElement.type
|
|
146
|
+
return renderNode({ ...memoElement, type: memoizedComponent }, context)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (isForwardRef(element)) {
|
|
150
|
+
const forwardRefElement = element as ReactElement<unknown, ForwardRefExoticComponent<ComponentType<unknown>>>
|
|
151
|
+
const { render } = forwardRefElement.type as unknown as { render: (props: unknown) => ReactNode }
|
|
152
|
+
return renderFunctionalComponent(render, forwardRefElement.props, context)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (isLazy(element)) {
|
|
156
|
+
const lazyElement = element as ReactElement<unknown, LazyExoticComponent<ComponentType<unknown>>>
|
|
157
|
+
const lazyType = lazyElement.type as unknown as {
|
|
158
|
+
_init?: (payload: unknown) => ComponentType<unknown>
|
|
159
|
+
_payload?: unknown
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof lazyType._init !== 'function') {
|
|
163
|
+
throw new Error('Lazy component could not be resolved by the Voltra renderer.')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const resolvedType = lazyType._init(lazyType._payload)
|
|
168
|
+
return renderNode({ ...lazyElement, type: resolvedType }, context)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof Promise) {
|
|
171
|
+
throw new Error('Lazy component suspended! Voltra does not support Suspense/Promises.')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
throw error
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isContextProvider(element)) {
|
|
179
|
+
const contextProviderElement = element as ReactElement<ProviderProps<unknown>>
|
|
180
|
+
const reactContext = contextProviderElement.type as Context<unknown>
|
|
181
|
+
const { value, children } = contextProviderElement.props
|
|
182
|
+
context.registry.pushProvider(reactContext, value)
|
|
183
|
+
const result = renderNode(children, context)
|
|
184
|
+
context.registry.popProvider(reactContext)
|
|
185
|
+
return result
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (isContextConsumer(element)) {
|
|
189
|
+
const contextConsumerElement = element as ReactElement<ConsumerProps<unknown>>
|
|
190
|
+
const reactContext = (contextConsumerElement.type as unknown as { _context: Context<unknown> })._context
|
|
191
|
+
const value = context.registry.readContext(reactContext)
|
|
192
|
+
const children = contextConsumerElement.props.children
|
|
193
|
+
|
|
194
|
+
if (typeof children === 'function') {
|
|
195
|
+
return renderNode(children(value), context)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
throw new Error(`Expected a function as children of a context consumer, but got "${typeof children}".`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
element != null &&
|
|
203
|
+
typeof element === 'object' &&
|
|
204
|
+
'type' in element &&
|
|
205
|
+
'props' in element &&
|
|
206
|
+
typeof (element as { type: unknown }).type === 'function'
|
|
207
|
+
) {
|
|
208
|
+
const reactElement = element as ReactElement
|
|
209
|
+
const componentType = reactElement.type as FunctionComponent<unknown>
|
|
210
|
+
|
|
211
|
+
if (componentType.prototype && 'render' in componentType.prototype) {
|
|
212
|
+
throw new Error('Class components are not supported in Voltra.')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (isVoltraComponent(componentType)) {
|
|
216
|
+
const child = componentType(reactElement.props)
|
|
217
|
+
|
|
218
|
+
if (typeof child !== 'object' || child === null || !('type' in child) || !('props' in child)) {
|
|
219
|
+
throw new Error(`Expected a React element, but got "${typeof child}".`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (typeof child.type !== 'string') {
|
|
223
|
+
throw new Error(`Expected a string as the type of a React element, but got "${typeof child.type}".`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { children, ...parameters } = child.props as { children?: ReactNode; [key: string]: unknown }
|
|
227
|
+
const isTextComponent = child.type === 'Text' || child.type === 'AndroidText'
|
|
228
|
+
const childContext: VoltraRenderingContext = {
|
|
229
|
+
...context,
|
|
230
|
+
inStringOnlyContext: isTextComponent,
|
|
231
|
+
}
|
|
232
|
+
const renderedChildren =
|
|
233
|
+
children !== null && children !== undefined ? renderNode(children, childContext) : isTextComponent ? '' : []
|
|
234
|
+
|
|
235
|
+
const id = typeof parameters.id === 'string' ? parameters.id : undefined
|
|
236
|
+
const { id: _id, ...cleanParameters } = parameters
|
|
237
|
+
|
|
238
|
+
if (isTextComponent) {
|
|
239
|
+
if (typeof renderedChildren !== 'string') {
|
|
240
|
+
throw new Error(
|
|
241
|
+
'Text component children must resolve to a string. Nested components are allowed, but they must eventually resolve to a string.'
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const transformedProps = transformProps(cleanParameters, context)
|
|
246
|
+
const hasProps = Object.keys(transformedProps).length > 0
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
t: context.componentRegistry.getComponentId(child.type),
|
|
250
|
+
...(id ? { i: id } : {}),
|
|
251
|
+
c: renderedChildren,
|
|
252
|
+
...(hasProps ? { p: transformedProps } : {}),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (typeof renderedChildren === 'string') {
|
|
257
|
+
throw new Error('Unexpected string in non-Text component children.')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const transformedProps = transformProps(cleanParameters, context)
|
|
261
|
+
const hasProps = Object.keys(transformedProps).length > 0
|
|
262
|
+
const hasChildren = Array.isArray(renderedChildren) ? renderedChildren.length > 0 : true
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
t: context.componentRegistry.getComponentId(child.type),
|
|
266
|
+
...(id ? { i: id } : {}),
|
|
267
|
+
...(hasChildren ? { c: renderedChildren } : {}),
|
|
268
|
+
...(hasProps ? { p: transformedProps } : {}),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return renderFunctionalComponent(componentType, reactElement.props, context)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Unsupported element type "${String(
|
|
277
|
+
(element as { type?: unknown }).type
|
|
278
|
+
)}". Report this as a bug in the Voltra project.`
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export const renderFunctionalComponent = <TProps>(
|
|
283
|
+
Component: FunctionComponent<TProps>,
|
|
284
|
+
props: TProps,
|
|
285
|
+
context: VoltraRenderingContext
|
|
286
|
+
): VoltraNodeJson => {
|
|
287
|
+
const reactDispatcher = getReactCurrentDispatcher()
|
|
288
|
+
const prevHooksDispatcher = reactDispatcher.H
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
reactDispatcher.H = getHooksDispatcher(context.registry)
|
|
292
|
+
const result = Component(props)
|
|
293
|
+
|
|
294
|
+
if (result instanceof Promise) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Component "${
|
|
297
|
+
Component.name || 'Anonymous'
|
|
298
|
+
}" tried to suspend (returned a Promise). Async components are not supported in this synchronous renderer.`
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return renderNode(result, context)
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof Promise) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Component "${Component.name || 'Anonymous'}" suspended! Voltra does not support Suspense/Promises.`
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
throw error
|
|
311
|
+
} finally {
|
|
312
|
+
reactDispatcher.H = prevHooksDispatcher
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const renderVariantToJson = (element: ReactNode, componentRegistry: ComponentRegistry): VoltraNodeJson => {
|
|
317
|
+
const registry = getContextRegistry()
|
|
318
|
+
const context: VoltraRenderingContext = {
|
|
319
|
+
registry,
|
|
320
|
+
componentRegistry,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return renderNode(element, context)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function compressStyleObject(style: any): any {
|
|
327
|
+
if (style === null || style === undefined) {
|
|
328
|
+
return style
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const flattened = flattenStyle(style)
|
|
332
|
+
const compressed: Record<string, any> = {}
|
|
333
|
+
|
|
334
|
+
for (const [key, value] of Object.entries(flattened)) {
|
|
335
|
+
const shortKey = shorten(key)
|
|
336
|
+
|
|
337
|
+
if (value === null || value === undefined) {
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (typeof value === 'object' && !Array.isArray(value) && value.constructor === Object) {
|
|
342
|
+
const compressedNested: Record<string, any> = {}
|
|
343
|
+
for (const [nestedKey, nestedValue] of Object.entries(value)) {
|
|
344
|
+
compressedNested[nestedKey] = nestedValue
|
|
345
|
+
}
|
|
346
|
+
compressed[shortKey] = compressedNested
|
|
347
|
+
} else {
|
|
348
|
+
compressed[shortKey] = value
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return compressed
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isReactNode(value: unknown): value is ReactNode {
|
|
356
|
+
if (value === null || value === undefined || value === false || value === true) {
|
|
357
|
+
return false
|
|
358
|
+
}
|
|
359
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint') {
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
if (Array.isArray(value)) {
|
|
363
|
+
return true
|
|
364
|
+
}
|
|
365
|
+
if (typeof value === 'object' && value !== null && 'type' in value && 'props' in value) {
|
|
366
|
+
return true
|
|
367
|
+
}
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function transformProps(
|
|
372
|
+
props: Record<string, unknown>,
|
|
373
|
+
context: VoltraRenderingContext
|
|
374
|
+
): Record<string, VoltraPropValue> {
|
|
375
|
+
const transformed: Record<string, VoltraPropValue> = {}
|
|
376
|
+
|
|
377
|
+
for (const [key, value] of Object.entries(props)) {
|
|
378
|
+
if (key === 'style') {
|
|
379
|
+
const shortKey = shorten(key)
|
|
380
|
+
if (context.stylesheetRegistry) {
|
|
381
|
+
const index = context.stylesheetRegistry.registerStyle(value as object)
|
|
382
|
+
transformed[shortKey] = index
|
|
383
|
+
} else {
|
|
384
|
+
transformed[shortKey] = compressStyleObject(value)
|
|
385
|
+
}
|
|
386
|
+
} else if (isReactNode(value)) {
|
|
387
|
+
const serializedComponent = renderNode(value, {
|
|
388
|
+
registry: getContextRegistry(),
|
|
389
|
+
stylesheetRegistry: context.stylesheetRegistry,
|
|
390
|
+
elementRegistry: context.elementRegistry,
|
|
391
|
+
duplicates: context.duplicates,
|
|
392
|
+
inStringOnlyContext: false,
|
|
393
|
+
componentRegistry: context.componentRegistry,
|
|
394
|
+
})
|
|
395
|
+
const shortKey = shorten(key)
|
|
396
|
+
transformed[shortKey] = serializedComponent
|
|
397
|
+
} else {
|
|
398
|
+
const shortKey = shorten(key)
|
|
399
|
+
transformed[shortKey] = value as VoltraPropValue
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return transformed
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export const VOLTRA_PAYLOAD_VERSION = 1
|
|
407
|
+
|
|
408
|
+
export const createVoltraRenderer = (componentRegistry: ComponentRegistry) => {
|
|
409
|
+
const rootNodes: { name: string; node: ReactNode }[] = []
|
|
410
|
+
|
|
411
|
+
let duplicates: Set<ReactNode> | undefined
|
|
412
|
+
let stylesheetRegistry: StylesheetRegistry | undefined
|
|
413
|
+
let elementRegistry: ElementRegistry | undefined
|
|
414
|
+
let renderCache: RenderCache | undefined
|
|
415
|
+
|
|
416
|
+
const addRootNode = (name: string, node: ReactNode): void => {
|
|
417
|
+
if (node === null || node === undefined) {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
rootNodes.push({ name, node })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const preScanAllNodes = (): Set<ReactNode> => {
|
|
424
|
+
const seen = new Set<ReactNode>()
|
|
425
|
+
const duplicatesSet = new Set<ReactNode>()
|
|
426
|
+
|
|
427
|
+
for (const { node } of rootNodes) {
|
|
428
|
+
if (node && typeof node === 'object') {
|
|
429
|
+
if (seen.has(node)) {
|
|
430
|
+
duplicatesSet.add(node)
|
|
431
|
+
} else {
|
|
432
|
+
seen.add(node)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const nodeDuplicates = preScanForDuplicates(node)
|
|
437
|
+
for (const dup of nodeDuplicates) {
|
|
438
|
+
duplicatesSet.add(dup)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return duplicatesSet
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const render = (): Record<string, any> => {
|
|
446
|
+
stylesheetRegistry = createStylesheetRegistry()
|
|
447
|
+
elementRegistry = createElementRegistry()
|
|
448
|
+
duplicates = preScanAllNodes()
|
|
449
|
+
|
|
450
|
+
const renderVariantToJson = (element: ReactNode): VoltraNodeJson => {
|
|
451
|
+
const registry = getContextRegistry()
|
|
452
|
+
const context: VoltraRenderingContext = {
|
|
453
|
+
registry,
|
|
454
|
+
stylesheetRegistry,
|
|
455
|
+
elementRegistry,
|
|
456
|
+
duplicates,
|
|
457
|
+
componentRegistry,
|
|
458
|
+
}
|
|
459
|
+
return renderNode(element, context)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
renderCache = getRenderCache(renderVariantToJson)
|
|
463
|
+
|
|
464
|
+
const result: Record<string, any> = {
|
|
465
|
+
v: VOLTRA_PAYLOAD_VERSION,
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (const { name, node } of rootNodes) {
|
|
469
|
+
if (node !== null && node !== undefined) {
|
|
470
|
+
result[name] = renderCache.getOrRender(node)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const sharedElements = elementRegistry.getElements()
|
|
475
|
+
if (sharedElements.length > 0) {
|
|
476
|
+
result.e = sharedElements
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const styles = stylesheetRegistry.getStyles()
|
|
480
|
+
if (styles.length > 0) {
|
|
481
|
+
result.s = styles
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return result
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
addRootNode,
|
|
489
|
+
render,
|
|
490
|
+
}
|
|
491
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { shorten } from '../payload/short-names.js'
|
|
2
|
+
import { flattenStyle } from './flatten-styles.js'
|
|
3
|
+
|
|
4
|
+
function compressStyleObject(style: any): any {
|
|
5
|
+
if (style === null || style === undefined) {
|
|
6
|
+
return style
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const flattened = flattenStyle(style)
|
|
10
|
+
const compressed: Record<string, any> = {}
|
|
11
|
+
|
|
12
|
+
for (const [key, value] of Object.entries(flattened)) {
|
|
13
|
+
const shortKey = shorten(key)
|
|
14
|
+
|
|
15
|
+
if (value === null || value === undefined) {
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof value === 'object' && !Array.isArray(value) && value.constructor === Object) {
|
|
20
|
+
const compressedNested: Record<string, any> = {}
|
|
21
|
+
for (const [nestedKey, nestedValue] of Object.entries(value)) {
|
|
22
|
+
compressedNested[nestedKey] = nestedValue
|
|
23
|
+
}
|
|
24
|
+
compressed[shortKey] = compressedNested
|
|
25
|
+
} else {
|
|
26
|
+
compressed[shortKey] = value
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return compressed
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StylesheetRegistry = {
|
|
34
|
+
registerStyle: (styleObject: object) => number
|
|
35
|
+
getStyles: () => Record<string, unknown>[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const createStylesheetRegistry = (): StylesheetRegistry => {
|
|
39
|
+
const styleToIndex = new Map<object, number>()
|
|
40
|
+
const styles: Record<string, unknown>[] = []
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
registerStyle: (styleObject: object): number => {
|
|
44
|
+
const existing = styleToIndex.get(styleObject)
|
|
45
|
+
if (existing !== undefined) return existing
|
|
46
|
+
|
|
47
|
+
const index = styles.length
|
|
48
|
+
styleToIndex.set(styleObject, index)
|
|
49
|
+
styles.push(compressStyleObject(styleObject))
|
|
50
|
+
return index
|
|
51
|
+
},
|
|
52
|
+
getStyles: () => styles,
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type VoltraPropValue = string | number | boolean | null | VoltraNodeJson
|
|
2
|
+
|
|
3
|
+
export type VoltraElementJson = {
|
|
4
|
+
t: number
|
|
5
|
+
i?: string
|
|
6
|
+
c?: VoltraNodeJson
|
|
7
|
+
p?: Record<string, VoltraPropValue>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type VoltraElementRef = {
|
|
11
|
+
$r: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type VoltraNodeJson = VoltraElementJson | VoltraElementJson[] | VoltraElementRef | string
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env shape consumed by Dynamic Widgets.
|
|
3
|
+
*
|
|
4
|
+
* Dynamic Widgets are functions of `(props, env) => JSX`, evaluated inside the
|
|
5
|
+
* Voltra JS runtime (JSC on iOS, Hermes on Android) at every render. The `env` second
|
|
6
|
+
* argument is populated by the native runtime at draw time and carries:
|
|
7
|
+
*
|
|
8
|
+
* - **Runtime device state** (`colorScheme`, `widgetFamily`, etc.) captured per render
|
|
9
|
+
* - **Platform-specific runtime state** (`widgetRenderingMode` on iOS), present only on the
|
|
10
|
+
* platform that has the concept
|
|
11
|
+
* - **AppIntent / user-configured params** under `env.configuration` (TypeScript-typed per
|
|
12
|
+
* widget via the generic parameter)
|
|
13
|
+
* - **Build env** under `env.build.*` — values that don't change between renders inside a
|
|
14
|
+
* process (isDev, Metro URL, app version, Voltra version)
|
|
15
|
+
*
|
|
16
|
+
* The shape mirrors expo-widgets' `WidgetEnvironment` for the runtime device fields, with
|
|
17
|
+
* a Voltra-specific `env.build.*` namespace added for dev-mode tooling.
|
|
18
|
+
*
|
|
19
|
+
* @typeParam TConfig - Shape of `env.configuration` (AppIntent / user-configured params).
|
|
20
|
+
* Defaults to `undefined` for widgets that don't accept user configuration. Widget authors
|
|
21
|
+
* can supply a more specific type per widget for typed access.
|
|
22
|
+
*/
|
|
23
|
+
export type WidgetEnvironment<TConfig extends Record<string, unknown> | undefined = undefined> = {
|
|
24
|
+
/** Date the widget is being rendered for. Transported as epoch ms over the JS boundary
|
|
25
|
+
* and reconstructed as `Date` by the runtime entry. */
|
|
26
|
+
date: Date
|
|
27
|
+
|
|
28
|
+
/** Widget size family. iOS values: `systemSmall`, `systemMedium`, `systemLarge`, etc.
|
|
29
|
+
* Android values: synthesized from Glance `LocalSize` (e.g. `"200x200"`). */
|
|
30
|
+
widgetFamily: string
|
|
31
|
+
|
|
32
|
+
/** Current color scheme of the widget's environment. May be `undefined` if the platform
|
|
33
|
+
* doesn't expose it (rare). */
|
|
34
|
+
colorScheme?: 'light' | 'dark'
|
|
35
|
+
|
|
36
|
+
/** BCP-47 locale tag — for example `"en-US"` or `"pl-PL"`. */
|
|
37
|
+
locale?: string
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// iOS-only runtime values
|
|
41
|
+
// Present only when rendering on iOS; `undefined` on Android.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** iOS — rendering mode the widget is being drawn in. `fullColor` on home screen,
|
|
45
|
+
* `accented` on tinted/Liquid Glass widgets (iOS 18+) and watchOS, `vibrant` on lock
|
|
46
|
+
* screen. Maps to SwiftUI `@Environment(\.widgetRenderingMode)`. */
|
|
47
|
+
widgetRenderingMode?: 'fullColor' | 'accented' | 'vibrant'
|
|
48
|
+
|
|
49
|
+
/** iOS — whether the system is drawing a container background behind the widget.
|
|
50
|
+
* Maps to SwiftUI `@Environment(\.showsWidgetContainerBackground)`. iOS 17+. */
|
|
51
|
+
showsWidgetContainerBackground?: boolean
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// System-managed configuration
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** AppIntent / user-configured parameters for this widget. `undefined` for widgets that
|
|
58
|
+
* don't accept user configuration. Typed per widget via the [TConfig] generic. */
|
|
59
|
+
configuration: TConfig
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Build env — static for the process lifetime, supplied by the runtime
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** Build / process-level metadata, populated by the runtime once per process. Static for
|
|
66
|
+
* the JS runtime's lifetime; does not change between renders. */
|
|
67
|
+
build: WidgetBuildEnvironment
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build / process metadata available inside the widget render function. Populated by the
|
|
72
|
+
* native runtime; identical across every render in a process.
|
|
73
|
+
*/
|
|
74
|
+
export type WidgetBuildEnvironment = {
|
|
75
|
+
/** True when running against a development build (DEBUG / `__DEV__`). Used to gate
|
|
76
|
+
* dev-mode behaviour like fetching bundles from Metro. */
|
|
77
|
+
isDev: boolean
|
|
78
|
+
|
|
79
|
+
/** URL of the Metro dev server when `isDev` is true. Used by the runtime to fetch widget
|
|
80
|
+
* bundles for hot-reload. `undefined` in release builds. */
|
|
81
|
+
metroUrl?: string
|
|
82
|
+
|
|
83
|
+
/** App version string (`CFBundleShortVersionString` on iOS, `versionName` on Android). */
|
|
84
|
+
appVersion: string
|
|
85
|
+
|
|
86
|
+
/** Voltra package version (`@use-voltra/core`). Surfaces in error reports and lets
|
|
87
|
+
* widgets gate behaviour by compatibility level if needed. */
|
|
88
|
+
voltraVersion: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Type guard — returns true when the runtime env is an iOS-platform env.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* if (isIosEnv(env)) {
|
|
96
|
+
* // env.widgetRenderingMode is narrowed to the concrete value (not undefined)
|
|
97
|
+
* }
|
|
98
|
+
*/
|
|
99
|
+
export function isIosEnv(
|
|
100
|
+
env: WidgetEnvironment
|
|
101
|
+
): env is WidgetEnvironment & { widgetRenderingMode: NonNullable<WidgetEnvironment['widgetRenderingMode']> } {
|
|
102
|
+
return env.widgetRenderingMode !== undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Type guard — returns true when the runtime env is an Android-platform env.
|
|
107
|
+
*
|
|
108
|
+
* Android carries no platform-only runtime field (Material You colors are consumed via
|
|
109
|
+
* `AndroidDynamicColors` tokens resolved by the native renderer, not through `env`), so this is
|
|
110
|
+
* the complement of {@link isIosEnv}.
|
|
111
|
+
*/
|
|
112
|
+
export function isAndroidEnv(env: WidgetEnvironment): boolean {
|
|
113
|
+
return !isIosEnv(env)
|
|
114
|
+
}
|