@octane-xplat/ui 0.7.3 → 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 (195) hide show
  1. package/README.md +4 -3
  2. package/dist/native/Button.tsrx.js +1 -1
  3. package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
  4. package/dist/native/Markdown.tsrx.js +233 -0
  5. package/dist/native/Overlay.tsrx.js +15 -26
  6. package/dist/native/PinInput.tsrx.js +25 -7
  7. package/dist/native/Popover.tsrx.js +17 -18
  8. package/dist/native/Pressable.tsrx.js +4 -1
  9. package/dist/native/SearchInput.tsrx.js +6 -3
  10. package/dist/native/Sheet.tsrx.js +22 -37
  11. package/dist/native/TextArea.tsrx.js +6 -3
  12. package/dist/native/TextInput.tsrx.js +6 -3
  13. package/dist/native/VirtualList.tsrx.js +305 -121
  14. package/dist/native/index.shared.js +7 -5
  15. package/dist/native/keyboard-inset.mobile.js +5 -5
  16. package/dist/native/navigation-request.js +26 -0
  17. package/dist/native/octane-client-build.json +1 -1
  18. package/dist/native/overlay-lifetime.mobile.js +65 -0
  19. package/dist/native/pan.tsrx.js +16 -2
  20. package/dist/native/route-table.js +90 -10
  21. package/dist/native/route.js +26 -16
  22. package/dist/native/shared.js +7 -5
  23. package/dist/native/sheet-service.js +1 -1
  24. package/dist/native/svg.mobile.js +1 -1
  25. package/dist/native/text-focus.js +10 -0
  26. package/dist/native/text-write.js +18 -11
  27. package/dist/native/toast-anchor.tsrx.js +1 -1
  28. package/dist/native/ui.js +7 -6
  29. package/dist/native/vendor/ui-svg/index.android.js +2 -2
  30. package/dist/native/vendor/ui-svg/index.ios.js +2 -2
  31. package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
  32. package/dist/native/virtual-list-cell-pool.js +63 -0
  33. package/dist/web/Button.web.tsrx.js +1 -1
  34. package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
  35. package/dist/web/Markdown.tsrx.js +98 -0
  36. package/dist/web/Overlay.web.tsrx.js +81 -32
  37. package/dist/web/PinInput.web.tsrx.js +24 -10
  38. package/dist/web/Pressable.web.tsrx.js +169 -53
  39. package/dist/web/SearchInput.web.tsrx.js +2 -2
  40. package/dist/web/Sheet.web.tsrx.js +20 -7
  41. package/dist/web/TextArea.web.tsrx.js +22 -17
  42. package/dist/web/TextInput.web.tsrx.js +2 -2
  43. package/dist/web/VirtualList.web.tsrx.js +220 -109
  44. package/dist/web/WebView.web.tsrx.js +3 -3
  45. package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
  46. package/dist/web/modal-focus.web.js +95 -0
  47. package/dist/web/navigation-request.js +26 -0
  48. package/dist/web/octane-client-build.json +1 -1
  49. package/dist/web/pan.web.tsrx.js +27 -10
  50. package/dist/web/route-table.js +90 -10
  51. package/dist/web/route.web.js +88 -22
  52. package/dist/web/sheet-service.web.js +8 -0
  53. package/dist/web/theme/colorScheme.web.js +31 -4
  54. package/dist/web/ui.js +8 -6
  55. package/dist/web/virtual-list-cell-pool.js +63 -0
  56. package/package.json +21 -19
  57. package/platforms/android/native-api-usage.json +7 -7
  58. package/src/CommandPalette.tsrx +1 -0
  59. package/src/Grid.tsrx +2 -0
  60. package/src/Icon.macos.tsrx +1 -0
  61. package/src/KeyboardAvoiding.macos.tsrx +7 -1
  62. package/src/KeyboardAvoiding.mobile.tsrx +2 -2
  63. package/src/KeyboardAvoiding.tsrx +15 -0
  64. package/src/KeyboardAvoiding.web.tsrx +15 -0
  65. package/src/Markdown.tsrx +121 -0
  66. package/src/Meter.tsrx +1 -0
  67. package/src/Overlay.tsrx +15 -30
  68. package/src/Overlay.web.tsrx +12 -3
  69. package/src/PinInput.macos.tsrx +20 -6
  70. package/src/PinInput.mobile.test.ts +107 -0
  71. package/src/PinInput.tsrx +20 -6
  72. package/src/PinInput.web.test.tsrx +42 -0
  73. package/src/PinInput.web.tsrx +20 -6
  74. package/src/Popover.tsrx +17 -17
  75. package/src/Pressable.tsrx +1 -1
  76. package/src/Pressable.web.test.tsrx +36 -0
  77. package/src/Pressable.web.tsrx +37 -6
  78. package/src/RouteHost.macos.tsrx +1 -0
  79. package/src/SearchInput.tsrx +4 -3
  80. package/src/SearchInput.web.tsrx +1 -1
  81. package/src/Sheet.tsrx +20 -41
  82. package/src/Sheet.web.tsrx +9 -1
  83. package/src/Text.tsrx +2 -0
  84. package/src/TextArea.tsrx +6 -5
  85. package/src/TextArea.web.test.tsrx +39 -0
  86. package/src/TextArea.web.tsrx +7 -5
  87. package/src/TextInput.tsrx +6 -6
  88. package/src/TextInput.web.tsrx +1 -1
  89. package/src/VirtualList.macos.tsrx +93 -6
  90. package/src/VirtualList.mobile.test.tsrx +232 -0
  91. package/src/VirtualList.tsrx +285 -81
  92. package/src/VirtualList.web.tsrx +131 -25
  93. package/src/WebView.tsrx +1 -0
  94. package/src/WebView.web.tsrx +3 -0
  95. package/src/android/BottomNavigationView.android.tsrx +1 -0
  96. package/src/android/MaterialDialog.android.tsrx +1 -0
  97. package/src/anim.macos.tsrx +1 -0
  98. package/src/apply-native-modifiers.native.ts +4 -1
  99. package/src/child-array.ts +12 -7
  100. package/src/colorScheme.macos.ts +1 -0
  101. package/src/controls.macos.tsrx +4 -1
  102. package/src/data-driver.mobile.test.ts +120 -0
  103. package/src/data-lifecycle.mobile.test.ts +251 -0
  104. package/src/escape-props.ts +2 -0
  105. package/src/grid-auto-placement.ts +54 -12
  106. package/src/grid-placement.ts +1 -1
  107. package/src/hosted-unsupported.macos.tsrx +1 -0
  108. package/src/index.macos.ts +23 -3
  109. package/src/index.mobile.ts +0 -1
  110. package/src/index.shared.ts +22 -1
  111. package/src/index.shared.web.ts +291 -0
  112. package/src/index.web.ts +1 -1
  113. package/src/input-readiness.web.test.tsrx +82 -0
  114. package/src/ios/UITabBar.ios.tsrx +1 -0
  115. package/src/keyboard-inset.mobile.test.ts +62 -0
  116. package/src/keyboard-inset.mobile.ts +63 -43
  117. package/src/layout-shells.macos.tsrx +1 -5
  118. package/src/macos-extras.macos.tsrx +2 -0
  119. package/src/modal-focus.web.test.tsrx +63 -0
  120. package/src/modal-focus.web.ts +162 -0
  121. package/src/navigation-request.ts +37 -0
  122. package/src/overlay-lifecycle.mobile.test.ts +316 -0
  123. package/src/overlay-lifetime.mobile.ts +107 -0
  124. package/src/pan.mobile.test.tsrx +116 -0
  125. package/src/pan.tsrx +20 -2
  126. package/src/pan.web.tsrx +35 -7
  127. package/src/platformProps.ts +1 -0
  128. package/src/popover.mobile.test.ts +9 -1
  129. package/src/pressable-accessibility.mobile.test.ts +30 -0
  130. package/src/props.ts +163 -3
  131. package/src/refresh.ts +6 -1
  132. package/src/refresh.web.test.ts +77 -77
  133. package/src/route-android.test.ts +162 -0
  134. package/src/route-history.web.test.ts +80 -0
  135. package/src/route-navigation.web.test.ts +173 -0
  136. package/src/route-table.test.ts +188 -9
  137. package/src/route-table.ts +138 -10
  138. package/src/route.macos.ts +56 -13
  139. package/src/route.ts +54 -24
  140. package/src/route.web.ts +141 -24
  141. package/src/sheet-detents.ts +5 -3
  142. package/src/sheet-service.macos.ts +5 -1
  143. package/src/sheet-service.web.ts +8 -0
  144. package/src/sheet-snap.test.ts +23 -23
  145. package/src/sheet-snap.ts +6 -1
  146. package/src/svg.mobile.ts +9 -3
  147. package/src/tap-to-blur.ios.ts +48 -32
  148. package/src/tap-to-blur.mobile.ts +23 -15
  149. package/src/text-focus.ts +12 -0
  150. package/src/text-input.mobile.test.ts +89 -0
  151. package/src/text-write.ts +21 -11
  152. package/src/theme/colorScheme.linux.ts +2 -4
  153. package/src/theme/colorScheme.web.ts +51 -4
  154. package/src/theme/theme-scheme.macos.ts +8 -2
  155. package/src/theme/tokens.css +1736 -1730
  156. package/src/translate.macos.ts +4 -1
  157. package/src/virtual-list-cell-pool.test.ts +53 -0
  158. package/src/virtual-list-cell-pool.ts +81 -0
  159. package/src/virtual-list-layout.test.ts +11 -8
  160. package/src/virtual-list-layout.ts +4 -8
  161. package/src/windows.linux.ts +7 -2
  162. package/src/windows.macos.ts +1 -0
  163. package/types/generated/.tsrx-typegen-manifest.json +13 -3
  164. package/types/generated/KeyboardAvoiding.d.ts +3 -0
  165. package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
  166. package/types/generated/Markdown.d.ts +59 -0
  167. package/types/generated/TextArea.d.ts +2 -2
  168. package/types/generated/TextArea.web.d.ts +1 -1
  169. package/types/generated/TextInput.d.ts +2 -3
  170. package/types/generated/VirtualList.d.ts +3 -3
  171. package/types/generated/index.mobile.d.ts +0 -1
  172. package/types/generated/index.shared.d.ts +6 -3
  173. package/types/generated/index.shared.web.d.ts +114 -0
  174. package/types/generated/index.web.d.ts +1 -1
  175. package/types/generated/modal-focus.web.d.ts +3 -0
  176. package/types/generated/navigation-request.d.ts +10 -0
  177. package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
  178. package/types/generated/props.d.ts +111 -3
  179. package/types/generated/route-table.d.ts +14 -3
  180. package/types/generated/svg.mobile.d.ts +2 -2
  181. package/types/generated/text-focus.d.ts +3 -0
  182. package/types/generated/text-write.d.ts +3 -1
  183. package/types/generated/theme/colorScheme.web.d.ts +5 -0
  184. package/types/generated/virtual-list-cell-pool.d.ts +18 -0
  185. package/types/index.macos.d.ts +15 -3
  186. /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
  187. /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
  188. /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
  189. /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
  190. /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
  191. /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
  192. /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
  193. /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
  194. /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
  195. /package/types/generated/vendor/ui-svg/{index.ios.d.ts → src/index.ios.d.ts} +0 -0
