@octane-xplat/ui 0.7.2 → 0.9.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.
Files changed (219) hide show
  1. package/README.md +4 -3
  2. package/dist/native/Button.tsrx.js +1 -1
  3. package/dist/native/CommandPalette.tsrx.js +7 -1
  4. package/dist/native/Grid.tsrx.js +31 -2
  5. package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
  6. package/dist/native/Markdown.tsrx.js +233 -0
  7. package/dist/native/Overlay.tsrx.js +15 -26
  8. package/dist/native/Pagination.tsrx.js +15 -12
  9. package/dist/native/PinInput.tsrx.js +25 -7
  10. package/dist/native/Popover.tsrx.js +33 -23
  11. package/dist/native/Pressable.tsrx.js +4 -1
  12. package/dist/native/Screen.tsrx.js +1 -1
  13. package/dist/native/SearchInput.tsrx.js +6 -3
  14. package/dist/native/Sheet.tsrx.js +23 -38
  15. package/dist/native/Slider.tsrx.js +23 -14
  16. package/dist/native/Text.tsrx.js +16 -0
  17. package/dist/native/TextArea.tsrx.js +6 -3
  18. package/dist/native/TextInput.tsrx.js +6 -3
  19. package/dist/native/VirtualList.tsrx.js +305 -121
  20. package/dist/native/WebView.tsrx.js +17 -2
  21. package/dist/native/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
  22. package/dist/native/escape-props.js +2 -0
  23. package/dist/native/grid-auto-placement.js +83 -0
  24. package/dist/native/grid-placement.js +20 -0
  25. package/dist/native/index.shared.js +7 -5
  26. package/dist/native/keyboard-inset.mobile.js +5 -5
  27. package/dist/native/navigation-request.js +26 -0
  28. package/dist/native/octane-client-build.json +1 -1
  29. package/dist/native/overlay-lifetime.mobile.js +65 -0
  30. package/dist/native/pan.tsrx.js +16 -2
  31. package/dist/native/route-table.js +90 -10
  32. package/dist/native/route.js +26 -16
  33. package/dist/native/shared.js +7 -5
  34. package/dist/native/sheet-service.js +2 -2
  35. package/dist/native/svg.mobile.js +1 -1
  36. package/dist/native/{tap-to-blur.mobile.js → tap-to-blur.ios.js} +20 -32
  37. package/dist/native/text-focus.js +10 -0
  38. package/dist/native/text-write.js +18 -11
  39. package/dist/native/toast-anchor.tsrx.js +1 -1
  40. package/dist/native/ui.js +7 -6
  41. package/dist/native/useMeasure.tsrx.js +17 -18
  42. package/dist/native/vendor/ui-svg/index.android.js +2 -2
  43. package/dist/native/vendor/ui-svg/index.ios.js +2 -2
  44. package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
  45. package/dist/native/virtual-list-cell-pool.js +63 -0
  46. package/dist/web/Button.web.tsrx.js +1 -1
  47. package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
  48. package/dist/web/Markdown.tsrx.js +98 -0
  49. package/dist/web/Overlay.web.tsrx.js +81 -32
  50. package/dist/web/PinInput.web.tsrx.js +24 -10
  51. package/dist/web/Pressable.web.tsrx.js +169 -53
  52. package/dist/web/SearchInput.web.tsrx.js +2 -2
  53. package/dist/web/Sheet.web.tsrx.js +20 -7
  54. package/dist/web/TextArea.web.tsrx.js +22 -17
  55. package/dist/web/TextInput.web.tsrx.js +2 -2
  56. package/dist/web/VirtualList.web.tsrx.js +220 -109
  57. package/dist/web/WebView.web.tsrx.js +3 -3
  58. package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
  59. package/dist/web/modal-focus.web.js +95 -0
  60. package/dist/web/navigation-request.js +26 -0
  61. package/dist/web/octane-client-build.json +1 -1
  62. package/dist/web/pan.web.tsrx.js +27 -10
  63. package/dist/web/route-table.js +90 -10
  64. package/dist/web/route.web.js +88 -22
  65. package/dist/web/sheet-service.web.js +8 -0
  66. package/dist/web/theme/colorScheme.web.js +31 -4
  67. package/dist/web/ui.js +8 -6
  68. package/dist/web/virtual-list-cell-pool.js +63 -0
  69. package/package.json +21 -19
  70. package/platforms/android/native-api-usage.json +7 -7
  71. package/src/CommandPalette.tsrx +8 -1
  72. package/src/Grid.tsrx +32 -1
  73. package/src/Icon.macos.tsrx +1 -0
  74. package/src/KeyboardAvoiding.macos.tsrx +7 -1
  75. package/src/KeyboardAvoiding.mobile.tsrx +2 -2
  76. package/src/KeyboardAvoiding.tsrx +15 -0
  77. package/src/KeyboardAvoiding.web.tsrx +15 -0
  78. package/src/Markdown.tsrx +121 -0
  79. package/src/Meter.tsrx +1 -0
  80. package/src/Overlay.tsrx +15 -30
  81. package/src/Overlay.web.tsrx +12 -3
  82. package/src/Pagination.tsrx +6 -4
  83. package/src/PinInput.macos.tsrx +20 -6
  84. package/src/PinInput.mobile.test.ts +107 -0
  85. package/src/PinInput.tsrx +20 -6
  86. package/src/PinInput.web.test.tsrx +42 -0
  87. package/src/PinInput.web.tsrx +20 -6
  88. package/src/Popover.tsrx +36 -22
  89. package/src/Pressable.tsrx +1 -1
  90. package/src/Pressable.web.test.tsrx +36 -0
  91. package/src/Pressable.web.tsrx +37 -6
  92. package/src/RouteHost.macos.tsrx +1 -0
  93. package/src/Screen.tsrx +1 -1
  94. package/src/SearchInput.tsrx +4 -3
  95. package/src/SearchInput.web.tsrx +1 -1
  96. package/src/Sheet.tsrx +21 -42
  97. package/src/Sheet.web.tsrx +9 -1
  98. package/src/Slider.tsrx +13 -11
  99. package/src/Text.tsrx +36 -0
  100. package/src/TextArea.tsrx +6 -5
  101. package/src/TextArea.web.test.tsrx +39 -0
  102. package/src/TextArea.web.tsrx +7 -5
  103. package/src/TextInput.tsrx +6 -6
  104. package/src/TextInput.web.tsrx +1 -1
  105. package/src/VirtualList.macos.tsrx +93 -6
  106. package/src/VirtualList.mobile.test.tsrx +232 -0
  107. package/src/VirtualList.tsrx +285 -81
  108. package/src/VirtualList.web.tsrx +131 -25
  109. package/src/WebView.tsrx +20 -10
  110. package/src/WebView.web.tsrx +3 -0
  111. package/src/android/BottomNavigationView.android.tsrx +1 -0
  112. package/src/android/MaterialDialog.android.tsrx +1 -0
  113. package/src/anim.macos.tsrx +1 -0
  114. package/src/apply-native-modifiers.native.ts +4 -1
  115. package/src/child-array.ts +12 -7
  116. package/src/colorScheme.macos.ts +1 -0
  117. package/src/controls.macos.tsrx +4 -1
  118. package/src/data-driver.mobile.test.ts +120 -0
  119. package/src/data-lifecycle.mobile.test.ts +251 -0
  120. package/src/escape-props.ts +13 -1
  121. package/src/grid-auto-placement.ts +163 -0
  122. package/src/grid-placement.ts +29 -0
  123. package/src/hosted-unsupported.macos.tsrx +1 -0
  124. package/src/index.macos.ts +23 -3
  125. package/src/index.mobile.ts +0 -1
  126. package/src/index.shared.ts +22 -1
  127. package/src/index.shared.web.ts +291 -0
  128. package/src/index.web.ts +1 -1
  129. package/src/input-readiness.web.test.tsrx +82 -0
  130. package/src/ios/UITabBar.ios.tsrx +1 -0
  131. package/src/keyboard-inset.mobile.test.ts +62 -0
  132. package/src/keyboard-inset.mobile.ts +63 -43
  133. package/src/layout-shells.macos.tsrx +1 -5
  134. package/src/macos-extras.macos.tsrx +2 -0
  135. package/src/modal-focus.web.test.tsrx +63 -0
  136. package/src/modal-focus.web.ts +162 -0
  137. package/src/navigation-request.ts +37 -0
  138. package/src/overlay-lifecycle.mobile.test.ts +316 -0
  139. package/src/overlay-lifetime.mobile.ts +107 -0
  140. package/src/pan.mobile.test.tsrx +116 -0
  141. package/src/pan.tsrx +20 -2
  142. package/src/pan.web.tsrx +35 -7
  143. package/src/platformProps.ts +1 -0
  144. package/src/popover.mobile.test.ts +9 -1
  145. package/src/pressable-accessibility.mobile.test.ts +30 -0
  146. package/src/props.ts +163 -3
  147. package/src/refresh.ts +6 -1
  148. package/src/refresh.web.test.ts +77 -77
  149. package/src/route-android.test.ts +162 -0
  150. package/src/route-history.web.test.ts +80 -0
  151. package/src/route-navigation.web.test.ts +173 -0
  152. package/src/route-table.test.ts +188 -9
  153. package/src/route-table.ts +138 -10
  154. package/src/route.macos.ts +56 -13
  155. package/src/route.ts +54 -24
  156. package/src/route.web.ts +141 -24
  157. package/src/sheet-detents.ts +5 -3
  158. package/src/sheet-service.macos.ts +5 -1
  159. package/src/sheet-service.ts +1 -1
  160. package/src/sheet-service.web.ts +8 -0
  161. package/src/sheet-snap.test.ts +23 -23
  162. package/src/sheet-snap.ts +6 -1
  163. package/src/svg.mobile.ts +9 -3
  164. package/src/tap-to-blur.ios.ts +106 -0
  165. package/src/tap-to-blur.mobile.ts +21 -95
  166. package/src/text-focus.ts +12 -0
  167. package/src/text-input.mobile.test.ts +89 -0
  168. package/src/text-write.ts +21 -11
  169. package/src/theme/colorScheme.linux.ts +2 -4
  170. package/src/theme/colorScheme.web.ts +51 -4
  171. package/src/theme/theme-scheme.macos.ts +8 -2
  172. package/src/theme/tokens.css +1740 -1718
  173. package/src/translate.macos.ts +4 -1
  174. package/src/useMeasure.tsrx +20 -21
  175. package/src/virtual-list-cell-pool.test.ts +53 -0
  176. package/src/virtual-list-cell-pool.ts +81 -0
  177. package/src/virtual-list-layout.test.ts +11 -8
  178. package/src/virtual-list-layout.ts +4 -8
  179. package/src/windows.linux.ts +7 -2
  180. package/src/windows.macos.ts +1 -0
  181. package/types/generated/.tsrx-typegen-manifest.json +16 -3
  182. package/types/generated/Grid.d.ts +0 -1
  183. package/types/generated/KeyboardAvoiding.d.ts +3 -0
  184. package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
  185. package/types/generated/Markdown.d.ts +59 -0
  186. package/types/generated/TextArea.d.ts +2 -2
  187. package/types/generated/TextArea.web.d.ts +1 -1
  188. package/types/generated/TextInput.d.ts +2 -3
  189. package/types/generated/VirtualList.d.ts +3 -3
  190. package/types/generated/escape-props.d.ts +4 -0
  191. package/types/generated/grid-auto-placement.d.ts +13 -0
  192. package/types/generated/grid-placement.d.ts +5 -0
  193. package/types/generated/index.mobile.d.ts +0 -1
  194. package/types/generated/index.shared.d.ts +6 -3
  195. package/types/generated/index.shared.web.d.ts +114 -0
  196. package/types/generated/index.web.d.ts +1 -1
  197. package/types/generated/modal-focus.web.d.ts +3 -0
  198. package/types/generated/navigation-request.d.ts +10 -0
  199. package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
  200. package/types/generated/props.d.ts +111 -3
  201. package/types/generated/route-table.d.ts +14 -3
  202. package/types/generated/svg.mobile.d.ts +2 -2
  203. package/types/generated/tap-to-blur.ios.d.ts +13 -0
  204. package/types/generated/tap-to-blur.mobile.d.ts +1 -15
  205. package/types/generated/text-focus.d.ts +3 -0
  206. package/types/generated/text-write.d.ts +3 -1
  207. package/types/generated/theme/colorScheme.web.d.ts +5 -0
  208. package/types/generated/virtual-list-cell-pool.d.ts +18 -0
  209. package/types/index.macos.d.ts +15 -3
  210. /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
  211. /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
  212. /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
  213. /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
  214. /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
  215. /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
  216. /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
  217. /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
  218. /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
  219. /package/types/generated/vendor/ui-svg/{index.ios.d.ts → src/index.ios.d.ts} +0 -0
