@lynxship/expo 0.1.7 → 0.1.8

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/README.md CHANGED
@@ -134,12 +134,47 @@ export function LynxScreen() {
134
134
  style={{ flex: 1 }}
135
135
  bundle="main.lynx.bundle"
136
136
  initialData="{}"
137
+ globalProps={{ theme: "dark" }}
138
+ autoGlobalProps
137
139
  reloadOnUpdate
138
140
  />
139
141
  );
140
142
  }
141
143
  ```
142
144
 
145
+ `globalProps` is passed to `lynx.__globalProps` during the native template
146
+ load. It can also be changed without remounting the React Native screen. The
147
+ native ref exposes the same host controls on Android and iOS:
148
+
149
+ ```tsx
150
+ const viewRef = useRef<LynxViewRef>(null);
151
+
152
+ viewRef.current?.updateData('{"screen":"inbox"}');
153
+ viewRef.current?.updateGlobalProps({ theme: "light" });
154
+ viewRef.current?.updateGlobalPropsByIncrement({ unreadCount: 3 });
155
+ viewRef.current?.sendGlobalEvent("accountChanged", [{ id: "user-1" }]);
156
+ await viewRef.current?.reload();
157
+ ```
158
+
159
+ `updateData` uses Lynx's official host-data update API and accepts a JSON
160
+ string up to 8 MiB without remounting the view. Its optional second argument
161
+ selects a registered Lynx data processor. `show`, `hide` and
162
+ `updateViewport({ width, height })` are also available.
163
+ `updateGlobalPropsByIncrement` applies only the supplied keys and is useful for
164
+ small host-state changes; `updateGlobalProps` remains the full replacement API.
165
+ The ref also exposes `getContainerId()`, `getLoadState()` and
166
+ `isLoadSuccess()`, matching the native container state contract without
167
+ requiring a React remount.
168
+ The viewport is updated automatically from the native view layout, so the
169
+ usual `style={{ flex: 1 }}` remains the recommended layout. The native view
170
+ owns the Lynx lifecycle and is destroyed when Expo releases the view; this
171
+ prevents an old page/runtime from surviving React reconciliation.
172
+
173
+ The view also exposes lifecycle events that can drive application-owned UI:
174
+ `onLoadStart`, `onResourceFetchStart`, `onLoadSuccess`, `onReady` (first screen), `onError`, `onUpdate` (OTA or non-remounting data update),
175
+ `onShow` and `onHide`. The events are notifications only; they do not replace
176
+ the native permission, navigation or OTA policy owned by the host.
177
+
143
178
  The native module initializes Lynx, creates the official `LynxView`, and
144
179
  provides a template provider. The provider first reads the verified active OTA
145
180
  asset and falls back to the embedded bundle. An OTA release is accepted only
@@ -190,8 +225,13 @@ the provider is the integration point for the LynxShip OTA cache.
190
225
 
191
226
  References:
192
227
 
193
- - [Lynx integration with existing apps](https://lynxjs.org/3.8/guide/start/integrate-with-existing-apps.html)
228
+ - [Lynx/Rspeedy existing-app integration](https://lynxjs.org/next/rspeedy/start/integrate-with-existing-apps)
229
+ - [Lynx native `LynxView` API](https://lynxjs.org/next/api/lynx-native-api/lynx-view/lynx-view.html)
230
+ - [Lynx global props](https://lynxjs.org/next/api/lynx-api/lynx/lynx-global-props.html)
231
+ - [Lynx global events](https://lynxjs.org/next/api/lynx-native-api/lynx-view/send-global-event.html)
232
+ - [Lynx native viewport API](https://lynxjs.org/next/api/lynx-native-api/lynx-view/update-viewport.html)
194
233
  - [Expo native view modules](https://docs.expo.dev/modules/native-view-tutorial/)
234
+ - [Expo view refs and async functions](https://docs.expo.dev/modules/module-api/)
195
235
  - [Expo module configuration](https://docs.expo.dev/modules/module-config/)
196
236
  - [LynxShip OTA security and compatibility](../../docs/compatibility.md)
197
237
 
@@ -42,6 +42,7 @@ android {
42
42
 
43
43
  dependencies {
44
44
  implementation "org.lynxsdk.lynx:lynx:${lynxVersion}"
45
+ implementation "androidx.annotation:annotation:1.10.0"
45
46
  implementation "org.lynxsdk.lynx:lynx-jssdk:${lynxVersion}"
46
47
  implementation "org.lynxsdk.lynx:lynx-trace:${lynxVersion}"
47
48
  implementation "org.lynxsdk.lynx:primjs:${lynxVersion}"
@@ -8,14 +8,22 @@ class LynxShipExpoModule : Module() {
8
8
  Name("LynxShip")
9
9
 
10
10
  View(LynxShipExpoView::class) {
11
- Events("onReady", "onError", "onUpdate")
11
+ Events("onReady", "onLoadStart", "onResourceFetchStart", "onLoadSuccess", "onError", "onUpdate", "onShow", "onHide")
12
12
 
13
13
  Prop("bundle") { view: LynxShipExpoView, value: String? ->
14
- view.bundleName = value ?: "main.lynx.bundle"
14
+ view.setBundleName(value ?: "main.lynx.bundle")
15
15
  }
16
16
 
17
17
  Prop("initialData") { view: LynxShipExpoView, value: String? ->
18
- view.initialData = value ?: ""
18
+ view.setInitialData(value ?: "")
19
+ }
20
+
21
+ Prop("globalProps") { view: LynxShipExpoView, value: Map<String, Any?>? ->
22
+ view.updateGlobalProps(value ?: emptyMap())
23
+ }
24
+
25
+ Prop("autoGlobalProps") { view: LynxShipExpoView, value: Boolean? ->
26
+ view.autoGlobalProps = value ?: true
19
27
  }
20
28
 
21
29
  Prop("reloadOnUpdate") { view: LynxShipExpoView, value: Boolean? ->
@@ -25,6 +33,46 @@ class LynxShipExpoModule : Module() {
25
33
  AsyncFunction("reload") { view: LynxShipExpoView ->
26
34
  view.reload()
27
35
  }
36
+
37
+ AsyncFunction("getContainerId") { view: LynxShipExpoView ->
38
+ view.getContainerId()
39
+ }
40
+
41
+ AsyncFunction("getLoadState") { view: LynxShipExpoView ->
42
+ view.getLoadState()
43
+ }
44
+
45
+ AsyncFunction("isLoadSuccess") { view: LynxShipExpoView ->
46
+ view.isLoadSuccess()
47
+ }
48
+
49
+ AsyncFunction("updateData") { view: LynxShipExpoView, data: String, processorName: String? ->
50
+ view.updateData(data, processorName)
51
+ }
52
+
53
+ AsyncFunction("updateGlobalProps") { view: LynxShipExpoView, props: Map<String, Any?> ->
54
+ view.updateGlobalProps(props)
55
+ }
56
+
57
+ AsyncFunction("updateGlobalPropsByIncrement") { view: LynxShipExpoView, props: Map<String, Any?> ->
58
+ view.updateGlobalPropsByIncrement(props)
59
+ }
60
+
61
+ AsyncFunction("sendGlobalEvent") { view: LynxShipExpoView, eventName: String, params: List<Any?> ->
62
+ view.sendGlobalEvent(eventName, params)
63
+ }
64
+
65
+ AsyncFunction("show") { view: LynxShipExpoView -> view.show() }
66
+
67
+ AsyncFunction("hide") { view: LynxShipExpoView -> view.hide() }
68
+
69
+ AsyncFunction("updateViewport") { view: LynxShipExpoView, viewport: Map<String, Double> ->
70
+ view.updateViewport(viewport["width"] ?: 0.0, viewport["height"] ?: 0.0)
71
+ }
72
+
73
+ OnViewDestroys { view: LynxShipExpoView ->
74
+ view.release()
75
+ }
28
76
  }
29
77
  }
30
78
  }
@@ -3,8 +3,16 @@ package com.lynxship.expo
3
3
  import android.app.Application
4
4
  import android.content.Context
5
5
  import android.content.SharedPreferences
6
+ import android.content.res.Configuration
6
7
  import android.os.Bundle
8
+ import android.os.Build
9
+ import android.os.PowerManager
7
10
  import android.provider.Settings
11
+ import android.view.View
12
+ import android.view.View.MeasureSpec
13
+ import android.view.WindowInsets
14
+ import android.view.accessibility.AccessibilityManager
15
+ import com.lynx.react.bridge.JavaOnlyArray
8
16
  import com.facebook.drawee.backends.pipeline.Fresco
9
17
  import com.facebook.imagepipeline.core.ImagePipelineConfig
10
18
  import com.facebook.imagepipeline.memory.PoolConfig
@@ -13,6 +21,10 @@ import com.lynx.service.http.LynxHttpService
13
21
  import com.lynx.service.image.LynxImageService
14
22
  import com.lynx.service.log.LynxLogService
15
23
  import com.lynx.tasm.LynxEnv
24
+ import com.lynx.tasm.LynxError
25
+ import com.lynx.tasm.LynxLoadMeta
26
+ import com.lynx.tasm.LynxUpdateMeta
27
+ import com.lynx.tasm.TemplateData
16
28
  import com.lynx.tasm.LynxView
17
29
  import com.lynx.tasm.LynxViewClient
18
30
  import com.lynx.tasm.LynxViewBuilder
@@ -30,13 +42,19 @@ import java.util.UUID
30
42
  class LynxShipExpoView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
31
43
  private val applicationContext = context.applicationContext
32
44
  private val onReady by EventDispatcher()
45
+ private val onLoadStart by EventDispatcher()
46
+ private val onResourceFetchStart by EventDispatcher()
47
+ private val onLoadSuccess by EventDispatcher()
33
48
  private val onError by EventDispatcher()
34
49
  private val onUpdate by EventDispatcher()
50
+ private val onShow by EventDispatcher()
51
+ private val onHide by EventDispatcher()
35
52
  private val preferences: SharedPreferences = applicationContext.getSharedPreferences("lynxship-expo", Context.MODE_PRIVATE)
36
53
  private val metadata: Bundle = applicationContext.applicationInfo.metaData ?: Bundle()
37
54
  private val otaClient = createOtaClient(applicationContext)
38
55
  private val templateProvider = object : AbsTemplateProvider() {
39
56
  override fun loadTemplate(uri: String, callback: Callback) {
57
+ post { onResourceFetchStart(mapOf("bundle" to uri)) }
40
58
  Thread {
41
59
  try {
42
60
  val bytes = otaClient?.openActiveAsset(uri) ?: readEmbeddedAsset(uri)
@@ -49,9 +67,21 @@ class LynxShipExpoView(context: Context, appContext: AppContext) : ExpoView(cont
49
67
  }
50
68
  }
51
69
  private val lynxView: LynxView
52
- var bundleName: String = metadata.getString("com.lynxship.expo.embeddedBundle", "main.lynx.bundle")
53
- var initialData: String = ""
70
+ private var bundleNameValue: String = metadata.getString("com.lynxship.expo.embeddedBundle", "main.lynx.bundle")
71
+ private var initialDataValue: String = ""
72
+ private var globalProps: Map<String, Any?> = emptyMap()
54
73
  var reloadOnUpdate: Boolean = true
74
+ var autoGlobalProps: Boolean = true
75
+ set(value) {
76
+ field = value
77
+ if (hasRendered) pushGlobalProps()
78
+ }
79
+ private var hasRendered = false
80
+ private var loadState = "idle"
81
+ private var started = false
82
+ private val containerId = UUID.randomUUID().toString()
83
+ private val containerInitTime = System.currentTimeMillis().toString()
84
+ private var appInBackground = false
55
85
 
56
86
  init {
57
87
  initializeLynx(context)
@@ -59,13 +89,26 @@ class LynxShipExpoView(context: Context, appContext: AppContext) : ExpoView(cont
59
89
  // application context only for process-wide services and storage.
60
90
  lynxView = LynxViewBuilder().setTemplateProvider(templateProvider).build(context)
61
91
  lynxView.addLynxViewClient(object : LynxViewClient() {
92
+ override fun onPageStart(url: String?) {
93
+ loadState = "loading"
94
+ onLoadStart(mapOf("bundle" to (url ?: bundleNameValue)))
95
+ }
96
+
62
97
  override fun onFirstScreen() {
98
+ loadState = "loaded"
63
99
  try {
64
100
  otaClient?.markLaunchSuccess()
65
101
  } catch (error: IOException) {
66
102
  onError(mapOf("message" to (error.message ?: "Could not record Lynx launch"), "recoverable" to true))
67
103
  }
68
- onReady(mapOf("bundle" to bundleName, "sequence" to (otaClient?.activeSequence() ?: 0)))
104
+ val event = mapOf("bundle" to bundleNameValue, "sequence" to (otaClient?.activeSequence() ?: 0))
105
+ onLoadSuccess(event)
106
+ onReady(event)
107
+ }
108
+
109
+ override fun onReceivedError(error: LynxError) {
110
+ loadState = "failed"
111
+ onError(mapOf("message" to error.toString(), "recoverable" to false))
69
112
  }
70
113
  })
71
114
  addView(lynxView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
@@ -73,27 +116,238 @@ class LynxShipExpoView(context: Context, appContext: AppContext) : ExpoView(cont
73
116
 
74
117
  override fun onAttachedToWindow() {
75
118
  super.onAttachedToWindow()
119
+ lynxView.onEnterForeground()
120
+ onShow(emptyMap<String, Any>())
121
+ if (started) return
122
+ started = true
76
123
  try {
77
124
  otaClient?.beginLaunch()
78
125
  installUpdateInBackground()
79
- render()
126
+ if (!hasRendered) render()
80
127
  } catch (error: Exception) {
81
128
  onError(mapOf("message" to (error.message ?: "Lynx startup failed")))
82
129
  }
83
130
  }
84
131
 
132
+ override fun onDetachedFromWindow() {
133
+ lynxView.onEnterBackground()
134
+ onHide(emptyMap<String, Any>())
135
+ super.onDetachedFromWindow()
136
+ }
137
+
85
138
  fun reload() {
86
139
  render()
87
140
  }
88
141
 
142
+ fun getContainerId(): String = containerId
143
+
144
+ fun getLoadState(): String = loadState
145
+
146
+ fun isLoadSuccess(): Boolean = loadState == "loaded"
147
+
148
+ fun updateData(data: String, processorName: String? = null) {
149
+ require(data.length <= 8 * 1024 * 1024) { "Lynx update data is larger than 8 MiB" }
150
+ check(hasRendered) { "Lynx view has not loaded a bundle" }
151
+ if (processorName != null) {
152
+ require(processorName.isNotBlank() && processorName.length <= 256 && processorName.none { it.isISOControl() }) {
153
+ "Lynx data processor name is invalid"
154
+ }
155
+ }
156
+ val templateData = TemplateData.fromString(data)
157
+ processorName?.let(templateData::markState)
158
+ val builder = LynxUpdateMeta.Builder()
159
+ builder.setUpdatedData(templateData)
160
+ lynxView.updateMetaData(builder.build())
161
+ initialDataValue = data
162
+ onUpdate(mapOf("bundle" to bundleNameValue, "reason" to "data"))
163
+ }
164
+
165
+ fun setBundleName(value: String) {
166
+ if (value == bundleNameValue) return
167
+ bundleNameValue = value
168
+ if (started) render()
169
+ }
170
+
171
+ fun setInitialData(value: String) {
172
+ if (value == initialDataValue) return
173
+ initialDataValue = value
174
+ if (started) render()
175
+ }
176
+
177
+ fun updateGlobalProps(props: Map<String, Any?>) {
178
+ globalProps = props.toMap()
179
+ pushGlobalProps()
180
+ }
181
+
182
+ fun updateGlobalPropsByIncrement(props: Map<String, Any?>) {
183
+ if (props.isEmpty()) return
184
+ globalProps = buildMap {
185
+ putAll(globalProps)
186
+ putAll(props)
187
+ }
188
+ lynxView.updateGlobalProps(TemplateData.fromMap(props))
189
+ }
190
+
191
+ fun sendGlobalEvent(eventName: String, params: List<Any?>) {
192
+ require(eventName.isNotBlank()) { "Lynx global event name cannot be blank" }
193
+ require(eventName.length <= 256) { "Lynx global event name is too long" }
194
+ require(params.size <= 256) { "Lynx global event payload is too large" }
195
+ lynxView.sendGlobalEvent(
196
+ eventName,
197
+ JavaOnlyArray.from(params),
198
+ )
199
+ }
200
+
201
+ fun show() {
202
+ visibility = View.VISIBLE
203
+ }
204
+
205
+ fun hide() {
206
+ visibility = View.INVISIBLE
207
+ }
208
+
209
+ fun updateViewport(width: Double, height: Double) {
210
+ require(width.isFinite() && height.isFinite() && width >= 0 && height >= 0) {
211
+ "Lynx viewport dimensions must be finite and non-negative"
212
+ }
213
+ lynxView.updateViewport(
214
+ MeasureSpec.makeMeasureSpec(width.toInt(), MeasureSpec.EXACTLY),
215
+ MeasureSpec.makeMeasureSpec(height.toInt(), MeasureSpec.EXACTLY),
216
+ )
217
+ }
218
+
89
219
  private fun render() {
90
220
  try {
91
- lynxView.renderTemplateUrl(bundleName, initialData)
221
+ loadState = "loading"
222
+ val builder = LynxLoadMeta.Builder()
223
+ builder.setUrl(bundleNameValue)
224
+ if (initialDataValue.isNotEmpty()) {
225
+ builder.setInitialData(TemplateData.fromString(initialDataValue))
226
+ }
227
+ if (autoGlobalProps || globalProps.isNotEmpty()) {
228
+ builder.setGlobalProps(TemplateData.fromMap(effectiveGlobalProps()))
229
+ }
230
+ lynxView.loadTemplate(builder.build())
231
+ hasRendered = true
92
232
  } catch (error: Exception) {
233
+ loadState = "failed"
93
234
  onError(mapOf("message" to (error.message ?: "Lynx render failed")))
94
235
  }
95
236
  }
96
237
 
238
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
239
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
240
+ if (width > 0 && height > 0) {
241
+ lynxView.updateViewport(
242
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
243
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
244
+ )
245
+ if (hasRendered && autoGlobalProps) pushGlobalProps()
246
+ }
247
+ }
248
+
249
+ override fun onWindowVisibilityChanged(visibility: Int) {
250
+ super.onWindowVisibilityChanged(visibility)
251
+ val nextBackground = visibility != View.VISIBLE
252
+ if (nextBackground == appInBackground) return
253
+ appInBackground = nextBackground
254
+ if (hasRendered && autoGlobalProps) pushGlobalProps()
255
+ }
256
+
257
+ private fun pushGlobalProps() {
258
+ if (!autoGlobalProps && globalProps.isEmpty()) return
259
+ lynxView.updateGlobalProps(TemplateData.fromMap(effectiveGlobalProps()))
260
+ }
261
+
262
+ private fun effectiveGlobalProps(): Map<String, Any?> {
263
+ if (!autoGlobalProps) return globalProps
264
+ val metrics = resources.displayMetrics
265
+ val density = metrics.density.takeIf { it > 0f } ?: 1f
266
+ val screenWidth = metrics.widthPixels.toDouble() / density
267
+ val screenHeight = metrics.heightPixels.toDouble() / density
268
+ val contentWidth = width.toDouble() / density
269
+ val contentHeight = height.toDouble() / density
270
+ val insets = systemInsets()
271
+ val orientation = if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
272
+ "landscape"
273
+ } else {
274
+ "portrait"
275
+ }
276
+ val night = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
277
+ val theme = if (night == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
278
+ val locale = resources.configuration.locales[0]?.toLanguageTag() ?: "en-US"
279
+ val powerManager = getContext().getSystemService(Context.POWER_SERVICE) as? PowerManager
280
+ val hasCutout = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && rootWindowInsets?.displayCutout != null
281
+ val isTablet = resources.configuration.smallestScreenWidthDp >= 600
282
+ return buildMap {
283
+ putAll(globalProps)
284
+ put("os", "android")
285
+ put("osVersion", Build.VERSION.RELEASE ?: "unknown")
286
+ put("deviceModel", Build.MODEL ?: "unknown")
287
+ put("containerID", containerId)
288
+ put("containerInitTime", containerInitTime)
289
+ put("screenWidth", screenWidth)
290
+ put("screenHeight", screenHeight)
291
+ put("contentWidth", contentWidth.coerceAtLeast(0.0))
292
+ put("contentHeight", contentHeight.coerceAtLeast(0.0))
293
+ put("safeAreaInsets", insets)
294
+ put("pixelRatio", density.toDouble())
295
+ put("accessibleMode", accessibilityMode())
296
+ put("isIPhoneX", 0)
297
+ put("isIPhoneXMax", 0)
298
+ put("isPad", if (isTablet) 1 else 0)
299
+ put("isNotchScreen", hasCutout)
300
+ put("isLowPowerMode", if (powerManager?.isPowerSaveMode == true) 1 else 0)
301
+ put("orientation", orientation)
302
+ put("screenOrientation", orientation)
303
+ put("theme", theme)
304
+ put("appLanguage", locale.substringBefore('-'))
305
+ put("appLocale", locale)
306
+ put("isAppBackground", appInBackground)
307
+ put("queryItems", emptyMap<String, String>())
308
+ put("statusBarHeight", insets["top"] ?: 0.0)
309
+ put("navigationBarHeight", insets["bottom"] ?: 0.0)
310
+ put("safeAreaHeight", insets["top"] ?: 0.0)
311
+ }
312
+ }
313
+
314
+ private fun systemInsets(): Map<String, Double> {
315
+ val insets = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
316
+ rootWindowInsets?.getInsets(WindowInsets.Type.systemBars())
317
+ } else {
318
+ null
319
+ }
320
+ val density = resources.displayMetrics.density.takeIf { it > 0f } ?: 1f
321
+ if (insets != null) {
322
+ return mapOf(
323
+ "top" to (insets.top / density).toDouble(),
324
+ "right" to (insets.right / density).toDouble(),
325
+ "bottom" to (insets.bottom / density).toDouble(),
326
+ "left" to (insets.left / density).toDouble(),
327
+ )
328
+ }
329
+ val legacy = rootWindowInsets
330
+ return mapOf(
331
+ "top" to (((legacy?.systemWindowInsetTop ?: 0) / density).toDouble()),
332
+ "right" to (((legacy?.systemWindowInsetRight ?: 0) / density).toDouble()),
333
+ "bottom" to (((legacy?.systemWindowInsetBottom ?: 0) / density).toDouble()),
334
+ "left" to (((legacy?.systemWindowInsetLeft ?: 0) / density).toDouble()),
335
+ )
336
+ }
337
+
338
+ private fun accessibilityMode(): Int {
339
+ val manager = getContext().getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
340
+ return if (manager?.isTouchExplorationEnabled == true) 1 else 0
341
+ }
342
+
343
+ fun release() {
344
+ if (hasRendered) {
345
+ lynxView.destroy()
346
+ hasRendered = false
347
+ }
348
+ loadState = "released"
349
+ }
350
+
97
351
  private fun installUpdateInBackground() {
98
352
  otaClient?.checkAndInstallAsync(object : LynxShipOtaClient.Listener {
99
353
  override fun onSuccess(updateAvailable: Boolean) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ComponentType, type ReactNode } from "react";
1
+ import { type ComponentType } from "react";
2
2
  import type { ViewProps } from "react-native";
3
3
  import { type LynxShipExpoConfig } from "./config.js";
4
4
  export type { LynxShipExpoConfig } from "./config.js";
@@ -8,14 +8,84 @@ export interface LynxViewProps extends ViewProps {
8
8
  bundle?: string;
9
9
  /** Optional initial data passed to Lynx when the view is rendered. */
10
10
  initialData?: string;
11
+ /** Global props exposed as `lynx.__globalProps` in the loaded Lynx page. */
12
+ globalProps?: Readonly<Record<string, unknown>>;
13
+ /** Injects the standard OS, size, safe-area and lifecycle host props. */
14
+ autoGlobalProps?: boolean;
11
15
  /** Requests a fresh render after an OTA candidate has been activated. */
12
16
  reloadOnUpdate?: boolean;
17
+ /** Emitted after Lynx reports that the first screen layout completed. */
18
+ onReady?: (event: LynxViewReadyEvent) => void;
19
+ /** Emitted when Lynx begins loading a bundle. */
20
+ onLoadStart?: (event: LynxViewLoadStartEvent) => void;
21
+ /** Emitted when the native provider starts fetching bundle bytes. */
22
+ onResourceFetchStart?: (event: LynxViewResourceFetchStartEvent) => void;
23
+ /** Emitted when the first rendered screen is available. */
24
+ onLoadSuccess?: (event: LynxViewReadyEvent) => void;
25
+ /** Emitted when the native host or bundle provider reports an error. */
26
+ onError?: (event: LynxViewErrorEvent) => void;
27
+ /** Emitted after a verified OTA candidate has been activated. */
28
+ onUpdate?: (event: LynxViewUpdateEvent) => void;
29
+ /** Emitted when the native Lynx view becomes visible. */
30
+ onShow?: () => void;
31
+ /** Emitted when the native Lynx view leaves the window. */
32
+ onHide?: () => void;
33
+ }
34
+ export interface LynxViewLoadStartEvent {
35
+ readonly nativeEvent: {
36
+ readonly bundle: string;
37
+ };
38
+ }
39
+ export interface LynxViewResourceFetchStartEvent {
40
+ readonly nativeEvent: {
41
+ readonly bundle: string;
42
+ };
43
+ }
44
+ export interface LynxViewReadyEvent {
45
+ readonly nativeEvent: {
46
+ readonly bundle: string;
47
+ readonly sequence: number;
48
+ };
49
+ }
50
+ export interface LynxViewErrorEvent {
51
+ readonly nativeEvent: {
52
+ readonly message: string;
53
+ readonly recoverable?: boolean;
54
+ };
55
+ }
56
+ export interface LynxViewUpdateEvent {
57
+ readonly nativeEvent: {
58
+ readonly sequence: number;
59
+ };
60
+ }
61
+ export interface LynxViewViewport {
62
+ readonly width: number;
63
+ readonly height: number;
64
+ }
65
+ export type LynxViewLoadState = "idle" | "loading" | "loaded" | "failed" | "released";
66
+ /** Imperative controls implemented by the native LynxView ref. */
67
+ export interface LynxViewRef {
68
+ getContainerId(): Promise<string>;
69
+ getLoadState(): Promise<LynxViewLoadState>;
70
+ isLoadSuccess(): Promise<boolean>;
71
+ reload(): Promise<void>;
72
+ /** Updates Lynx initData without remounting the native view. */
73
+ updateData(data: string, processorName?: string): Promise<void>;
74
+ updateGlobalProps(props: Readonly<Record<string, unknown>>): Promise<void>;
75
+ /** Merges a partial global-props patch without remounting the Lynx page. */
76
+ updateGlobalPropsByIncrement(props: Readonly<Record<string, unknown>>): Promise<void>;
77
+ sendGlobalEvent(eventName: string, params?: readonly unknown[]): Promise<void>;
78
+ show(): Promise<void>;
79
+ hide(): Promise<void>;
80
+ updateViewport(viewport: LynxViewViewport): Promise<void>;
13
81
  }
14
82
  /**
15
83
  * A LynxView that can be placed anywhere in an Expo/React Native view tree.
16
84
  * Native OTA configuration is supplied by the LynxShip config plugin.
17
85
  */
18
- export declare function LynxView(props: LynxViewProps): ReactNode;
86
+ export declare const LynxView: ComponentType<LynxViewProps & {
87
+ ref?: LynxViewRef | null | undefined;
88
+ }>;
19
89
  export declare const LynxShipView: ComponentType<LynxViewProps>;
20
90
  export declare function defineLynxShipExpoConfig(config: LynxShipExpoConfig): LynxShipExpoConfig;
21
91
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAE1E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAEzD,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAID;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,CAOxD;AAED,eAAO,MAAM,YAAY,EAAE,aAAa,CAAC,aAAa,CAAY,CAAC;AAEnE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,kBAAkB,GACzB,kBAAkB,CAEpB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LynxShipView = exports.validateLynxShipExpoConfig = void 0;
4
- exports.LynxView = LynxView;
3
+ exports.LynxShipView = exports.LynxView = exports.validateLynxShipExpoConfig = void 0;
5
4
  exports.defineLynxShipExpoConfig = defineLynxShipExpoConfig;
6
5
  const react_1 = require("react");
7
6
  const expo_modules_core_1 = require("expo-modules-core");
@@ -13,15 +12,16 @@ const NativeLynxView = (0, expo_modules_core_1.requireNativeViewManager)("LynxSh
13
12
  * A LynxView that can be placed anywhere in an Expo/React Native view tree.
14
13
  * Native OTA configuration is supplied by the LynxShip config plugin.
15
14
  */
16
- function LynxView(props) {
17
- return (0, react_1.createElement)(NativeLynxView, {
18
- bundle: props.bundle ?? "main.lynx.bundle",
19
- initialData: props.initialData ?? "",
20
- reloadOnUpdate: props.reloadOnUpdate ?? true,
21
- ...props,
22
- });
23
- }
24
- exports.LynxShipView = LynxView;
15
+ exports.LynxView = (0, react_1.forwardRef)((props, ref) => (0, react_1.createElement)(NativeLynxView, {
16
+ bundle: props.bundle ?? "main.lynx.bundle",
17
+ initialData: props.initialData ?? "",
18
+ globalProps: props.globalProps ?? {},
19
+ autoGlobalProps: props.autoGlobalProps ?? true,
20
+ reloadOnUpdate: props.reloadOnUpdate ?? true,
21
+ ...props,
22
+ ref,
23
+ }));
24
+ exports.LynxShipView = exports.LynxView;
25
25
  function defineLynxShipExpoConfig(config) {
26
26
  return (0, config_js_1.validateLynxShipExpoConfig)(config);
27
27
  }
@@ -4,12 +4,18 @@ public class LynxShipExpoModule: Module {
4
4
  public func definition() -> ModuleDefinition {
5
5
  Name("LynxShip")
6
6
  View(LynxShipExpoView.self) {
7
- Events("onReady", "onError", "onUpdate")
7
+ Events("onReady", "onLoadStart", "onResourceFetchStart", "onLoadSuccess", "onError", "onUpdate", "onShow", "onHide")
8
8
  Prop("bundle") { (view: LynxShipExpoView, value: String?) in
9
- view.bundleName = value ?? "main.lynx.bundle"
9
+ view.setBundleName(value ?? "main.lynx.bundle")
10
10
  }
11
11
  Prop("initialData") { (view: LynxShipExpoView, value: String?) in
12
- view.initialData = value ?? ""
12
+ view.setInitialData(value ?? "")
13
+ }
14
+ Prop("globalProps") { (view: LynxShipExpoView, value: [String: Any]?) in
15
+ view.updateGlobalProps(value ?? [:])
16
+ }
17
+ Prop("autoGlobalProps") { (view: LynxShipExpoView, value: Bool?) in
18
+ view.autoGlobalProps = value ?? true
13
19
  }
14
20
  Prop("reloadOnUpdate") { (view: LynxShipExpoView, value: Bool?) in
15
21
  view.reloadOnUpdate = value ?? true
@@ -17,6 +23,36 @@ public class LynxShipExpoModule: Module {
17
23
  AsyncFunction("reload") { (view: LynxShipExpoView) in
18
24
  try view.reload()
19
25
  }
26
+ AsyncFunction("getContainerId") { (view: LynxShipExpoView) in
27
+ view.getContainerId()
28
+ }
29
+ AsyncFunction("getLoadState") { (view: LynxShipExpoView) in
30
+ view.getLoadState()
31
+ }
32
+ AsyncFunction("isLoadSuccess") { (view: LynxShipExpoView) in
33
+ view.isLoadSuccess()
34
+ }
35
+ AsyncFunction("updateData") { (view: LynxShipExpoView, data: String, processorName: String?) in
36
+ try view.updateData(data, processorName: processorName)
37
+ }
38
+ AsyncFunction("updateGlobalProps") { (view: LynxShipExpoView, props: [String: Any]) in
39
+ view.updateGlobalProps(props)
40
+ }
41
+ AsyncFunction("updateGlobalPropsByIncrement") { (view: LynxShipExpoView, props: [String: Any]) in
42
+ view.updateGlobalPropsByIncrement(props)
43
+ }
44
+ AsyncFunction("sendGlobalEvent") { (view: LynxShipExpoView, eventName: String, params: [Any]) in
45
+ try view.sendGlobalEvent(eventName, params: params)
46
+ }
47
+ AsyncFunction("show") { (view: LynxShipExpoView) in
48
+ view.show()
49
+ }
50
+ AsyncFunction("hide") { (view: LynxShipExpoView) in
51
+ view.hide()
52
+ }
53
+ AsyncFunction("updateViewport") { (view: LynxShipExpoView, viewport: [String: Double]) in
54
+ try view.updateViewport(viewport)
55
+ }
20
56
  }
21
57
  }
22
58
  }
@@ -6,38 +6,79 @@ import UIKit
6
6
 
7
7
  private final class LynxShipTemplateProvider: NSObject, LynxTemplateProvider {
8
8
  private let load: (String) throws -> Data
9
- private let onLoaded: () -> Void
9
+ private let onStart: (String) -> Void
10
10
 
11
- init(load: @escaping (String) throws -> Data, onLoaded: @escaping () -> Void) {
11
+ init(load: @escaping (String) throws -> Data, onStart: @escaping (String) -> Void) {
12
12
  self.load = load
13
- self.onLoaded = onLoaded
13
+ self.onStart = onStart
14
14
  }
15
15
 
16
16
  func loadTemplate(withUrl url: String!, onComplete callback: LynxTemplateLoadBlock!) {
17
+ onStart(url ?? "")
17
18
  do {
18
19
  callback(load(url), nil)
19
- onLoaded()
20
20
  } catch {
21
21
  callback(nil, error)
22
22
  }
23
23
  }
24
24
  }
25
25
 
26
+ private final class LynxShipLifecycleClient: NSObject, LynxViewLifecycle {
27
+ private let onStart: () -> Void
28
+ private let onFirstScreen: () -> Void
29
+
30
+ init(onStart: @escaping () -> Void, onFirstScreen: @escaping () -> Void) {
31
+ self.onStart = onStart
32
+ self.onFirstScreen = onFirstScreen
33
+ }
34
+
35
+ func lynxViewDidStartLoading(_ view: LynxView) {
36
+ onStart()
37
+ }
38
+
39
+ func lynxViewDidFirstScreen(_ view: LynxView) {
40
+ onFirstScreen()
41
+ }
42
+ }
43
+
26
44
  public final class LynxShipExpoView: ExpoView {
27
45
  let onReady = EventDispatcher()
46
+ let onLoadStart = EventDispatcher()
47
+ let onResourceFetchStart = EventDispatcher()
48
+ let onLoadSuccess = EventDispatcher()
28
49
  let onError = EventDispatcher()
29
50
  let onUpdate = EventDispatcher()
51
+ let onShow = EventDispatcher()
52
+ let onHide = EventDispatcher()
30
53
 
31
54
  private lazy var templateProvider = LynxShipTemplateProvider(
32
55
  load: { [weak self] path in
33
56
  guard let self else { throw NSError(domain: "LynxShip", code: 1, userInfo: [NSLocalizedDescriptionKey: "Lynx view was released"]) }
34
57
  return try self.otaClient?.openActiveAsset(path) ?? self.readEmbeddedAsset(path)
35
58
  },
36
- onLoaded: { [weak self] in
59
+ onStart: { [weak self] bundle in
60
+ DispatchQueue.main.async { self?.onResourceFetchStart(["bundle": bundle]) }
61
+ }
62
+ )
63
+
64
+ private lazy var lifecycleClient = LynxShipLifecycleClient(
65
+ onStart: { [weak self] in
37
66
  guard let self else { return }
38
- try? self.otaClient?.markLaunchSuccess()
67
+ self.loadState = "loading"
68
+ self.onLoadStart(["bundle": self.bundleName])
69
+ },
70
+ onFirstScreen: { [weak self] in
71
+ guard let self else { return }
72
+ self.loadState = "loaded"
73
+ do {
74
+ try self.otaClient?.markLaunchSuccess()
75
+ } catch {
76
+ self.onError(["message": error.localizedDescription, "recoverable": true])
77
+ }
39
78
  DispatchQueue.main.async {
40
- self.onReady(["bundle": self.bundleName, "sequence": self.otaClient?.activeSequence ?? 0])
79
+ let event: [String: Any] = ["bundle": self.bundleName, "sequence": self.otaClient?.activeSequence ?? 0]
80
+ self.onLoadSuccess(event)
81
+ self.onReady(event)
41
82
  }
42
83
  }
43
84
  )
@@ -51,15 +92,26 @@ public final class LynxShipExpoView: ExpoView {
51
92
 
52
93
  private var otaClient: LynxShipOtaClient?
53
94
  private var hasRendered = false
95
+ private var loadState = "idle"
54
96
  var bundleName = "main.lynx.bundle"
55
97
  var initialData = ""
98
+ private var globalProps: [String: Any] = [:]
56
99
  var reloadOnUpdate = true
100
+ var autoGlobalProps = true {
101
+ didSet {
102
+ if hasRendered { pushGlobalProps() }
103
+ }
104
+ }
105
+ private let containerID = UUID().uuidString
106
+ private let containerInitTime = ISO8601DateFormatter().string(from: Date())
107
+ private var appInBackground = false
57
108
 
58
109
  public required init(appContext: AppContext? = nil) {
59
110
  super.init(appContext: appContext)
60
111
  LynxEnv.sharedInstance()
61
112
  otaClient = makeOtaClient()
62
113
  addSubview(lynxView)
114
+ lynxView.addLifecycleClient(lifecycleClient)
63
115
  }
64
116
 
65
117
  public override func layoutSubviews() {
@@ -67,16 +119,30 @@ public final class LynxShipExpoView: ExpoView {
67
119
  lynxView.frame = bounds
68
120
  lynxView.preferredLayoutWidth = bounds.width
69
121
  lynxView.preferredLayoutHeight = bounds.height
122
+ lynxView.updateViewport(withPreferredLayoutWidth: bounds.width,
123
+ preferredLayoutHeight: bounds.height,
124
+ needLayout: true)
125
+ if hasRendered && autoGlobalProps { pushGlobalProps() }
70
126
  }
71
127
 
72
128
  public override func didMoveToWindow() {
73
129
  super.didMoveToWindow()
130
+ appInBackground = window == nil
131
+ if window != nil {
132
+ lynxView.onEnterForeground()
133
+ onShow([:])
134
+ } else {
135
+ lynxView.onEnterBackground()
136
+ onHide([:])
137
+ }
138
+ if hasRendered && autoGlobalProps { pushGlobalProps() }
74
139
  guard window != nil, !hasRendered else { return }
75
140
  do {
76
141
  try otaClient?.beginLaunch()
77
142
  render()
78
143
  checkForUpdate()
79
144
  } catch {
145
+ loadState = "failed"
80
146
  onError(["message": error.localizedDescription])
81
147
  }
82
148
  }
@@ -85,11 +151,167 @@ public final class LynxShipExpoView: ExpoView {
85
151
  render()
86
152
  }
87
153
 
154
+ func getContainerId() -> String { containerID }
155
+
156
+ func getLoadState() -> String { loadState }
157
+
158
+ func isLoadSuccess() -> Bool { loadState == "loaded" }
159
+
160
+ func updateData(_ data: String, processorName: String? = nil) throws {
161
+ guard data.utf8.count <= 8 * 1024 * 1024 else {
162
+ throw NSError(domain: "LynxShip", code: 8, userInfo: [NSLocalizedDescriptionKey: "Lynx update data is larger than 8 MiB"])
163
+ }
164
+ guard hasRendered else {
165
+ throw NSError(domain: "LynxShip", code: 9, userInfo: [NSLocalizedDescriptionKey: "Lynx view has not loaded a bundle"])
166
+ }
167
+ if let processorName {
168
+ guard !processorName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
169
+ processorName.count <= 256,
170
+ processorName.rangeOfCharacter(from: .controlCharacters) == nil else {
171
+ throw NSError(domain: "LynxShip", code: 10, userInfo: [NSLocalizedDescriptionKey: "Lynx data processor name is invalid"])
172
+ }
173
+ let templateData = LynxTemplateData(json: data)
174
+ templateData.markState(processorName)
175
+ lynxView.updateData(withTemplateData: templateData)
176
+ } else {
177
+ lynxView.updateData(withString: data)
178
+ }
179
+ initialData = data
180
+ onUpdate(["bundle": bundleName, "reason": "data"])
181
+ }
182
+
183
+ func setBundleName(_ value: String) {
184
+ guard value != bundleName else { return }
185
+ bundleName = value
186
+ if hasRendered { render() }
187
+ }
188
+
189
+ func setInitialData(_ value: String) {
190
+ guard value != initialData else { return }
191
+ initialData = value
192
+ if hasRendered { render() }
193
+ }
194
+
195
+ func updateGlobalProps(_ props: [String: Any]) {
196
+ globalProps = props
197
+ pushGlobalProps()
198
+ }
199
+
200
+ func updateGlobalPropsByIncrement(_ props: [String: Any]) {
201
+ guard !props.isEmpty else { return }
202
+ globalProps.merge(props) { _, newValue in newValue }
203
+ lynxView.updateGlobalProps(withDictionary: props)
204
+ }
205
+
206
+ func sendGlobalEvent(_ eventName: String, params: [Any]) throws {
207
+ guard !eventName.isEmpty else {
208
+ throw NSError(domain: "LynxShip", code: 4, userInfo: [NSLocalizedDescriptionKey: "Lynx global event name cannot be empty"])
209
+ }
210
+ guard eventName.count <= 256 else {
211
+ throw NSError(domain: "LynxShip", code: 5, userInfo: [NSLocalizedDescriptionKey: "Lynx global event name is too long"])
212
+ }
213
+ guard params.count <= 256 else {
214
+ throw NSError(domain: "LynxShip", code: 6, userInfo: [NSLocalizedDescriptionKey: "Lynx global event payload is too large"])
215
+ }
216
+ lynxView.sendGlobalEvent(eventName, withParams: params)
217
+ }
218
+
219
+ func show() {
220
+ isHidden = false
221
+ }
222
+
223
+ func hide() {
224
+ isHidden = true
225
+ }
226
+
227
+ func updateViewport(_ viewport: [String: Double]) throws {
228
+ let width = viewport["width"] ?? 0
229
+ let height = viewport["height"] ?? 0
230
+ guard width.isFinite && height.isFinite && width >= 0 && height >= 0 else {
231
+ throw NSError(domain: "LynxShip", code: 7, userInfo: [NSLocalizedDescriptionKey: "Lynx viewport dimensions must be finite and non-negative"])
232
+ }
233
+ lynxView.updateViewport(withPreferredLayoutWidth: CGFloat(width),
234
+ preferredLayoutHeight: CGFloat(height),
235
+ needLayout: true)
236
+ }
237
+
88
238
  private func render() {
239
+ loadState = "loading"
240
+ if autoGlobalProps || !globalProps.isEmpty {
241
+ lynxView.updateGlobalProps(withDictionary: effectiveGlobalProps())
242
+ }
89
243
  lynxView.loadTemplate(fromURL: bundleName, initData: initialData)
90
244
  hasRendered = true
91
245
  }
92
246
 
247
+ private func pushGlobalProps() {
248
+ guard autoGlobalProps || !globalProps.isEmpty else { return }
249
+ lynxView.updateGlobalProps(withDictionary: effectiveGlobalProps())
250
+ }
251
+
252
+ private func effectiveGlobalProps() -> [String: Any] {
253
+ guard autoGlobalProps else { return globalProps }
254
+ let screen = window?.screen ?? UIScreen.main
255
+ let screenSize = screen.bounds.size
256
+ let insets = safeAreaInsets
257
+ let width = max(0, bounds.width)
258
+ let height = max(0, bounds.height)
259
+ let contentWidth = max(0, width - insets.left - insets.right)
260
+ let contentHeight = max(0, height - insets.top - insets.bottom)
261
+ let orientation: String
262
+ switch window?.windowScene?.interfaceOrientation {
263
+ case .landscapeLeft: orientation = "landscape-left"
264
+ case .landscapeRight: orientation = "landscape-right"
265
+ case .portraitUpsideDown: orientation = "portrait-upside-down"
266
+ case .portrait: orientation = "portrait"
267
+ default: orientation = width > height ? "landscape" : "portrait"
268
+ }
269
+ let theme: String
270
+ switch traitCollection.userInterfaceStyle {
271
+ case .dark: theme = "dark"
272
+ case .light: theme = "light"
273
+ default: theme = "system"
274
+ }
275
+ let locale = Locale.current.identifier
276
+ let language = Locale.current.languageCode ?? locale.split(separator: "_").first.map(String.init) ?? locale
277
+ let isTablet = UIDevice.current.userInterfaceIdiom == .pad
278
+ let isNotchScreen = UIDevice.current.userInterfaceIdiom == .phone && insets.top > 20
279
+ var props = globalProps
280
+ props["os"] = "ios"
281
+ props["osVersion"] = UIDevice.current.systemVersion
282
+ props["deviceModel"] = UIDevice.current.model
283
+ props["containerID"] = containerID
284
+ props["containerInitTime"] = containerInitTime
285
+ props["screenWidth"] = screenSize.width
286
+ props["screenHeight"] = screenSize.height
287
+ props["contentWidth"] = contentWidth
288
+ props["contentHeight"] = contentHeight
289
+ props["safeAreaInsets"] = [
290
+ "top": insets.top,
291
+ "right": insets.right,
292
+ "bottom": insets.bottom,
293
+ "left": insets.left,
294
+ ]
295
+ props["pixelRatio"] = screen.scale
296
+ props["accessibleMode"] = UIAccessibility.isVoiceOverRunning ? 1 : 0
297
+ props["isIPhoneX"] = isNotchScreen ? 1 : 0
298
+ props["isIPhoneXMax"] = isNotchScreen ? 1 : 0
299
+ props["isPad"] = isTablet ? 1 : 0
300
+ props["isNotchScreen"] = isNotchScreen
301
+ props["isLowPowerMode"] = ProcessInfo.processInfo.isLowPowerModeEnabled ? 1 : 0
302
+ props["orientation"] = orientation
303
+ props["screenOrientation"] = orientation
304
+ props["theme"] = theme
305
+ props["appLanguage"] = language
306
+ props["appLocale"] = locale
307
+ props["isAppBackground"] = appInBackground
308
+ props["queryItems"] = [String: String]()
309
+ props["topHeight"] = insets.top
310
+ props["bottomHeight"] = insets.bottom
311
+ props["safeAreaHeight"] = insets.top
312
+ return props
313
+ }
314
+
93
315
  private func checkForUpdate() {
94
316
  Task { [weak self] in
95
317
  guard let self else { return }
@@ -150,4 +372,9 @@ public final class LynxShipExpoView: ExpoView {
150
372
  return nil
151
373
  }
152
374
  }
375
+
376
+ deinit {
377
+ loadState = "released"
378
+ lynxView.destroy()
379
+ }
153
380
  }
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "name": "@lynxship/expo",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Expo native LynxView with LynxShip OTA delivery and cache support.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "files": [
9
9
  "dist",
10
- "android",
11
- "ios",
10
+ "android/build.gradle",
11
+ "android/consumer-rules.pro",
12
+ "android/src",
13
+ "ios/*.podspec",
14
+ "ios/*.h",
15
+ "ios/*.m",
16
+ "ios/*.swift",
12
17
  "src",
13
18
  "app.plugin.js",
14
19
  "app.plugin.cjs",
@@ -17,6 +17,9 @@ declare module "react" {
17
17
  type: unknown,
18
18
  props: Record<string, unknown> | null,
19
19
  ): ReactNode;
20
+ export function forwardRef<T, Props>(
21
+ render: (props: Props, ref: T | null) => ReactNode,
22
+ ): ComponentType<Props & { ref?: T | null }>;
20
23
  }
21
24
 
22
25
  declare module "react-native" {
package/src/index.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { createElement, type ComponentType, type ReactNode } from "react";
1
+ import {
2
+ createElement,
3
+ forwardRef,
4
+ type ComponentType,
5
+ type ReactNode,
6
+ } from "react";
2
7
  import { requireNativeViewManager } from "expo-modules-core";
3
8
  import type { ViewProps } from "react-native";
4
9
  import {
@@ -15,24 +20,112 @@ export interface LynxViewProps extends ViewProps {
15
20
  bundle?: string;
16
21
  /** Optional initial data passed to Lynx when the view is rendered. */
17
22
  initialData?: string;
23
+ /** Global props exposed as `lynx.__globalProps` in the loaded Lynx page. */
24
+ globalProps?: Readonly<Record<string, unknown>>;
25
+ /** Injects the standard OS, size, safe-area and lifecycle host props. */
26
+ autoGlobalProps?: boolean;
18
27
  /** Requests a fresh render after an OTA candidate has been activated. */
19
28
  reloadOnUpdate?: boolean;
29
+ /** Emitted after Lynx reports that the first screen layout completed. */
30
+ onReady?: (event: LynxViewReadyEvent) => void;
31
+ /** Emitted when Lynx begins loading a bundle. */
32
+ onLoadStart?: (event: LynxViewLoadStartEvent) => void;
33
+ /** Emitted when the native provider starts fetching bundle bytes. */
34
+ onResourceFetchStart?: (event: LynxViewResourceFetchStartEvent) => void;
35
+ /** Emitted when the first rendered screen is available. */
36
+ onLoadSuccess?: (event: LynxViewReadyEvent) => void;
37
+ /** Emitted when the native host or bundle provider reports an error. */
38
+ onError?: (event: LynxViewErrorEvent) => void;
39
+ /** Emitted after a verified OTA candidate has been activated. */
40
+ onUpdate?: (event: LynxViewUpdateEvent) => void;
41
+ /** Emitted when the native Lynx view becomes visible. */
42
+ onShow?: () => void;
43
+ /** Emitted when the native Lynx view leaves the window. */
44
+ onHide?: () => void;
20
45
  }
21
46
 
22
- const NativeLynxView = requireNativeViewManager<LynxViewProps>("LynxShip");
47
+ export interface LynxViewLoadStartEvent {
48
+ readonly nativeEvent: { readonly bundle: string };
49
+ }
50
+
51
+ export interface LynxViewResourceFetchStartEvent {
52
+ readonly nativeEvent: { readonly bundle: string };
53
+ }
54
+
55
+ export interface LynxViewReadyEvent {
56
+ readonly nativeEvent: {
57
+ readonly bundle: string;
58
+ readonly sequence: number;
59
+ };
60
+ }
61
+
62
+ export interface LynxViewErrorEvent {
63
+ readonly nativeEvent: {
64
+ readonly message: string;
65
+ readonly recoverable?: boolean;
66
+ };
67
+ }
68
+
69
+ export interface LynxViewUpdateEvent {
70
+ readonly nativeEvent: {
71
+ readonly sequence: number;
72
+ };
73
+ }
74
+
75
+ export interface LynxViewViewport {
76
+ readonly width: number;
77
+ readonly height: number;
78
+ }
79
+
80
+ export type LynxViewLoadState =
81
+ | "idle"
82
+ | "loading"
83
+ | "loaded"
84
+ | "failed"
85
+ | "released";
86
+
87
+ /** Imperative controls implemented by the native LynxView ref. */
88
+ export interface LynxViewRef {
89
+ getContainerId(): Promise<string>;
90
+ getLoadState(): Promise<LynxViewLoadState>;
91
+ isLoadSuccess(): Promise<boolean>;
92
+ reload(): Promise<void>;
93
+ /** Updates Lynx initData without remounting the native view. */
94
+ updateData(data: string, processorName?: string): Promise<void>;
95
+ updateGlobalProps(props: Readonly<Record<string, unknown>>): Promise<void>;
96
+ /** Merges a partial global-props patch without remounting the Lynx page. */
97
+ updateGlobalPropsByIncrement(
98
+ props: Readonly<Record<string, unknown>>,
99
+ ): Promise<void>;
100
+ sendGlobalEvent(
101
+ eventName: string,
102
+ params?: readonly unknown[],
103
+ ): Promise<void>;
104
+ show(): Promise<void>;
105
+ hide(): Promise<void>;
106
+ updateViewport(viewport: LynxViewViewport): Promise<void>;
107
+ }
108
+
109
+ const NativeLynxView = requireNativeViewManager<
110
+ LynxViewProps & { ref?: LynxViewRef | null }
111
+ >("LynxShip");
23
112
 
24
113
  /**
25
114
  * A LynxView that can be placed anywhere in an Expo/React Native view tree.
26
115
  * Native OTA configuration is supplied by the LynxShip config plugin.
27
116
  */
28
- export function LynxView(props: LynxViewProps): ReactNode {
29
- return createElement(NativeLynxView, {
30
- bundle: props.bundle ?? "main.lynx.bundle",
31
- initialData: props.initialData ?? "",
32
- reloadOnUpdate: props.reloadOnUpdate ?? true,
33
- ...props,
34
- });
35
- }
117
+ export const LynxView = forwardRef<LynxViewRef, LynxViewProps>(
118
+ (props, ref): ReactNode =>
119
+ createElement(NativeLynxView, {
120
+ bundle: props.bundle ?? "main.lynx.bundle",
121
+ initialData: props.initialData ?? "",
122
+ globalProps: props.globalProps ?? {},
123
+ autoGlobalProps: props.autoGlobalProps ?? true,
124
+ reloadOnUpdate: props.reloadOnUpdate ?? true,
125
+ ...props,
126
+ ref,
127
+ }),
128
+ );
36
129
 
37
130
  export const LynxShipView: ComponentType<LynxViewProps> = LynxView;
38
131