package/src/TextArea.tsrx CHANGED
@@ -4,14 +4,15 @@ import { Utils } from '@nativescript/core';
4
4
  import type { TextAreaProps } from "./props";
5
5
  import { cx } from "./cx";
6
6
  import { applyEscapeProps } from "./escape-props";
7
- import { writeText } from './text-write';
7
+ import { isWritingText, writeText } from './text-write';
8
+ import { blurText } from './text-focus';
8
9
 
9
10
  /** Multiline input — <textview>. TextView measures to content natively, so
10
11
  * `autoGrow` is its default; `rows`/`maxRows` have no NS equivalent, so the
11
12
  * leaf converts them to min/maxHeight in dips using the widget's own line
12
13
  * height (iOS `font.lineHeight`, Android `getLineHeight()` px→dip — measured
13
- * on `loaded`, re-fit every commit for prop/style changes). The driver's
14
- * text→textChange echo suppression applies here unchanged. */
14
+ * on `loaded`, re-fit every commit for prop/style changes). Imperative
15
+ * writes suppress their synchronous textChange echo through text-write. */
15
16
  export function TextArea(props: TextAreaProps) @{
16
17
  const el = useRef<any>(null);
17
18
  const submitOnReturn = props.returnKeyType === 'done' || props.returnKeyType === 'send';
@@ -46,7 +47,7 @@ export function TextArea(props: TextAreaProps) @{
46
47
  el.current = v;
47
48
  if (!v) return;
48
49
  applyEscapeProps(v, props);
49
- props.bind?.({ focus: () => v.focus(), blur: () => v.dismissSoftInput?.() ?? v.blur(), native: v });
50
+ props.bind?.({ focus: () => v.focus(), blur: () => blurText(v), native: v });
50
51
  }}