@@ -9,12 +9,15 @@ import {
9
9
  type VirtualListEntry,
10
10
  type VirtualListSizeRecord,
11
11
  } from './virtual-list-layout'
12
+
13
+ import { VirtualListCellPool } from './virtual-list-cell-pool'
14
+
12
15
  import { applyEscapeProps } from './escape-props'
13
16
  import { layoutChildProps } from './layout-child.web'
14
17
 
15
18
  type NodeMeta =
16
19
  | { kind: 'viewport' }
17
- | { kind: 'row'; rowKey: string }
20
+ | { kind: 'row'; rowKey: string; cellId: number; generation: number }
18
21
  | { kind: 'header' }
19
22
  | { kind: 'footer' }
20
23
 
@@ -29,8 +32,13 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
29
32
  () => createVirtualListEntries(props.items, props.keyExtractor, props.getItemType),
30
33
  [props.items, props.keyExtractor, props.getItemType],
31
34
  )
35
+
32
36
  const [windowVersion, setWindowVersion] = useState(0)
33
37
  const sizeIndexRef = useRef(new VirtualListSizeIndex())
38
+ const cellsRef = useRef(new VirtualListCellPool<T>())
39
+ const cellCallbacksRef = useRef(new Map<number, (node: Element | null) => void>())
40
+ const measuredAssignmentsRef = useRef(new Map<number, number>())
41
+ const disposedRef = useRef(false)
34
42
  const sizeCacheRef = useRef(new Map<string, VirtualListSizeRecord>())