51
52
  className={cx('vx-textarea', props.className)}
52
53
  style={{ ...props.style, ...(props.placeholderTextColor ? { placeholderColor: props.placeholderTextColor } : {}) }}
@@ -55,7 +56,7 @@ export function TextArea(props: TextAreaProps) @{
55
56
  returnKeyType={props.returnKeyType}
56
57
  editable={props.editable ?? true}
57
58
  onLoaded={(e: any) => fit(e.object)}
58
- onTextChange={(e: any) => props.onChange?.(e.value)}
59
+ onTextChange={(e: any) => { if (!isWritingText(e.object)) props.onChange?.(e.value); }}
59
60
  // TextView emits returnPress for every newline. Only an explicit action
60
61
  // key opts into the submit semantic; ordinary Return remains a newline.
61
62
  onReturnPress={submitOnReturn ? props.onSubmit : undefined}
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from 'vitest'
3
+ import { act, createRoot } from 'octane'
4
+ import { TextArea } from './TextArea.web.tsrx'
5
+ const roots:any[]=[]
6
+ function mount(props:any={}) {
7
+ const el=document.createElement('div');document.body.append(el);const root=createRoot(el);roots.push(root)
8
+ let measured=20
9
+ const render=(next:any={}) => act(() => root.render(<TextArea {...props} style={{fontSize:10,lineHeight:"10px",padding:0,borderWidth:0,boxSizing:'border-box'}} {...next} />));
10
+ const descriptor=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'scrollHeight')
11
+ Object.defineProperty(HTMLTextAreaElement.prototype,'scrollHeight',{configurable:true,get:() => measured})
12
+ render();const field=el.querySelector('textarea')!
13
+ const input=(value:string,height:number) => {measured=height;field.value=value;act(() => field.dispatchEvent(new Event('input',{bubbles:true})))}
14
+ const measure=(height:number) => {measured=height}
15
+ return {field,render,input,measure,restore:() => {if(descriptor) Object.defineProperty(HTMLTextAreaElement.prototype,'scrollHeight',descriptor);else delete (HTMLTextAreaElement.prototype as any).scrollHeight}}
16
+ }
17
+
18
+ const restorers:(() => void)[]=[]
19
+ afterEach(() => {for(const root of roots.splice(0)) act(() => root.unmount());for(const restore of restorers.splice(0)) restore();document.body.innerHTML='';vi.restoreAllMocks()})
20
+ it('grows and shrinks an uncontrolled field from actual input', () => {
21
+ const onChange=vi.fn();const m=mount({autoGrow:true,onChange});restorers.push(m.restore)
22
+ m.input('one\ntwo\nthree',80);expect(m.field.style.height).toBe('80px');expect(onChange).toHaveBeenCalledWith('one\ntwo\nthree')
23
+ m.input('one',20);expect(m.field.style.height).toBe('20px');expect(m.field.style.overflowY).toBe('hidden')
24
+ })
25
+
26
+ it('caps uncontrolled growth at maxRows and scrolls past the cap', () => {
27
+ const m=mount({autoGrow:true,rows:2,maxRows:3});restorers.push(m.restore)
28
+ expect(m.field.rows).toBe(2);m.input('many lines',80);expect(m.field.style.height).toBe('30px');expect(m.field.style.overflowY).toBe('auto')
29
+ m.input('short',20);expect(m.field.style.height).toBe('20px');expect(m.field.style.overflowY).toBe('hidden')
30
+ })
31
+
32
+ it('still fits controlled value updates', () => {
33
+ const m=mount({autoGrow:true,value:'one'});restorers.push(m.restore);m.measure(70);m.render({value:'one\ntwo\nthree'})
34
+ expect(m.field.style.height).toBe('70px');expect(m.field.value).toBe('one\ntwo\nthree')
35
+ })
36
+
37
+ it('leaves fixed-row inputs at their declared rows', () => {
38
+ const m=mount({rows:4});restorers.push(m.restore);m.input('many lines',80);expect(m.field.rows).toBe(4);expect(m.field.style.height).toBe('')
39
+ })
@@ -4,7 +4,7 @@ import type { TextAreaProps } from "./props";
4
4
  import { applyEscapeProps } from "./escape-props";
5
5
 
6
6
  /** Multiline input — <textarea>. `autoGrow` re-fits height to content after
7
- * every commit via scrollHeight, capped by `maxRows` translated to a real
7
+ * input and value updates via scrollHeight, capped by `maxRows` translated to a real
8
8
  * max-height from the element's computed line-height; past the cap the
9
9
  * field scrolls. `useLayoutEffect` so the measure lands pre-paint in the
10
10
  * commit that wrote `value`. Deps are explicit — tsrx infers them from the
@@ -13,7 +13,7 @@ import { applyEscapeProps } from "./escape-props";
13
13
  export function TextArea(props: TextAreaProps) @{
14
14
  const el = useRef<HTMLTextAreaElement | null>(null);
15
15
 
16
- useLayoutEffect(() => {
16
+ const fit = () => {
17
17
  const t = el.current;
18
18
  if (!t) return;
19
19
  const cs = getComputedStyle(t);
@@ -35,7 +35,9 @@ export function TextArea(props: TextAreaProps) @{
35
35
  t.style.height = Math.min(need, cap) + 'px';
36
36
  t.style.overflowY = need > cap ? 'auto' : 'hidden';
37
37
  }
38
- }, [props.value, props.rows, props.autoGrow, props.maxRows, props.className, props.style]);
38
+ };
39
+
40
+ useLayoutEffect(fit, [props.value, props.rows, props.autoGrow, props.maxRows, props.className, props.style]);
39
41
 
40
42
  <textarea
41
43
  ref={(node: any) => {
@@ -53,8 +55,8 @@ export function TextArea(props: TextAreaProps) @{
53
55
  readOnly={props.editable === false}
54
56
  placeholder={props.placeholder}
55
57
  rows={props.rows ?? (props.autoGrow ? 1 : 3)}
56
- onInput={(e: any) => props.onChange?.(e.target.value)}
57
- onKeyDown={(e: any) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) props.onSubmit?.(); }}
58
+ onInput={(e: any) => { fit(); props.onChange?.(e.target.value); }}
59
+ onKeyDown={(e: any) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.isComposing && e.keyCode !== 229) props.onSubmit?.(); }}
58
60
  onFocus={props.onFocus}
59
61
  onBlur={props.onBlur}
60
62
  />
@@ -3,11 +3,11 @@ import { useEffect, useRef } from 'octane';
3
3
  import type { TextInputProps } from "./props";
4
4
  import { cx } from "./cx";
5
5
  import { applyEscapeProps } from "./escape-props";
6
- import { writeText } from './text-write';
6
+ import { isWritingText, writeText } from './text-write';
7
+ import { blurText } from './text-focus';
7
8
 
8
- /** Controlled single-line input. The driver suppresses the textChange echo
9
- * of its own `text` writes (upstream #5, since 0.2.1) — no leaf-side
10
- * guard needed. `editable` defaults true at the leaf too: an undefined
9
+ /** Controlled single-line input. Imperative writes suppress their synchronous
10
+ * textChange echo through text-write. `editable` defaults true: an undefined
11
11
  * write coerces ios.userInteractionEnabled to NO (dead field) — the
12
12
  * driver's skip-undefined patch covers it, the ?? stays as documentation.
13
13
  * `text` isn't a bound prop: the leaf writes it imperatively so the
@@ -21,7 +21,7 @@ export function TextInput(props: TextInputProps) @{
21
21
  el.current = v;
22
22
  if (!v) return;
23
23
  applyEscapeProps(v, props);
24
- props.bind?.({ focus: () => v.focus(), blur: () => v.dismissSoftInput?.() ?? v.blur(), native: v });
24
+ props.bind?.({ focus: () => v.focus(), blur: () => blurText(v), native: v });
25
25
  }}
26
26
  id={props.id}
27
27
  className={cx('vx-input', props.className)}
@@ -31,7 +31,7 @@ export function TextInput(props: TextInputProps) @{
31
31
  keyboardType={props.keyboardType === 'default' ? undefined : props.keyboardType}
32
32
  returnKeyType={props.returnKeyType}
33
33
  editable={props.editable ?? true}
34
- onTextChange={(e: any) => props.onChange?.(e.value)}
34
+ onTextChange={(e: any) => { if (!isWritingText(e.object)) props.onChange?.(e.value); }}
35
35
  onReturnPress={props.onSubmit}
36
36
  onFocus={props.onFocus}
37
37
  onBlur={props.onBlur}
@@ -21,7 +21,7 @@ export function TextInput(props: TextInputProps) @{
21
21
  readOnly={props.editable === false}
22
22
  placeholder={props.placeholder}
23
23
  onInput={(e: any) => props.onChange?.(e.target.value)}
24
- onKeyDown={(e: any) => { if (e.key === 'Enter' && !e.shiftKey) props.onSubmit?.(); }}
24
+ onKeyDown={(e: any) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) props.onSubmit?.(); }}
25
25
  onFocus={props.onFocus}
26
26
  onBlur={props.onBlur}
27
27
  />
@@ -70,6 +70,8 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
70
70
  const indexByRowKeyRef = useRef(new Map<string, number>())
71
71
  const scrollRef = useRef<any>(null)
72
72
  const scrollOffsetRef = useRef(0)
73
+ const topSpacerRef = useRef<any>(null)
74
+ const topSpacerHeightRef = useRef(0)
73
75
  const viewportHeightRef = useRef(0)
74
76
  const viewportWidthRef = useRef(0)
75
77
  const headerHeightRef = useRef(0)
@@ -80,6 +82,12 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
80
82
  const estimateAnchorRef = useRef<{ rowKey: string; within: number } | null>(null)
81
83
  const estimateTimerRef = useRef<any>(null)
82
84
  const pendingRowMeasureRef = useRef(new Map<string, number>())
85
+ const programmaticWriteRef = useRef<{ offset: number; until: number } | null>(null)
86
+ /** Convergent deep seek state (FlashList v2's scrollToIndex model): a large
87
+ * external scroll jump is tracked and anchor-restore writes are suppressed
88
+ * while measurements converge — the clip already sits at the seek target,
89
+ * and re-anchoring would rewrite it to the biased estimated prefix. */
90
+ const seekStateRef = useRef<{ target: number; until: number } | null>(null)
83
91
  const hasSeparator = Boolean(props.renderSeparator)