35
43
  const entriesRef = useRef<VirtualListEntry<T>[]>([])
36
44
  const indexByRowKeyRef = useRef(new Map<string, number>())
@@ -42,6 +50,8 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
42
50
  const footerHeightRef = useRef(0)
43
51
  const observerRef = useRef<ResizeObserver | null>(null)
44
52
  const estimateScheduledRef = useRef(false)
53
+ const resizeDeliveryRef = useRef(false)
54
+ const refreshAfterResizeRef = useRef(false)
45
55
  const nodesBySlotRef = useRef(new Map<string, Element>())
46
56
  const metaByNodeRef = useRef(new WeakMap<Element, NodeMeta>())
47
57
  const rangeRef = useRef({ start: -1, end: -1, top: -1, bottom: -1 })
@@ -61,6 +71,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
61
71
  entry.rowKey,
62
72
  hasSeparatorRef.current && entry.index < nextEntries.length - 1,
63
73
  )
74
+
64
75
  activeMeasurements.add(cacheKey)
65
76
  }
66
77
 
@@ -74,7 +85,11 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
74
85
  )
75
86
  }
76
87
 
77
- if (entries !== entriesRef.current) rebuildLayout(entries, viewportWidthRef.current)
88
+ if (entries !== entriesRef.current) {
89
+ const anchor = captureAnchor()
90
+ rebuildLayout(entries, viewportWidthRef.current)
91
+ restoreAnchor(anchor)
92
+ }
78
93
 