84
92
  const hasSeparatorRef = useRef(hasSeparator)
85
93
  hasSeparatorRef.current = hasSeparator
@@ -146,8 +154,14 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
146
154
  return { rowKey: entry.rowKey, within: listOffset - sizeIndexRef.current.prefix(index) }
147
155
  }
148
156
 
157
+ function activeSeek() {
158
+ const seek = seekStateRef.current
159
+ if (seek && Date.now() > seek.until) {seekStateRef.current = null}
160
+ return seekStateRef.current
161
+ }
162
+
149
163
  function restoreAnchor(anchor: { rowKey: string; within: number } | null) {
150
- if (!anchor) {return}
164
+ if (!anchor || activeSeek()) {return}
151
165
  queuedAnchorRef.current = anchor
152
166
  if (anchorRestoreScheduledRef.current) {return}
153
167
  const scroll = scrollRef.current
@@ -158,6 +172,9 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
158
172
  if (anchorRestoreTimerRef.current !== timer) {return}
159
173
  anchorRestoreTimerRef.current = null
160
174
  anchorRestoreScheduledRef.current = false
175
+ // A restore scheduled before a deep seek must not fire its write
176
+ // mid-convergence — it would move the clip off the seek target.
177
+ if (activeSeek()) {queuedAnchorRef.current = null; return}
161
178
  if (scrollRef.current !== scroll) {return}
162
179
  const queued = queuedAnchorRef.current
163
180
  queuedAnchorRef.current = null
@@ -169,7 +186,8 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
169
186
  headerHeightRef.current + sizeIndexRef.current.prefix(index) + queued.within,
170
187
  )
171
188
 
172
- if (sameSize(nextOffset, scrollViewOffset(scroll))) {return}
189
+ if (sameSize(nextOffset, scrollOffsetRef.current)) {return}
190
+ programmaticWriteRef.current = { offset: nextOffset, until: Date.now() + 250 }
173
191
  scrollViewSetTop(scroll, nextOffset)
174
192
  }, 0)
175
193
 
@@ -184,6 +202,14 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
184
202
  // relayout/anchor cycle count bounded while samples accumulate.