79
94
  function currentRange() {
80
95
  const listOffset = Math.max(0, scrollOffsetRef.current - headerHeightRef.current)
@@ -87,6 +102,12 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
87
102
  }
88
103
 
89
104
  function refreshWindow() {
105
+ if (disposedRef.current) return
106
+ if (resizeDeliveryRef.current) {
107
+ refreshAfterResizeRef.current = true
108
+ return
109
+ }
110
+
90
111
  const next = currentRange()
91
112
  const previous = rangeRef.current
92
113
  if (
@@ -101,7 +122,8 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
101
122
  }
102
123
 
103
124
  function captureAnchor() {
104
- const listOffset = scrollOffsetRef.current - headerHeightRef.current
125
+ const scrollOffset = scrollRef.current?.scrollTop ?? scrollOffsetRef.current
126
+ const listOffset = scrollOffset - headerHeightRef.current
105
127
  if (listOffset < 0 || entriesRef.current.length === 0) return null
106
128
 
107
129
  const index = sizeIndexRef.current.indexAt(listOffset)
@@ -122,6 +144,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
122
144
  0,
123
145
  headerHeightRef.current + sizeIndexRef.current.prefix(index) + anchor.within,
124
146
  )
147
+
125
148
  if (sameSize(nextOffset, scrollOffsetRef.current)) return
126
149
 
127
150
  scrollOffsetRef.current = nextOffset
@@ -133,6 +156,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
133
156
  estimateScheduledRef.current = true
134
157
  Promise.resolve().then(() => {
135
158
  estimateScheduledRef.current = false
159
+ if (disposedRef.current) return
136
160
  const anchor = captureAnchor()
137
161
  rebuildLayout(entriesRef.current, viewportWidthRef.current)
138
162
  restoreAnchor(anchor)
@@ -141,7 +165,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
141
165
  }
142
166
 
143
167
  function recordRowSize(rowKey: string, width: number, height: number) {
144
- if (!Number.isFinite(height) || height < 0) return
168
+ if (disposedRef.current || !Number.isFinite(height) || height < 0) return
145
169
  const index = indexByRowKeyRef.current.get(rowKey)
146
170
  if (index === undefined) return
147
171
  const entry = entriesRef.current[index]
@@ -156,6 +180,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
156
180
  if (anchor && index < anchor.index) restoreAnchor(anchor)
157
181
  refreshWindow()
158
182
  }
183
+
159
184
  scheduleEstimateRebuild()
160
185
  }
161
186
 
@@ -166,12 +191,14 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
166
191
  metaByNodeRef.current.delete(previous)
167
192
  nodesBySlotRef.current.delete(slot)
168
193
  }
194
+
169
195
  if (!node) {
170
196
  if (previous) {
171
197
  observerRef.current?.unobserve(previous)
172
198
  metaByNodeRef.current.delete(previous)
173
199
  nodesBySlotRef.current.delete(slot)
174
200
  }
201
+
175
202
  return
176
203
  }
177
204
 
@@ -180,6 +207,24 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
180
207
  observerRef.current?.observe(node)
181
208
  }
182
209
 
210
+ function cellCallback(id: number) {
211
+ let callback = cellCallbacksRef.current.get(id)
212
+ if (!callback) {
213
+ callback = (node: Element | null) => {
214
+ const cell = cellsRef.current.get(id)
215
+ bindObservedNode('cell:' + id, node, {
216
+ kind: 'row', rowKey: cell?.entry?.rowKey ?? '', cellId: id, generation: cell?.generation ?? 0,
217
+ })
218
+
219
+ if (!node) cellCallbacksRef.current.delete(id)
220
+ }
221
+
222
+ cellCallbacksRef.current.set(id, callback)
223
+ }
224
+
225
+ return callback
226
+ }
227
+
183
228
  handlersRef.current = (meta, entry) => {
184
229
  if (meta.kind === 'viewport') {
185
230
  const width = scrollRef.current?.clientWidth ?? entry.contentRect.width
@@ -192,13 +237,18 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
192
237
  rebuildLayout(entriesRef.current, width)
193
238
  restoreAnchor(anchor)
194
239
  }
240
+
195
241
  refreshWindow()
196
242
  return
197
243
  }
198
244
 
199
245
  const height = entry.contentRect.height