185
203
  estimateTimerRef.current = setTimeout(() => {
186
204
  estimateTimerRef.current = null
205
+ // FlashList v2's scrollToIndex model: correction passes stay
206
+ // paused for the whole seek — sizes keep accumulating in the
207
+ // cache/index and one rebuild runs once the seek expires.
208
+ if (activeSeek()) {
209
+ scheduleEstimateRebuild(null)
210
+ return
211
+ }
212
+
187
213
  const scheduledAnchor = estimateAnchorRef.current
188
214
  estimateAnchorRef.current = null
189
215
  rebuildLayout(entriesRef.current, viewportWidthRef.current)
@@ -207,6 +233,12 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
207
233
  // re-run this whole path (and reschedule estimate rebuilds) forever.
208
234
  if (cached && sameSize(cached.height, height) && sameSize(cached.width, width)) {return}
209
235
 
236
+ // Keep the seek suppression alive through the measurement storm — the
237
+ // seek ends ~400 ms after the last new/changed size lands (Legend's
238
+ // idle-settle model), not on the first quiet rebuild.
239
+ const seek = activeSeek()
240
+ if (seek) {seek.until = Date.now() + 400}
241
+
210
242
  const anchor = queuedAnchorRef.current ?? captureAnchor()
211
243
  sizeCacheRef.current.set(cacheKey, { width, height })
212
244
 
@@ -237,10 +269,58 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
237
269
  }
238
270
 
239
271
  function onScroll(event: AppKitScrollEvent) {
240
- // The event's verticalOffset is the raw clip bounds origin — bottom-up
241
- // on a non-flipped document — so derive the top-down offset ourselves.
242
272
  const scroll = scrollRef.current
243
- scrollOffsetRef.current = scroll ? scrollViewOffset(scroll) : Number(event?.verticalOffset ?? 0)
273
+ // Prefer the spacer-landmark offset: the top spacer's bottom edge sits
274
+ // at content position `range.top`, so measuring the clip against it
275
+ // reports DOM-style scrollTop with no docH term. Estimate rebuilds
276
+ // resize the document constantly and would phantom-drift the
277
+ // docHeight-derived offset even when the clip never moved.
278
+ const clip = scroll?.contentView
279
+ const spacerBottomY = Number(topSpacerRef.current?.frame?.origin?.y ?? Number.NaN)
280
+ const doc = scroll?.documentView
281
+ const flip = typeof doc?.isFlipped === 'function' ? doc.isFlipped() : doc?.isFlipped
282
+ const flipped = flip === true || Number(flip) === 1
283
+ const originY = Number(clip?.bounds?.origin?.y ?? event?.verticalOffset ?? 0)
284
+ const clipH = Number(clip?.bounds?.size?.height ?? 0)
285
+ let nextOffset: number
286
+ if (Number.isFinite(spacerBottomY)) {
287
+ const clipTopY = flipped ? originY : originY + clipH
288
+ const spacerBottomContent = flipped
289
+ ? Number(topSpacerRef.current?.frame?.size?.height ?? 0) + spacerBottomY
290
+ : spacerBottomY
291
+
292
+ nextOffset =
293
+ headerHeightRef.current +
294
+ topSpacerHeightRef.current +
295
+ (flipped ? clipTopY - spacerBottomContent : spacerBottomY - clipTopY)
296
+ } else {
297
+ nextOffset = scroll ? scrollViewOffset(scroll) : Number(event?.verticalOffset ?? 0)
298
+ }
299
+
300
+ if (props.id) {
301
+ const published = ((globalThis as any).__xplatVlistOffsets ??= {} as Record<string, number>)
302
+ published[props.id] = nextOffset
303
+ }
304
+
305
+ let ownWrite = false
306
+ const wrote = programmaticWriteRef.current
307
+ if (wrote !== null) {
308
+ if (sameSize(nextOffset, wrote.offset)) {
309
+ programmaticWriteRef.current = null
310
+ ownWrite = true
311
+ } else if (Date.now() > wrote.until) {
312
+ programmaticWriteRef.current = null
313
+ }
314
+ }
315
+
316
+ const jumpThreshold = Math.max(200, viewportHeightRef.current * 1.5)
317
+ // A jump that doesn't match a pending anchor write is an external seek —
318
+ // including one that lands while a stale write flag is still open.
319
+ if (!ownWrite && Math.abs(nextOffset - scrollOffsetRef.current) > jumpThreshold) {
320
+ seekStateRef.current = { target: nextOffset, until: Date.now() + 1000 }
321
+ }
322
+
323
+ scrollOffsetRef.current = nextOffset
244
324
  viewportHeightRef.current = Number(event?.viewportHeight ?? viewportHeightRef.current)
245
325
  refreshWindow()
246
326
  }
@@ -278,6 +358,7 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
278
358
 
279
359
  const range = currentRange()
280
360
  rangeRef.current = range
361
+ topSpacerHeightRef.current = range.top
281
362
  void windowVersion
282
363
  const visibleEntries = entries.slice(range.start, range.end)
283
364
 
@@ -311,7 +392,13 @@ export function VirtualList<T = any>(props: VirtualListProps<T>) @{
311
392
  }
312
393
  } @else {
313
394
  <flexboxlayout flexDirection="column">
314
- <flexboxlayout className="vx-virtual-list-spacer" style={{ height: range.top }} />
395
+ <flexboxlayout
396
+ className="vx-virtual-list-spacer"
397
+ style={{ height: range.top }}
398
+ ref={(view: any) => {
399
+ topSpacerRef.current = view
400
+ }}
401
+ />
315
402
  @for (const entry of visibleEntries; key entry.rowKey) {
316
403
  <flexboxlayout
317
404
  flexDirection="column"
@@ -0,0 +1,232 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { useState } from 'octane'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { createObjectContainer, createObjectDriver, createUniversalRoot } from 'octane/universal/native'
5
+ import { VirtualList } from './VirtualList.tsrx'
6
+ import { VirtualListSizeIndex } from './virtual-list-layout'
7
+
8
+ vi.mock('@nativescript/core', () => ({ isAndroid: true, isIOS: false }))
9
+
10
+ const items = Array.from({ length: 500 }, (_, index) => ({ id: 'r' + index }))
11
+ const keyExtractor = (item: { id: string }) => item.id
12
+ const renderItem = (item: { id: string }) => <label id={'row-' + item.id} text={item.id} />
13
+ function StatefulRow(props: { id: string }) @{
14
+ const [taps, setTaps] = useState(0)
15
+
16
+ <label id={'row-' + props.id} text={props.id + ':' + taps} onTap={() => setTaps(taps + 1)} />
17
+ }
18
+
19
+ const renderStatefulItem = (item: { id: string }) => <StatefulRow id={item.id} />
20
+
21
+ const roots: { unmount: () => void }[] = []
22
+
23
+ function collect(nodes: any[]): any[] {
24
+ return nodes.flatMap((node) => [node, ...collect(node.children ?? [])])
25
+ }
26
+
27
+ async function mount(viewportWidth = 100, itemRenderer = renderItem) {
28
+ vi.useFakeTimers()
29
+ const container = createObjectContainer('nativescript')
30
+ const driver = createObjectDriver('nativescript')
31
+ const commits: any[] = []
32
+ const prepare = driver.prepareBatch
33
+ driver.prepareBatch = (...args) => {
34
+ commits.push(args[1])
35
+ return prepare(...args)
36
+ }
37
+
38
+ const root = createUniversalRoot(container, driver)
39
+ roots.push(root)
40
+ root.render(VirtualList as any, { items, keyExtractor, renderItem: itemRenderer })
41
+ const scroll: any = container.children[0]
42
+ scroll.verticalOffset = 0
43
+ scroll.getActualSize = () => ({ width: viewportWidth, height: 200 })
44
+ scroll.scrollToVerticalOffset = vi.fn((offset) => { scroll.verticalOffset = offset })
45
+ container.dispatchEvent(scroll, 'loaded', { object: scroll })
46
+ const content = scroll.children[0]
47
+ content.getActualSize = () => ({ width: 100, height: 500 * 48 })
48
+ container.dispatchEvent(content, 'layoutchanged', { object: content })
49
+ await vi.advanceTimersByTimeAsync(32)
50
+ scroll.scrollToVerticalOffset.mockClear()
51
+ commits.length = 0
52
+ const move = (offset: number) => {
53
+ scroll.verticalOffset = offset
54
+ container.dispatchEvent(scroll, 'scroll', { object: scroll })
55
+ }
56
+
57
+ const measure = (id: string, height: number, emit = true) => {
58
+ const row = collect(container.children).find((node) =>
59
+ String(node.props.className).split(' ').includes('vx-virtual-list-row') && collect(node.children).some((child) => child.props.id === id),
60
+ )
61
+
62
+ if (!row) throw new Error('Missing row ' + id)
63
+ row.getActualSize = () => ({ width: 100, height })
64
+ if (emit) container.dispatchEvent(row, 'layoutchanged', { object: row })
65
+ }
66
+
67
+ return { container, root, scroll, commits, move, measure }
68
+ }
69
+
70
+ afterEach(() => {
71
+ for (const root of roots.splice(0)) root.unmount()
72
+ vi.useRealTimers()
73
+ vi.unstubAllGlobals()
74
+ vi.restoreAllMocks()
75
+ })
76
+
77
+ describe('native VirtualList scroll and measurement scheduling', () => {
78
+ it('does not restore an obsolete anchor after the user advances during measurement', async () => {
79
+ const { scroll, move, measure } = await mount()
80
+ move(480)
81
+ await vi.advanceTimersByTimeAsync(32)
82
+ scroll.scrollToVerticalOffset.mockClear()
83
+ measure('row-r16', 60)
84
+ await Promise.resolve()
85
+ move(492)
86
+ await vi.advanceTimersByTimeAsync(32)
87
+ expect(scroll.verticalOffset).toBe(492)
88
+ expect(scroll.scrollToVerticalOffset).not.toHaveBeenCalled()
89
+ })
90
+
91
+ it('applies an above-anchor height correction while preserving user movement', async () => {
92
+ const { scroll, move, measure } = await mount()
93
+ move(480)
94
+ await vi.advanceTimersByTimeAsync(32)
95
+ measure('row-r5', 60)
96
+ move(492)
97
+ await vi.advanceTimersByTimeAsync(32)
98
+ expect(scroll.verticalOffset).toBe(504)
99
+ })
100
+
101
+ it('coalesces a scroll burst instead of committing inside each native callback', async () => {
102
+ const { commits, move } = await mount()
103
+ move(240)
104
+ move(288)
105
+ move(336)
106
+ expect(commits).toHaveLength(0)
107
+ await vi.advanceTimersByTimeAsync(32)
108
+ expect(commits).toHaveLength(1)
109
+ })
110
+
111
+ it('does not rebuild unvisited estimates when another sample preserves the type average', async () => {
112
+ const reset = vi.spyOn(VirtualListSizeIndex.prototype, 'reset')
113
+ const { measure } = await mount()
114
+ for (let index = 0; index < 4; index++) measure('row-r' + index, 36 + index * 12)
115
+ await vi.advanceTimersByTimeAsync(180)
116
+ const count = reset.mock.calls.length
117
+ for (let index = 4; index < 8; index++) measure('row-r' + index, 36 + (index % 4) * 12)
118
+ await vi.advanceTimersByTimeAsync(180)
119
+ expect(reset).toHaveBeenCalledTimes(count)
120
+ })
121
+
122
+ it('retains exact row sizes when borders make content narrower than the viewport', async () => {
123
+ const { scroll, move, measure } = await mount(102)
124
+ for (let index = 0; index < 4; index++) measure('row-r' + index, 36 + index * 12)
125
+ await vi.advanceTimersByTimeAsync(180)
126
+ move(200)
127
+ await vi.advanceTimersByTimeAsync(32)
128
+ scroll.scrollToVerticalOffset.mockClear()
129
+ measure('row-r0', 36)
130
+ await vi.advanceTimersByTimeAsync(180)
131
+ expect(scroll.verticalOffset).toBe(200)
132
+ expect(scroll.scrollToVerticalOffset).not.toHaveBeenCalled()
133
+ })
134
+
135
+ it('rejects a queued measurement after the keyed item is replaced', async () => {
136
+ const { root, scroll, move, measure } = await mount()
137
+ move(480)
138
+ await vi.advanceTimersByTimeAsync(32)
139
+ measure('row-r5', 120)
140
+ const replacement = items.map((item, index) => index === 5 ? { ...item } : item)
141
+ root.render(VirtualList as any, { items: replacement, keyExtractor, renderItem })
142
+ // The replacement has its own current geometry; the queued 120-height result is obsolete.
143
+ measure('row-r5', 48, false)
144
+ await vi.advanceTimersByTimeAsync(32)
145
+ expect(scroll.verticalOffset).toBe(480)
146
+ })
147
+
148
+ it('commits a burst of row measurements together', async () => {
149
+ const { commits, move, measure } = await mount()
150
+ move(480)
151
+ await vi.advanceTimersByTimeAsync(32)
152
+ commits.length = 0
153
+ measure('row-r12', 60)
154
+ measure('row-r13', 72)
155
+ measure('row-r14', 84)
156
+ expect(commits).toHaveLength(0)
157
+ await vi.advanceTimersByTimeAsync(32)
158
+ expect(commits).toHaveLength(1)
159
+ })
160
+
161
+ it('coalesces through native animation frames and cancels a pending frame on disposal', async () => {
162
+ const { root, commits, move } = await mount()
163
+ vi.stubGlobal('requestAnimationFrame', (callback: (time: number) => void) => setTimeout(() => callback(Date.now()), 8))
164
+ const cancel = vi.fn((id: ReturnType<typeof setTimeout>) => clearTimeout(id))
165
+ vi.stubGlobal('cancelAnimationFrame', cancel)
166
+ move(240)
167
+ move(288)
168
+ expect(commits).toHaveLength(0)
169
+ await vi.advanceTimersByTimeAsync(32)
170
+ expect(commits).toHaveLength(1)
171
+ move(480)
172
+ await vi.advanceTimersByTimeAsync(8)
173
+ root.unmount()
174
+ await vi.advanceTimersByTimeAsync(0)
175
+ expect(cancel).toHaveBeenCalled()
176
+ const count = commits.length
177
+ await vi.advanceTimersByTimeAsync(32)
178
+ expect(commits).toHaveLength(count)
179
+ })
180
+
181
+ it('keeps physical hosts alive on reassignment and destroys them on disposal', async () => {
182
+ const { container, root, move } = await mount()
183
+ const cells = collect(container.children).filter((node) => String(node.props.className).split(' ').includes('vx-virtual-list-cell'))
184
+ expect(cells.length).toBeGreaterThan(0)
185
+ for (const cell of cells) cell.destroyNode = vi.fn()
186
+ move(480)
187
+ await vi.advanceTimersByTimeAsync(32)
188
+ for (const cell of cells) {
189
+ expect(cell.reusable).toBe(true)
190
+ expect(cell.destroyNode).not.toHaveBeenCalled()
191
+ }
192
+
193
+ root.unmount()
194
+ await vi.advanceTimersByTimeAsync(0)
195
+ for (const cell of cells) expect(cell.destroyNode).toHaveBeenCalledWith(true)
196
+ })
197
+
198
+ it('retains keyed state across prepend and resets it after leaving the window', async () => {
199
+ const { container, root, scroll, move } = await mount(100, renderStatefulItem)
200
+ move(480)
201
+ await vi.advanceTimersByTimeAsync(32)
202
+ const findRow = () => collect(container.children).find((node) => node.props.id === 'row-r10')
203
+ const row = findRow()
204
+ container.dispatchEvent(row, 'tap', { object: row })
205
+ expect(findRow().props.text).toBe('r10:1')
206
+ const prepended = [{ id: 'prepended' }, ...items]
207
+ root.render(VirtualList as any, { items: prepended, keyExtractor, renderItem: renderStatefulItem })
208
+ await vi.advanceTimersByTimeAsync(32)
209
+ expect(scroll.verticalOffset).toBe(528)
210
+ expect(findRow()).toBe(row)
211
+ expect(findRow().props.text).toBe('r10:1')
212
+ move(4000)
213
+ await vi.advanceTimersByTimeAsync(32)
214
+ expect(findRow()).toBeUndefined()
215
+ for (const visible of collect(container.children).filter((node) => /^row-r/.test(String(node.props.id)))) {
216
+ expect(visible.props.text).toMatch(/:0$/)
217
+ }
218
+
219
+ move(528)
220
+ await vi.advanceTimersByTimeAsync(32)
221
+ expect(findRow().props.text).toBe('r10:0')
222
+ })
223
+
224
+ it('cancels queued work when the list is disposed', async () => {
225
+ const { root, commits, move } = await mount()
226
+ move(480)
227
+ root.unmount()
228
+ const count = commits.length
229
+ await vi.advanceTimersByTimeAsync(100)
230
+ expect(commits).toHaveLength(count)
231
+ })
232
+ })