200
246
  if (meta.kind === 'row') {
201
- recordRowSize(meta.rowKey, entry.contentRect.width, height)
247
+ if (!cellsRef.current.isCurrent(meta.cellId, meta.rowKey, meta.generation)) return
248
+ // A reused node may have an older ResizeObserver record. Read its
249
+ // current box after assignment rather than caching the former row.
250
+ const bounds = entry.target.getBoundingClientRect()
251
+ recordRowSize(meta.rowKey, bounds.width, bounds.height)
202
252
  return
203
253
  }
204
254
 
@@ -213,20 +263,50 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
213
263
  }
214
264
  }
215
265
 
266
+ useEffect(() => {
267
+ disposedRef.current = false
268
+ return () => {
269
+ disposedRef.current = true
270
+ cellsRef.current.clear()
271
+ cellCallbacksRef.current.clear()
272
+ measuredAssignmentsRef.current.clear()
273
+ nodesBySlotRef.current.clear()
274
+ metaByNodeRef.current = new WeakMap()
275
+ }
276
+ }, [])
277
+
216
278
  useEffect(() => {
217
279
  if (typeof ResizeObserver === 'undefined') return
218
280
 
281
+ // Apply measurements and anchor corrections while the previous layout is
282
+ // still current, but commit the resulting render after ResizeObserver.
283
+ let frame: number | null = null
219
284
  const observer = new ResizeObserver((records) => {
220
- for (const record of records) {
221
- const meta = metaByNodeRef.current.get(record.target)
222
- if (meta) handlersRef.current(meta, record)
285
+ resizeDeliveryRef.current = true
286
+ try {
287
+ for (const record of records) {
288
+ const meta = metaByNodeRef.current.get(record.target)
289
+ if (meta) handlersRef.current(meta, record)
290
+ }
291
+ } finally {
292
+ resizeDeliveryRef.current = false
223
293
  }
294
+
295
+ if (!refreshAfterResizeRef.current || frame !== null) return
296
+ refreshAfterResizeRef.current = false
297
+ frame = requestAnimationFrame(() => {
298
+ frame = null
299
+ refreshWindow()
300
+ })
224
301
  })
302
+
225
303
  observerRef.current = observer
226
304
  for (const node of nodesBySlotRef.current.values()) observer.observe(node)
227
305
 
228
306
  return () => {
229
307
  observer.disconnect()
308
+ if (frame !== null) cancelAnimationFrame(frame)
309
+ refreshAfterResizeRef.current = false
230
310
  observerRef.current = null
231
311
  }
232
312
  }, [])
@@ -240,7 +320,33 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
240
320
  const range = currentRange()
241
321
  rangeRef.current = range
242
322
  void windowVersion
243
- const visibleEntries = entries.slice(range.start, range.end)
323
+ const cells = cellsRef.current.update(entries.slice(range.start, range.end)).map((cell) => ({
324
+ ...cell, top: cell.entry ? sizeIndexRef.current.prefix(cell.entry.index) : 0,
325
+ }))
326
+
327
+ const contentHeight = sizeIndexRef.current.total
328
+ for (const cell of cells) {
329
+ const node = nodesBySlotRef.current.get('cell:' + cell.id)
330
+ if (node) metaByNodeRef.current.set(node, {
331
+ kind: 'row', rowKey: cell.entry?.rowKey ?? '', cellId: cell.id, generation: cell.generation,
332
+ })
333
+ }
334
+
335
+ useEffect(() => {
336
+ for (const cell of cells) {
337
+ if (!cell.entry || measuredAssignmentsRef.current.get(cell.id) === cell.generation) continue
338
+ const node = nodesBySlotRef.current.get('cell:' + cell.id)
339
+ if (!node || !cellsRef.current.isCurrent(cell.id, cell.entry.rowKey, cell.generation)) continue
340
+ const bounds = node.getBoundingClientRect()
341
+ measuredAssignmentsRef.current.set(cell.id, cell.generation)
342
+ recordRowSize(cell.entry.rowKey, bounds.width, bounds.height)
343
+ }
344
+
345
+ for (const id of measuredAssignmentsRef.current.keys()) {
346
+ if (!nodesBySlotRef.current.has('cell:' + id)) measuredAssignmentsRef.current.delete(id)
347
+ }
348
+ }, [cells])
349
+
244
350
  const style = {
245
351
  ...props.style,
246
352
  minHeight: props.style?.minHeight ?? 0,
@@ -280,26 +386,26 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
280
386
  {entries.length === 0 ? (
281
387
  props.renderEmpty ? <div className="vx-virtual-list-empty">{props.renderEmpty()}</div> : null
282
388
  ) : (
283
- <>
284
- <div className="vx-virtual-list-spacer" style={{ height: range.top }} aria-hidden="true" />
285
- @for (const entry of visibleEntries; key entry.rowKey) {
389
+ <div className="vx-virtual-list-body" style={{ position: 'relative', height: contentHeight }}>
390
+ @for (const cell of cells; key cell.id) {
286
391
  <div
287
- className="vx-virtual-list-row"
288
- data-vx-row="true"
289
- ref={(node: any) => bindObservedNode(
290
- 'row:' + virtualListMeasurementKey(entry.rowKey, hasSeparator && entry.index < entries.length - 1),
291
- node,
292
- { kind: 'row', rowKey: entry.rowKey },
293
- )}
392
+ className={cell.entry ? 'vx-virtual-list-cell vx-virtual-list-row' : 'vx-virtual-list-cell'}
393
+ data-vx-row={cell.entry ? 'true' : undefined}
394
+ hidden={!cell.entry}
395
+ style={{ position: 'absolute', top: cell.top, left: 0, width: '100%' }}
396
+ ref={cellCallback(cell.id)}
294
397
  >
295
- <div className="vx-virtual-list-item">{props.renderItem(entry.item, entry.index)}</div>
296
- {hasSeparator && entry.index < entries.length - 1 ? (
297
- <div className="vx-virtual-list-separator">{props.renderSeparator?.()}</div>
298
- ) : null}
398
+ @for (const entry of cell.rows; key entry.rowKey) {
399
+ <>
400
+ <div className="vx-virtual-list-item">{props.renderItem(entry.item, entry.index)}</div>
401
+ {hasSeparator && entry.index < entries.length - 1 ? (
402
+ <div className="vx-virtual-list-separator">{props.renderSeparator?.()}</div>
403
+ ) : null}
404
+ </>
405
+ }
299
406
  </div>
300
407
  }
301
- <div className="vx-virtual-list-spacer" style={{ height: range.bottom }} aria-hidden="true" />
302
- </>
408
+ </div>
303
409
  )}
304
410
  {props.renderFooter ? (
305
411
  <div
package/src/WebView.tsrx CHANGED
@@ -16,6 +16,7 @@ function reportContentLayout(view: any, props: WebViewProps) {
16
16
  } else if (isAndroid) {
17
17
  height = Number(view?.android?.getContentHeight?.() ?? 0) * Number(view?.android?.getScale?.() ?? 1)
18
18
  }
19
+
19
20
  if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return
20
21
  props.onLayoutContent?.({ width, height })
21
22
  if (props.matchContents) {
@@ -24,6 +25,22 @@ function reportContentLayout(view: any, props: WebViewProps) {
24
25
  }
25
26
  }
26
27
 
28
+ function applyNativeScrollEnabled(view: any, enabled: boolean | undefined) {
29
+ if (isIOS) {
30
+ const scrollView = view?.ios?.scrollView
31
+ if (scrollView) scrollView.scrollEnabled = enabled !== false
32
+ } else if (isAndroid) {
33
+ const nativeView = view?.android
34
+ if (!nativeView?.setOnTouchListener) return
35
+ nativeView.setOnTouchListener(enabled === false
36
+ ? new android.view.View.OnTouchListener({
37
+ onTouch: (_view: any, event: any) =>
38
+ event.getActionMasked() === android.view.MotionEvent.ACTION_MOVE,
39
+ })
40
+ : null)
41
+ }
42
+ }
43
+
27
44
  /** Embedded web document — NS <webview> (WKWebView / android.webkit.WebView).
28
45
  * `html` flows through the `src` property: values that aren't http/file
29
46
  * URLs take its `_loadData` path. NS reports failures as `loadFinished`
@@ -39,16 +56,7 @@ export function WebView(props: WebViewProps) @{
39
56
  useEffect(() => {
40
57
  const v = el.current;
41
58
  if (!v) return;
42
- if (isIOS) {
43
- v.ios.scrollView.scrollEnabled = props.scrollEnabled !== false;
44
- } else if (isAndroid) {
45
- v.android.setOnTouchListener(props.scrollEnabled === false
46
- ? new android.view.View.OnTouchListener({
47
- onTouch: (_view: any, event: any) =>
48
- event.getActionMasked() === android.view.MotionEvent.ACTION_MOVE,
49
- })
50
- : null);
51
- }
59
+ applyNativeScrollEnabled(v, props.scrollEnabled)
52
60
  if (props.matchContents && loaded.current) reportContentLayout(v, props);
53
61
  }, [props.scrollEnabled, props.matchContents]);
54
62
 
@@ -70,7 +78,9 @@ export function WebView(props: WebViewProps) @{
70
78
  className={cx('vx-webview', props.className)}
71
79
  style={props.style}
72
80
  src={props.html !== undefined ? props.html : props.src}
81
+ onLoaded={() => applyNativeScrollEnabled(el.current, props.scrollEnabled)}
73
82
  onLoadFinished={(e: any) => {
83
+ applyNativeScrollEnabled(e.object ?? el.current, props.scrollEnabled)
74
84
  if (e.error) props.onError?.({ url: e.url, error: e.error });
75
85
  else {
76
86
  loaded.current = true;
@@ -33,6 +33,7 @@ export function WebView(props: WebViewProps) @{
33
33
  },
34
34
  native: el,
35
35
  });
36
+
36
37
  useEffect(() => {
37
38
  const el = frame.current;
38
39
  if (!el) return;
@@ -48,6 +49,7 @@ export function WebView(props: WebViewProps) @{
48
49
  width: Math.max(body.scrollWidth, root.scrollWidth),
49
50
  height: Math.max(body.scrollHeight, root.scrollHeight),
50
51
  };
52
+
51
53
  if (size.width <= 0 || size.height <= 0) return;
52
54
  if (props.matchContents) el.style.height = `${size.height}px`;
53
55
  props.onLayoutContent?.(size);
@@ -62,6 +64,7 @@ export function WebView(props: WebViewProps) @{
62
64
  // Cross-origin documents cannot be inspected by the embedding page.
63
65
  }
64
66
  };
67
+
65
68
  el.addEventListener('load', measure);
66
69
  measure();
67
70
  return () => {
@@ -47,6 +47,7 @@ function applyIcon(item: TabViewItem, name: string): void {
47
47
  item.iconSource = name;
48
48
  return;
49
49
  }
50
+
50
51
  const glyph = getIcon(name);
51
52
  if (!glyph) {
52
53
  warnIconOnce(name, `tab icon '${name}' isn't registered — call registerIcons() at boot.`);
@@ -31,6 +31,7 @@ export function MaterialDialog(props: ModalProps) @{
31
31
  console.warn('[Modal] open dropped — no live presenter (currentPage mid-navigation or no active frame).');
32
32
  return;
33
33
  }
34
+
34
35
  const host = new GridLayout();
35
36
  const unbindTheme = applyThemeClasses(host, 'vx-modal-host');
36
37
  const root = createNativeScriptRoot(host);
@@ -10,6 +10,7 @@ export function useAnimation(initial = 0, property = 'opacity'): AnimatedValue {
10
10
  value = next
11
11
  if (view) {view[property] = next}
12
12
  }
13
+
13
14
  return {
14
15
  get value() { return value },
15
16
  bind(el: any) { view = el; write(value) },
@@ -2,7 +2,10 @@ import type { NativeModifier } from './props'
2
2
 
3
3
  /** Apply serializable modifiers to a NativeScript host view in array order. */
4
4
  export function applyNativeModifiers(view: any, modifiers?: readonly NativeModifier[]): void {
5
- if (!view || !modifiers?.length) return
5
+ if (!view || !modifiers?.length) {
6
+ return
7
+ }
8
+
6
9
  for (const entry of modifiers) {
7
10
  if (entry.type === 'style') {
8
11
  Object.assign(view.style, entry.values)
@@ -1,16 +1,21 @@
1
1
  /** Normalize a JSX children value to a flat array — children may be a
2
2
  * single node, an array, or nested arrays from `.map` calls. */
3
3
  export function toChildArray(children: any): any[] {
4
- const out: any[] = [];
4
+ const out: any[] = []
5
5
  const walk = (node: any) => {
6
- if (node == null || node === false) { return; }
6
+ if (node == null || node === false) {
7
+ return
8
+ }
9
+
7
10
  if (Array.isArray(node)) {
8
- for (const child of node) { walk(child); }
11
+ for (const child of node) {
12
+ walk(child)
13
+ }
9
14
  } else {
10
- out.push(node);
15
+ out.push(node)
11
16
  }
12
- };
17
+ }
13
18
 
14
- walk(children);
15
- return out;
19
+ walk(children)
20
+ return out
16
21
  }
@@ -19,6 +19,7 @@ export function subscribeSystemScheme(callback: () => void): () => void {
19
19
  appearanceListener = () => listeners.forEach((listener) => listener())
20
20
  host()?.onAppearanceChange?.(appearanceListener)
21
21
  }
22
+
22
23
  return () => listeners.delete(callback)
23
24
  }
24
25
 
@@ -24,6 +24,7 @@ export function Button(props: ButtonProps) @{
24
24
  export function Checkbox(props: CheckboxProps) @{
25
25
  const checked = props.checked ?? false
26
26
  const colors = controlColors(useThemeScheme() === 'dark')
27
+
27
28
  <flexboxlayout
28
29
  id={props.id}
29
30
  flexDirection="row"
@@ -49,7 +50,7 @@ export function Checkbox(props: CheckboxProps) @{
49
50
  ...(checked ? { backgroundColor: colors.primary } : {}),
50
51
  }}
51
52
  >
52
- {checked ? <Text className="vx-checkbox-check" style={{ fontFamily: 'system-ui', fontSize: 14, lineHeight: 20, color: colors.onPrimary }}>✓</Text> : null}
53
+ {checked ? <Text className="vx-checkbox-check" style={{ fontFamily: 'system-ui', fontSize: 14, lineHeight: '20px', color: colors.onPrimary }}>✓</Text> : null}
53
54
  </flexboxlayout>
54
55
  {props.label != null ? <Text className="vx-checkbox-label">{props.label}</Text> : null}
55
56
  </flexboxlayout>
@@ -58,6 +59,7 @@ export function Checkbox(props: CheckboxProps) @{
58
59
  export function Switch(props: SwitchProps) @{
59
60
  const checked = props.checked ?? false
60
61
  const colors = controlColors(useThemeScheme() === 'dark')
62
+
61
63
  <flexboxlayout
62
64
  id={props.id}
63
65
  flexDirection="row"
@@ -108,6 +110,7 @@ export function Slider(props: SliderProps) @{
108
110
  setDragValue(null)
109
111
  }
110
112
  })
113
+
111
114
  const adjustValue = (direction: number) => {
112
115
  if (props.disabled) {return}
113
116
  const next = Math.min(max, Math.max(min, value + direction * ((max - min) / 100)))
@@ -0,0 +1,120 @@
1
+ import { expect, it, vi } from 'vitest'
2
+ import { createUniversalRoot, flushUniversalSync } from 'octane/universal/native'
3
+ import { Retained } from '../tests/data-lifecycle.fixture.mobile.tsrx'
4
+
5
+ // Only the NativeScript host objects are substituted. The installed driver,
6
+ // compiler output, scheduler, events, and visibility commands are real.
7
+ vi.mock('@nativescript/core', () => {
8
+ class View {
9
+ visibility = 'visible'
10
+ parent: View | null = null
11
+ text = ''
12
+ listeners = new Map<string, Set<(data: unknown) => void>>()
13
+ on(type: string, fn: (data: unknown) => void) {
14
+ if (!this.listeners.has(type)) {
15
+ this.listeners.set(type, new Set())
16
+ }
17
+
18
+ this.listeners.get(type)!.add(fn)
19
+ }
20
+ off(type: string, fn: (data: unknown) => void) {
21
+ this.listeners.get(type)?.delete(fn)
22
+ }
23
+ notify(data: { eventName: string }) {
24
+ for (const fn of this.listeners.get(data.eventName) ?? []) {
25
+ fn(data)
26
+ }
27
+ }
28
+ }
29
+
30
+ class LayoutBase extends View {
31
+ children: View[] = []
32
+ addChild(view: View) {
33
+ this.children.push(view)
34
+ view.parent = this
35
+ }
36
+ insertChild(view: View, index: number) {
37
+ this.children.splice(index, 0, view)
38
+ view.parent = this
39
+ }
40
+ removeChild(view: View) {
41
+ this.children.splice(this.children.indexOf(view), 1)
42
+ view.parent = null
43
+ }
44
+ getChildrenCount() {
45
+ return this.children.length
46
+ }
47
+ getChildIndex(view: View) {
48
+ return this.children.indexOf(view)
49
+ }
50
+ }
51
+
52
+ class ContentView extends View {
53
+ content: View | null = null
54
+ }
55
+
56
+ class TextBase extends View {}
57
+ const named = Object.fromEntries(
58
+ 'AbsoluteLayout ActionBar ActionItem ActivityIndicator Button LiquidGlass DatePicker DockLayout FlexboxLayout FormattedString Frame GridLayout HtmlView Image Label ListPicker ListView NavigationButton Page Placeholder Progress ProxyViewContainer RootLayout ScrollView SearchBar SegmentedBar SegmentedBarItem Slider Span StackLayout Switch TabView TabViewItem TextField TextView TimePicker ViewBase WebView WrapLayout'
59
+ .split(' ')
60
+ .map((name) => [name, class extends View {}]),
61
+ )
62
+
63
+ return {
64
+ ...named,
65
+ View,
66
+ LayoutBase,
67
+ ContentView,
68
+ TextBase,
69
+ Button: TextBase,
70
+ Label: TextBase,
71
+ unsetValue: Symbol('unset'),
72
+ }
73
+ })
74
+
75
+ it('NativeScript driver permits retained suspense and delivers native taps and uncaught errors', async () => {
76
+ // A direct driver import bypasses the object-driver alias for the renderer
77
+ // entry. This is the installed package that the mobile app actually uses.
78
+ const { nativeScriptDriver, createNativeScriptContainer, releaseNativeScriptContainer } =
79
+ await import('../node_modules/@nativescript-community/octane/dist/driver.js')
80
+
81
+ const { LayoutBase } = await import('@nativescript/core')
82
+ expect(nativeScriptDriver.capabilities.visibility).toBe(true)
83
+ const host = new LayoutBase()
84
+ const container = createNativeScriptContainer(host)
85
+ const uncaught = vi.fn()
86
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
87
+ const root = createUniversalRoot(container, nativeScriptDriver, { onUncaughtError: uncaught })
88
+ container.root = root
89
+ let resolve!: (value: string) => void
90
+ const pending = new Promise<string>((done) => {
91
+ resolve = done
92
+ })
93
+
94
+ const error = new Error('native handler failure')
95
+ const fail = vi.fn(() => {
96
+ throw error
97
+ })
98
+
99
+ try {
100
+ root.render(Retained, { pending, fail })
101
+ const body = (host as any).children[0]
102
+ body.notify({ eventName: 'tap' })
103
+ flushUniversalSync(() => {})
104
+ expect((host as any).children[0]).toBe(body)
105
+ expect(body.visibility).toBe('collapse')
106
+ expect((host as any).children[1].text).toBe('pending')
107
+ resolve('resolved')
108
+ await vi.waitFor(() => expect(body.text).toBe('resolved'))
109
+ expect(body.visibility).toBe('visible')
110
+
111
+ ;(host as any).children[1].notify({ eventName: 'tap' })
112
+ expect(fail).toHaveBeenCalledOnce()
113
+ expect(uncaught).toHaveBeenCalledWith(error)
114
+ expect(warning).not.toHaveBeenCalled()
115
+ } finally {
116
+ root.unmount()
117
+ releaseNativeScriptContainer(container)
118
+ warning.mockRestore()
119
+ }
120
+ })