@tarojs/components-advanced 4.1.12-beta.4 → 4.1.12-beta.40

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 (73) hide show
  1. package/dist/components/index.js +1 -0
  2. package/dist/components/index.js.map +1 -1
  3. package/dist/components/list/hooks/useItemSizeCache.d.ts +1 -13
  4. package/dist/components/list/hooks/useItemSizeCache.js +6 -50
  5. package/dist/components/list/hooks/useItemSizeCache.js.map +1 -1
  6. package/dist/components/list/hooks/useListNestedScroll.d.ts +18 -0
  7. package/dist/components/list/hooks/useListNestedScroll.js +61 -0
  8. package/dist/components/list/hooks/useListNestedScroll.js.map +1 -0
  9. package/dist/components/list/hooks/useListScrollElementAttach.d.ts +25 -0
  10. package/dist/components/list/hooks/useListScrollElementAttach.js +88 -0
  11. package/dist/components/list/hooks/useListScrollElementAttach.js.map +1 -0
  12. package/dist/components/list/hooks/useListScrollElementAttachWeapp.d.ts +14 -0
  13. package/dist/components/list/hooks/useListScrollElementAttachWeapp.js +134 -0
  14. package/dist/components/list/hooks/useListScrollElementAttachWeapp.js.map +1 -0
  15. package/dist/components/list/hooks/useMeasureStartOffset.d.ts +12 -0
  16. package/dist/components/list/hooks/useMeasureStartOffset.js +84 -0
  17. package/dist/components/list/hooks/useMeasureStartOffset.js.map +1 -0
  18. package/dist/components/list/hooks/useMeasureStartOffsetWeapp.d.ts +14 -0
  19. package/dist/components/list/hooks/useMeasureStartOffsetWeapp.js +82 -0
  20. package/dist/components/list/hooks/useMeasureStartOffsetWeapp.js.map +1 -0
  21. package/dist/components/list/hooks/useRefresher.d.ts +10 -2
  22. package/dist/components/list/hooks/useRefresher.js +150 -69
  23. package/dist/components/list/hooks/useRefresher.js.map +1 -1
  24. package/dist/components/list/hooks/useResizeObserver.d.ts +1 -0
  25. package/dist/components/list/hooks/useResizeObserver.js +37 -31
  26. package/dist/components/list/hooks/useResizeObserver.js.map +1 -1
  27. package/dist/components/list/hooks/useScrollCorrection.d.ts +3 -2
  28. package/dist/components/list/hooks/useScrollCorrection.js +7 -5
  29. package/dist/components/list/hooks/useScrollCorrection.js.map +1 -1
  30. package/dist/components/list/hooks/useScrollParentAutoFind.d.ts +20 -0
  31. package/dist/components/list/hooks/useScrollParentAutoFind.js +81 -0
  32. package/dist/components/list/hooks/useScrollParentAutoFind.js.map +1 -0
  33. package/dist/components/list/index.d.ts +16 -6
  34. package/dist/components/list/index.js +435 -498
  35. package/dist/components/list/index.js.map +1 -1
  36. package/dist/components/list/utils.d.ts +5 -0
  37. package/dist/components/list/utils.js +17 -1
  38. package/dist/components/list/utils.js.map +1 -1
  39. package/dist/components/virtual-list/vue/list.d.ts +10 -10
  40. package/dist/components/virtual-waterfall/vue/waterfall.d.ts +10 -10
  41. package/dist/components/water-flow/flow-item.js +6 -4
  42. package/dist/components/water-flow/flow-item.js.map +1 -1
  43. package/dist/components/water-flow/flow-section.js +1 -1
  44. package/dist/components/water-flow/flow-section.js.map +1 -1
  45. package/dist/components/water-flow/index.d.ts +1 -1
  46. package/dist/components/water-flow/interface.d.ts +18 -2
  47. package/dist/components/water-flow/root.d.ts +35 -4
  48. package/dist/components/water-flow/root.js +114 -42
  49. package/dist/components/water-flow/root.js.map +1 -1
  50. package/dist/components/water-flow/section.d.ts +7 -1
  51. package/dist/components/water-flow/section.js +54 -9
  52. package/dist/components/water-flow/section.js.map +1 -1
  53. package/dist/components/water-flow/utils.d.ts +4 -0
  54. package/dist/components/water-flow/utils.js +5 -1
  55. package/dist/components/water-flow/utils.js.map +1 -1
  56. package/dist/components/water-flow/water-flow-node-cache.d.ts +24 -0
  57. package/dist/components/water-flow/water-flow-node-cache.js +161 -0
  58. package/dist/components/water-flow/water-flow-node-cache.js.map +1 -0
  59. package/dist/components/water-flow/water-flow.d.ts +2 -3
  60. package/dist/components/water-flow/water-flow.js +286 -31
  61. package/dist/components/water-flow/water-flow.js.map +1 -1
  62. package/dist/index.js +2 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/utils/index.d.ts +1 -0
  65. package/dist/utils/index.js +1 -0
  66. package/dist/utils/index.js.map +1 -1
  67. package/dist/utils/scrollElementContext.d.ts +15 -0
  68. package/dist/utils/scrollElementContext.js +14 -0
  69. package/dist/utils/scrollElementContext.js.map +1 -0
  70. package/dist/utils/scrollParent.d.ts +33 -0
  71. package/dist/utils/scrollParent.js +88 -0
  72. package/dist/utils/scrollParent.js.map +1 -0
  73. package/package.json +9 -8
@@ -2,17 +2,22 @@ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
2
  import { View, ScrollView } from '@tarojs/components';
3
3
  import Taro from '@tarojs/taro';
4
4
  import React from 'react';
5
+ import '../../utils/index.js';
6
+ import { ScrollElementContextOrFallback } from '../../utils/scrollElementContext.js';
5
7
  import { useItemSizeCache } from './hooks/useItemSizeCache.js';
6
- import { useRefresher, DEFAULT_REFRESHER_HEIGHT } from './hooks/useRefresher.js';
8
+ import { useListNestedScroll } from './hooks/useListNestedScroll.js';
9
+ import { useListScrollElementAttach } from './hooks/useListScrollElementAttach.js';
10
+ import { useListScrollElementAttachWeapp } from './hooks/useListScrollElementAttachWeapp.js';
11
+ import { useRefresher } from './hooks/useRefresher.js';
7
12
  import { useResizeObserver } from './hooks/useResizeObserver.js';
8
13
  import { useScrollCorrection } from './hooks/useScrollCorrection.js';
9
14
  import { ListItem } from './ListItem.js';
10
15
  import { NoMore } from './NoMore.js';
11
16
  import { StickyHeader } from './StickyHeader.js';
12
17
  import { StickySection } from './StickySection.js';
13
- import { supportsNativeRefresher } from './utils.js';
18
+ import { isWeapp, createSelectorQueryScoped, isH5, supportsNativeRefresher } from './utils.js';
19
+ import { getScrollViewContextNode } from '../../utils/dom.js';
14
20
 
15
- // 工具:累加数组
16
21
  function accumulate(arr) {
17
22
  const result = [0];
18
23
  for (let i = 0; i < arr.length; i++) {
@@ -20,77 +25,86 @@ function accumulate(arr) {
20
25
  }
21
26
  return result;
22
27
  }
23
- // 检测抖动
24
28
  function isShaking(diffList) {
25
29
  if (diffList.length < 3)
26
30
  return false;
27
- // 检查是否有连续的正负交替
28
31
  const signs = diffList.map(diff => Math.sign(diff));
29
32
  let alternations = 0;
30
33
  for (let i = 1; i < signs.length; i++) {
31
- if (signs[i] !== 0 && signs[i] !== signs[i - 1]) {
34
+ if (signs[i] !== 0 && signs[i] !== signs[i - 1])
32
35
  alternations++;
33
- }
34
36
  }
35
- // 如果交替次数过多,认为是抖动
36
37
  return alternations >= 2;
37
38
  }
38
39
  // 小程序端:判断 item 是否应该执行 SelectorQuery 测量(仅检查是否已测量过)
39
40
  // SelectorQuery 是只读查询,不会触发 setData,滚动期间执行是安全的
40
- // 真正需要延迟的是 sizeCacheVersion bump(由 weappSizeCacheVersionBump 负责)
41
41
  function shouldMeasureWeappItem(index, measuredSet) {
42
42
  return !measuredSet.has(index);
43
43
  }
44
- // 小程序端(定高路径):滚动期间延迟 sizeCacheVersion bump,停止后再 flush。
45
- // 动高路径已改为“测量即重排、原生滚动主导”,不走该事务。
46
- function weappSizeCacheVersionBump(isUserScrollingRef, pendingBumpRef, measureScrollProtectRef, setSizeCacheVersion) {
47
- if (isUserScrollingRef.current) {
48
- pendingBumpRef.current = true;
49
- return;
50
- }
51
- measureScrollProtectRef.current = true;
52
- setSizeCacheVersion((v) => v + 1);
53
- }
54
- // 小程序端:onItemSizeChange 空置(稳定性优先,不向父层外抛)
55
- function weappDeferItemSizeChange(_isUserScrollingRef, _pendingSizeChangesRef, _index, _size, _onItemSizeChange) {
56
- // 回退到稳定策略:小程序端暂不外抛 onItemSizeChange,
57
- // 避免父层重渲染/remount 导致回顶或空白。
58
- }
59
- // 小程序端(定高路径):记录滚动期间的测量变化,用于 flush 时计算滚动修正量
60
- // 使用 Map 去重:同一个 index 多次测量时,保留 originalOldSize(首次)和 latestNewSize(最新)
61
- function weappRecordMeasurement(pendingMeasurementsRef, index, oldSize, newSize) {
62
- const existing = pendingMeasurementsRef.current.get(index);
63
- if (existing) {
64
- existing.latestNewSize = newSize;
44
+ // 小程序端暂不外抛 onItemSizeChange,避免父层重渲染导致 List remount 引发回顶或空白
45
+ function weappDeferItemSizeChange(_index, _size, _onItemSizeChange) { }
46
+ /** scroll 选项解析目标偏移量 */
47
+ function resolveScrollTargetOffset(options, isHorizontal) {
48
+ const opts = options !== null && options !== void 0 ? options : {};
49
+ const top = typeof opts.top === 'number' ? opts.top : undefined;
50
+ const left = typeof opts.left === 'number' ? opts.left : undefined;
51
+ let result = 0;
52
+ if (isHorizontal) {
53
+ if (typeof left === 'number')
54
+ result = left;
55
+ else if (typeof top === 'number')
56
+ result = top;
65
57
  }
66
58
  else {
67
- pendingMeasurementsRef.current.set(index, { originalOldSize: oldSize, latestNewSize: newSize });
59
+ if (typeof top === 'number')
60
+ result = top;
61
+ else if (typeof left === 'number')
62
+ result = left;
68
63
  }
64
+ return Number.isFinite(result) ? result : 0;
69
65
  }
70
- // 小程序动高:非 flush measureProtect 帧回传一次当前 offset,避免原生短暂归 0
66
+ // eslint-disable-next-line complexity -- List 多端/多模式逻辑集中,已抽离 useListNestedScroll、useListScrollElementAttach、resolveScrollTargetOffset
71
67
  const InnerList = (props, ref) => {
72
- var _a;
73
- const isH5 = process.env.TARO_ENV === 'h5';
74
- const isWeapp = process.env.TARO_ENV === 'weapp';
75
- const { stickyHeader = false, space = 0, height = 400, width = '100%', showScrollbar = true, scrollTop: controlledScrollTop, scrollX = false, scrollY = true, onScroll, onScrollToUpper, onScrollToLower, onScrollStart, onScrollEnd, upperThreshold = 0, lowerThreshold = 0, cacheCount = 2, cacheExtent, enableBackToTop, className, style, children, } = props;
68
+ var _a, _b;
69
+ const { stickyHeader = false, space = 0, height = 400, width = '100%', showScrollbar = true, scrollTop: controlledScrollTop, scrollX = false, scrollY = true, onScroll, onScrollToUpper, onScrollToLower, onScrollStart, onScrollEnd, upperThreshold = 50, lowerThreshold = 50, cacheCount = 2, cacheExtent, enableBackToTop, className, style, children, nestedScroll, scrollElement, scrollRef: scrollRefProp, } = props;
76
70
  const isHorizontal = scrollX === true;
71
+ const listType = nestedScroll === true ? 'nested' : 'default';
72
+ const { effectiveScrollElement, effectiveStartOffset, effectiveStartOffsetRef, useScrollElementMode, needAutoFind, autoFindStatus, contentWrapperRef, contentId, } = useListNestedScroll(listType, scrollElement, undefined, isHorizontal, props.selectorQueryScope);
77
73
  const DEFAULT_ITEM_WIDTH = 120;
78
74
  const DEFAULT_ITEM_HEIGHT = 40;
75
+ const defaultItemSize = isHorizontal ? DEFAULT_ITEM_WIDTH : DEFAULT_ITEM_HEIGHT;
76
+ const normalizeSize = React.useCallback((value) => {
77
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
78
+ return null;
79
+ return value;
80
+ }, []);
81
+ const resolveItemSizeByIndex = React.useCallback((index, fallback) => {
82
+ const { itemSize, itemData } = props;
83
+ const numberSize = normalizeSize(itemSize);
84
+ if (numberSize != null)
85
+ return numberSize;
86
+ if (typeof itemSize === 'function') {
87
+ const functionSize = normalizeSize(itemSize(index, itemData));
88
+ if (functionSize != null)
89
+ return functionSize;
90
+ }
91
+ return fallback;
92
+ }, [props.itemSize, props.itemData, normalizeSize]);
79
93
  // 滚动状态管理
80
94
  const containerRef = React.useRef(null);
81
95
  // 生成唯一 List ID(用于小程序 ResizeObserver)
82
96
  const listId = React.useMemo(() => `list-${Math.random().toString(36).slice(2, 11)}`, []);
83
97
  // 渲染偏移量 - 用于计算应该渲染哪些元素
84
98
  const [renderOffset, setRenderOffset] = React.useState(controlledScrollTop !== null && controlledScrollTop !== void 0 ? controlledScrollTop : 0);
85
- // 滚动视图偏移量 - 仅用于程序性滚动(scrollIntoView/修正/初始),用户滑动期间不更新,避免「受控 scrollTop」与原生滚动抢位置导致果冻感
99
+ // 程序性滚动用的目标偏移;用户滑动期间不更新,避免与原生滚动冲突
86
100
  const [scrollViewOffset, setScrollViewOffset] = React.useState(controlledScrollTop !== null && controlledScrollTop !== void 0 ? controlledScrollTop : 0);
87
101
  // 用户正在滑动时不再向 ScrollView 传 scrollTop,让滚动完全由原生接管
88
102
  const [isUserScrolling, setIsUserScrolling] = React.useState(false);
89
- // isUserScrolling 的 ref 镜像,供 RAF 回调等异步上下文中读取最新值(避免闭包捕获过期状态)
103
+ // isUserScrolling 的 ref 镜像,供异步上下文读取最新值
90
104
  const isUserScrollingRef = React.useRef(false);
91
105
  const initialContainerLength = typeof (isHorizontal ? width : height) === 'number' ? (isHorizontal ? width : height) : 400;
92
106
  const [containerLength, setContainerLength] = React.useState(initialContainerLength);
93
- // 用实际容器尺寸更新视口长度,避免「props 高度与 CSS 实际高度不一致」导致底部空白(虚拟列表视口 [renderOffset, renderOffset+containerLength] 小于实际可见区域)
107
+ // 用容器实际尺寸更新视口长度,避免 props CSS 不一致导致底部空白
94
108
  React.useEffect(() => {
95
109
  const el = containerRef.current;
96
110
  if (!el || typeof ResizeObserver === 'undefined')
@@ -106,31 +120,41 @@ const InnerList = (props, ref) => {
106
120
  ro.observe(el);
107
121
  return () => ro.disconnect();
108
122
  }, [isHorizontal]);
123
+ // WeChat 小程序:没有原生 ResizeObserver,用 SelectorQuery 一次性测量容器高度
124
+ React.useEffect(() => {
125
+ if (!isWeapp)
126
+ return;
127
+ Taro.nextTick(() => {
128
+ createSelectorQueryScoped(props.selectorQueryScope)
129
+ .select(`#${listId}`)
130
+ .boundingClientRect((rect) => {
131
+ const measured = isHorizontal ? rect === null || rect === void 0 ? void 0 : rect.width : rect === null || rect === void 0 ? void 0 : rect.height;
132
+ if (typeof measured === 'number' && measured > 0) {
133
+ setContainerLength(measured);
134
+ }
135
+ })
136
+ .exec();
137
+ });
138
+ }, [isWeapp, isHorizontal, listId]);
109
139
  // 滚动追踪相关refs
110
140
  const isScrollingRef = React.useRef(false);
111
141
  const lastScrollTopRef = React.useRef(controlledScrollTop !== null && controlledScrollTop !== void 0 ? controlledScrollTop : 0);
112
142
  const scrollDiffListRef = React.useRef([0, 0, 0]);
113
143
  const scrollTimeoutRef = React.useRef(null);
114
- // 小程序端:记录最近一次 scroll 事件时间,供微动静止检测使用
115
- const lastScrollEventAtRef = React.useRef(Date.now());
116
- // 小程序端:微动静止检测定时器(仅在存在 pending 测量时启动)
117
- const settleCheckTimerRef = React.useRef(null);
118
- const settleLastOffsetRef = React.useRef(controlledScrollTop !== null && controlledScrollTop !== void 0 ? controlledScrollTop : 0);
119
- const settleStillCountRef = React.useRef(0);
120
- // 仅程序性滚动(scrollIntoView/修正/初始)时才把 scrollViewOffset 写回 H5 DOM,避免用户拖动结束后误把旧值写回导致卡顿/跳跃(在桌面 Chrome 模拟器下尤为明显)
144
+ // H5:仅程序性滚动时写回 DOM scrollTop,用户滑动结束后不写回避免卡顿
121
145
  const programmaticScrollRef = React.useRef(false);
122
- // 小程序端:记录最近一次程序性滚动目标,用于过滤回调中的异常“回顶”噪声
123
- const programmaticTargetOffsetRef = React.useRef(null);
124
- const suppressResetUntilRef = React.useRef(0);
125
- // guard 兜底回补节流,避免异常回顶事件频繁触发重复回补
126
- const guardHealAtRef = React.useRef(0);
127
- // 程序性滚动冷却期:在此期间内 handleScroll 不设置同步定时器,避免 scrollIntoView 后被原生 scroll 回调"拉回"
146
+ // 小程序端程序性滚动冷却期:handleScroll 只更新 renderOffset,避免 scrollIntoView 后被原生回调拉回
128
147
  const programmaticCooldownRef = React.useRef(false);
129
148
  const programmaticCooldownTimerRef = React.useRef(null);
130
- // 处理渲染偏移量更新。策略:滑动中不同步 scrollViewOffset(避免果冻感),滚动结束后再同步。
131
- // syncToScrollView=true:程序性滚动(scrollIntoView/修正/初始),立即同步到 ScrollView。
132
- // syncToScrollView=false:用户滑动,仅更新 renderOffset;结束 150/200ms 后再同步 scrollViewOffset。
133
- const updateRenderOffset = React.useCallback((newOffset, syncToScrollView) => {
149
+ const scrollViewOffsetRef = React.useRef(0);
150
+ // 用户滚动期间挂起的 reflow 标记(用于滚动结束后补触发)
151
+ const pendingWeappReflowRef = React.useRef(false);
152
+ // ref 持有最新版 scheduleWeappDynamicReflow,供 updateRenderOffset 内的 setTimeout 闭包安全访问
153
+ const scheduleWeappDynamicReflowRef = React.useRef(null);
154
+ // 处理渲染偏移量更新。
155
+ // syncToScrollView=true:程序性滚动,立即同步 scrollViewOffset。
156
+ // syncToScrollView=false:用户滑动,仅更新 renderOffset。weapp 采用 recycle-view 策略:不把用户滑动位置同步到 scrollViewOffset,避免「传滞后值拉回」和「从有到无归顶」。
157
+ const updateRenderOffset = React.useCallback((newOffset, syncToScrollView, source) => {
134
158
  lastScrollTopRef.current = newOffset;
135
159
  isScrollingRef.current = true;
136
160
  if (scrollTimeoutRef.current) {
@@ -140,21 +164,31 @@ const InnerList = (props, ref) => {
140
164
  if (syncToScrollView) {
141
165
  isUserScrollingRef.current = false;
142
166
  setIsUserScrolling(false);
143
- setScrollViewOffset(newOffset);
167
+ // 小程序:target===sv 时 ScrollView 认为无变化不滚动→白屏;imperative/scrollIntoView 时先传中间值再 RAF 传 target 强制触发
168
+ // target=0 用 +0.01;target>0 用 -0.01(统一 +0.01 到底部会被 clamp 成同值无效)
169
+ const same = isWeapp && Math.abs(scrollViewOffsetRef.current - newOffset) < 1;
170
+ const needForce = same && (source === 'imperative' || source === 'scrollIntoView');
171
+ if (needForce) {
172
+ const intermediate = newOffset > 0 ? newOffset - 0.01 : 0.01;
173
+ setScrollViewOffset(intermediate);
174
+ programmaticScrollRef.current = true;
175
+ requestAnimationFrame(() => {
176
+ // 第二帧也需要标记为程序性滚动,否则 else 分支不会传 scrollTop
177
+ programmaticScrollRef.current = true;
178
+ setScrollViewOffset(newOffset);
179
+ });
180
+ }
181
+ else {
182
+ setScrollViewOffset(newOffset);
183
+ }
144
184
  programmaticScrollRef.current = true;
145
- // 小程序端:程序性滚动开启冷却期,在冷却期内忽略 handleScroll 中的同步逻辑,避免被原生 scroll 回调"拉回"
146
- // H5 端已稳定,不启用冷却期
147
185
  if (isWeapp) {
148
- programmaticTargetOffsetRef.current = newOffset;
149
- suppressResetUntilRef.current = Date.now() + 700;
150
186
  programmaticCooldownRef.current = true;
151
187
  if (programmaticCooldownTimerRef.current) {
152
188
  clearTimeout(programmaticCooldownTimerRef.current);
153
189
  }
154
190
  programmaticCooldownTimerRef.current = setTimeout(() => {
155
191
  programmaticCooldownRef.current = false;
156
- programmaticTargetOffsetRef.current = null;
157
- suppressResetUntilRef.current = 0;
158
192
  }, 500);
159
193
  }
160
194
  }
@@ -165,44 +199,59 @@ const InnerList = (props, ref) => {
165
199
  scrollTimeoutRef.current = setTimeout(() => {
166
200
  isScrollingRef.current = false;
167
201
  if (!syncToScrollView) {
168
- // 滚动结束后同步:state 与真实滚动位置一致,下次程序性滚动有正确基准
169
202
  isUserScrollingRef.current = false;
170
- setScrollViewOffset(lastScrollTopRef.current);
171
203
  setIsUserScrolling(false);
204
+ // weapp recycle-view 策略:用户滑动结束后不同步 scrollViewOffset,保持 pass 的值不变,避免 从有到无 归顶
205
+ if (!isWeapp) {
206
+ setScrollViewOffset(lastScrollTopRef.current);
207
+ }
208
+ // 滚动结束后触发期间被延迟的 reflow(item 首次测量触发)
209
+ if (isWeapp && pendingWeappReflowRef.current) {
210
+ scheduleWeappDynamicReflowRef.current();
211
+ }
172
212
  }
173
213
  }, isWeapp ? 200 : 150);
174
214
  }, []);
175
215
  // 暴露给外部的实例方法:通过 ref.scroll({ top / left }) 进行程序性滚动
176
216
  React.useImperativeHandle(ref, () => ({
177
217
  scroll(options) {
178
- const opts = options !== null && options !== void 0 ? options : {};
179
- const isHorizontal = scrollX === true;
180
- const top = typeof opts.top === 'number' ? opts.top : undefined;
181
- const left = typeof opts.left === 'number' ? opts.left : undefined;
182
- let targetOffset = 0;
183
- if (isHorizontal) {
184
- if (typeof left === 'number') {
185
- targetOffset = left;
218
+ const targetOffset = resolveScrollTargetOffset(options, isHorizontal);
219
+ const el = effectiveScrollElement === null || effectiveScrollElement === void 0 ? void 0 : effectiveScrollElement.current;
220
+ if (el && isH5) {
221
+ const scrollTarget = targetOffset + effectiveStartOffset;
222
+ if (isHorizontal) {
223
+ el.scrollTo({ left: scrollTarget });
186
224
  }
187
- else if (typeof top === 'number') {
188
- targetOffset = top;
225
+ else {
226
+ el.scrollTo({ top: scrollTarget });
189
227
  }
228
+ updateRenderOffset(targetOffset, false, 'scrollElement');
190
229
  }
191
- else {
192
- if (typeof top === 'number') {
193
- targetOffset = top;
194
- }
195
- else if (typeof left === 'number') {
196
- targetOffset = left;
197
- }
230
+ else if (el && isWeapp && useScrollElementMode) {
231
+ // 小程序 scrollElement 模式:需通过 getScrollViewContextNode + node.scrollTo 真正滚动外部 scroll-view
232
+ // updateRenderOffset 必须在 scrollTo 之后调用,否则 getScrollViewContextNode 异步期间会先更新 renderOffset 导致闪一下
233
+ const startOff = effectiveStartOffsetRef.current;
234
+ const scrollTarget = targetOffset + startOff;
235
+ const scrollViewId = el.id || `_ls_${listId}`;
236
+ if (!el.id)
237
+ el.id = scrollViewId;
238
+ getScrollViewContextNode(`#${scrollViewId}`).then((node) => {
239
+ var _a, _b;
240
+ if (isHorizontal) {
241
+ (_a = node === null || node === void 0 ? void 0 : node.scrollTo) === null || _a === void 0 ? void 0 : _a.call(node, { left: scrollTarget, animated: false });
242
+ }
243
+ else {
244
+ (_b = node === null || node === void 0 ? void 0 : node.scrollTo) === null || _b === void 0 ? void 0 : _b.call(node, { top: scrollTarget, animated: false });
245
+ }
246
+ updateRenderOffset(targetOffset, true, 'imperative');
247
+ });
198
248
  }
199
- if (!Number.isFinite(targetOffset)) {
200
- targetOffset = 0;
249
+ else {
250
+ updateRenderOffset(targetOffset, true, 'imperative');
201
251
  }
202
- updateRenderOffset(targetOffset, true);
203
252
  },
204
- }), [scrollX, updateRenderOffset]);
205
- // 提取 Refresher 配置(方案一:List 自身属性为 base,Refresher 子覆盖,对齐 dynamic/harmony)
253
+ }), [scrollX, effectiveScrollElement, effectiveStartOffset, effectiveStartOffsetRef, isH5, isWeapp, isHorizontal, listId, useScrollElementMode, updateRenderOffset]);
254
+ // 提取 Refresher 配置(List 属性为 base,Refresher 子组件覆盖)
206
255
  const refresherConfig = React.useMemo(() => {
207
256
  const listRefresherEnabled = props.refresherEnabled !== false && (props.refresherEnabled === true || props.onRefresherRefresh != null);
208
257
  const baseFromList = listRefresherEnabled
@@ -220,7 +269,6 @@ const InnerList = (props, ref) => {
220
269
  onRefresherStatusChange: props.onRefresherStatusChange,
221
270
  }
222
271
  : null;
223
- // 通过 displayName 检测 Refresher 子组件(保持与 Refresher 组件解耦)
224
272
  const isRefresherComponent = (child) => {
225
273
  const type = child.type;
226
274
  return (type === null || type === void 0 ? void 0 : type.displayName) === 'Refresher' || (type === null || type === void 0 ? void 0 : type.name) === 'Refresher';
@@ -268,7 +316,8 @@ const InnerList = (props, ref) => {
268
316
  if (noMoreCount > 1) {
269
317
  return;
270
318
  }
271
- config = child.props;
319
+ const childProps = child.props;
320
+ config = Object.assign(Object.assign({}, childProps), { visible: childProps.visible !== false });
272
321
  }
273
322
  });
274
323
  // Props 方式转换为配置(优先级低于子组件)
@@ -282,8 +331,10 @@ const InnerList = (props, ref) => {
282
331
  }
283
332
  return config;
284
333
  }, [children, props.showNoMore, props.noMoreText, props.noMoreStyle, props.renderNoMore]);
285
- // 使用 Refresher hook(平台适配);H5 用 addImperativeTouchListeners 挂 { passive: false },避免 preventDefault 报错
286
- const { scrollViewRefresherProps, scrollViewRefresherHandlers, h5RefresherProps, addImperativeTouchListeners, renderRefresherContent, } = useRefresher(refresherConfig, isH5 && refresherConfig && !supportsNativeRefresher ? 0 : renderOffset);
334
+ // Refresher 平台适配:H5 用 addImperativeTouchListeners
335
+ const { scrollViewRefresherProps, scrollViewRefresherHandlers, h5RefresherProps, addImperativeTouchListeners, renderRefresherContent, slotHeight: h5RefresherSlotHeight, } = useRefresher(refresherConfig, isH5 && refresherConfig && !supportsNativeRefresher ? 0 : renderOffset, useScrollElementMode
336
+ ? (ev) => { var _a, _b; return (_b = (ev.target != null && ((_a = contentWrapperRef.current) === null || _a === void 0 ? void 0 : _a.contains(ev.target)))) !== null && _b !== void 0 ? _b : false; }
337
+ : undefined, !!(isH5 && refresherConfig && !supportsNativeRefresher && refresherConfig.refresherEnabled !== false));
287
338
  const refresherTeardownRef = React.useRef(null);
288
339
  const addImperativeTouchListenersRef = React.useRef(addImperativeTouchListeners);
289
340
  addImperativeTouchListenersRef.current = addImperativeTouchListeners;
@@ -291,17 +342,16 @@ const InnerList = (props, ref) => {
291
342
  if (refresherTeardownRef.current)
292
343
  refresherTeardownRef.current();
293
344
  }, []);
294
- // H5 下拉刷新时顶部 Refresher 块高度(用于总高、scroll 偏移、内容跟随手指 wrapper);refresherEnabled=false 时不展示顶栏
295
- // 默认 50px(常量来自 useRefresher),有自定义 children 时由 renderRefresherContent 内容撑开
296
- const refresherHeightForH5 = (isH5 && refresherConfig && !supportsNativeRefresher && refresherConfig.refresherEnabled !== false) ? DEFAULT_REFRESHER_HEIGHT : 0;
297
- // 解析分组结构,只支持 StickySection 和 ListItem 作为直接子组件
298
- // 过滤掉 RefresherNoMore 组件
345
+ // H5 下拉刷新顶栏高度:自定义内容由 useRefresher ResizeObserver 测量,否则默认 50
346
+ const refresherHeightForH5 = (isH5 && refresherConfig && !supportsNativeRefresher && refresherConfig.refresherEnabled !== false)
347
+ ? h5RefresherSlotHeight
348
+ : 0;
349
+ // 解析分组结构:StickySection、ListItem 为直接子组件,过滤 Refresher/NoMore
299
350
  const sections = React.useMemo(() => {
300
351
  const result = [];
301
352
  const defaultItems = [];
302
353
  React.Children.forEach(children, (child, idx) => {
303
354
  if (React.isValidElement(child) && child.type === StickySection) {
304
- // 分组模式
305
355
  const sectionProps = child.props;
306
356
  let header = null;
307
357
  const items = [];
@@ -314,19 +364,16 @@ const InnerList = (props, ref) => {
314
364
  result.push({ header, items, key: child.key || String(idx) });
315
365
  }
316
366
  else if (React.isValidElement(child) && child.type === ListItem) {
317
- // 普通 ListItem
318
367
  defaultItems.push(child);
319
368
  }
320
- // 忽略 Refresher 和 NoMore 组件(已在上面提取配置)
321
369
  });
322
370
  if (defaultItems.length > 0) {
323
371
  result.push({ header: null, items: defaultItems, key: 'default' });
324
372
  }
325
373
  return result;
326
374
  }, [children]);
327
- // === 动态尺寸管理(新增)⭐ ===
328
- const defaultEstimatedSize = isHorizontal ? DEFAULT_ITEM_WIDTH : DEFAULT_ITEM_HEIGHT;
329
- const estimatedSize = (_a = props.estimatedItemSize) !== null && _a !== void 0 ? _a : defaultEstimatedSize;
375
+ // 动态尺寸管理
376
+ const estimatedSize = resolveItemSizeByIndex(0, defaultItemSize);
330
377
  // 计算总 item 数量(跨所有 section)
331
378
  const totalItemCount = React.useMemo(() => {
332
379
  return sections.reduce((sum, section) => sum + section.items.length, 0);
@@ -340,129 +387,83 @@ const InnerList = (props, ref) => {
340
387
  // 动态尺寸缓存更新版本:setItemSize 后递增,用于驱动 sectionOffsets/totalLength 与 item 定位重算
341
388
  const [sizeCacheVersion, setSizeCacheVersion] = React.useState(0);
342
389
  const sizeCacheRafRef = React.useRef(null);
343
- // 小程序端:测量触发 re-render 时,保护 scroll-top 不被重置的标记
344
- // 仅在 RAF 回调(测量完成后)设为 true,在 render 阶段读取并自消费(设为 false)
345
- const measureScrollProtectRef = React.useRef(false);
346
- // 小程序端(定高路径):滚动期间 sizeCacheVersion bump 被延迟,此标记记录有 pending bump 需要 flush
347
- const pendingBumpRef = React.useRef(false);
348
- // 定高路径:flush 后需要在下一帧恢复滚动位置(存储目标 scrollTop,null 表示无需恢复)
349
- const scrollRestoreRef = React.useRef(null);
350
- // 定高路径:restore 来源滚动位置(用于判断 restore 是否过期)
351
- const scrollRestoreFromRef = React.useRef(null);
352
- // 小程序端(定高路径):flush 恢复窗口标记。
353
- const weappFlushRestorePendingRef = React.useRef(false);
354
- // 定高路径:滚动期间累积的测量记录,用于 flush 时计算滚动修正量
355
- const pendingMeasurementsRef = React.useRef(new Map());
356
- // 小程序端:延迟 bump 时不同步调用 onItemSizeChange,避免父组件重渲染导致 List remount 闪回顶部;flush 时统一调用
357
- const pendingSizeChangesRef = React.useRef([]);
358
- // 小程序端(定高路径):pending 测量的“微动静止检测”
359
- const scheduleWeappSettleFlushCheck = React.useCallback(() => {
360
- if (!isWeapp)
361
- return;
362
- if (settleCheckTimerRef.current)
363
- return;
364
- const check = () => {
365
- settleCheckTimerRef.current = null;
366
- const hasPending = pendingBumpRef.current || pendingMeasurementsRef.current.size > 0;
367
- if (!hasPending || !isUserScrollingRef.current)
368
- return;
369
- const nowOffset = lastScrollTopRef.current;
370
- const delta = Math.abs(nowOffset - settleLastOffsetRef.current);
371
- settleLastOffsetRef.current = nowOffset;
372
- if (delta <= 1) {
373
- settleStillCountRef.current += 1;
374
- }
375
- else {
376
- settleStillCountRef.current = 0;
377
- }
378
- const idleFor = Date.now() - lastScrollEventAtRef.current;
379
- // 条件:连续两次小位移且最近 scroll 事件已进入静止窗口
380
- if (settleStillCountRef.current >= 2 && idleFor >= 140) {
381
- if (scrollTimeoutRef.current) {
382
- clearTimeout(scrollTimeoutRef.current);
383
- scrollTimeoutRef.current = null;
384
- }
385
- isScrollingRef.current = false;
386
- isUserScrollingRef.current = false;
387
- setIsUserScrolling(false);
388
- settleStillCountRef.current = 0;
389
- return;
390
- }
391
- settleCheckTimerRef.current = setTimeout(check, 80);
392
- };
393
- settleLastOffsetRef.current = lastScrollTopRef.current;
394
- settleStillCountRef.current = 0;
395
- settleCheckTimerRef.current = setTimeout(check, 80);
396
- }, [isWeapp]);
390
+ // (measureScrollProtectRef 已移除:scrollTop + 内容变更同帧 setData 会与 scrollAnchoring
391
+ // 冲突导致归顶;改由 scrollAnchoring=true 独立处理内容重排,无需显式传 scrollTop 保护)
397
392
  // 动态尺寸缓存
398
393
  const sizeCache = useItemSizeCache({
399
394
  isHorizontal,
400
- estimatedItemSize: estimatedSize,
395
+ estimatedSize,
401
396
  itemCount: totalItemCount
402
397
  });
403
398
  // header 动态尺寸缓存(sectionIndex -> size)
404
399
  const headerSizeCacheRef = React.useRef(new Map());
405
- // 滚动修正的可见起始索引(后续更新)
400
+ // 滚动修正的可见起始索引
406
401
  const visibleStartIndexRef = React.useRef(0);
407
- // ScrollTop 修正
408
- // 小程序端禁用 scrollCorrection:
409
- // 1. 其 100ms setTimeout 的闭包捕获的 renderOffset/setScrollOffset 可能过期,导致错误的程序性滚动
410
- // 2. 小程序采用「延迟更新」策略(滚动中不 bump sizeCacheVersion),不需要实时修正
411
- // 3. 其 setScrollOffset 调用 updateRenderOffset(true) 会触发 setData,可能干扰 scroll-view
402
+ // ScrollTop 修正(仅 H5):动高时尺寸变化自动修正 scrollTop
412
403
  const scrollCorrectionEnabled = !isWeapp && props.useResizeObserver === true;
413
404
  const scrollCorrection = useScrollCorrection({
414
405
  enabled: scrollCorrectionEnabled,
415
- visibleStartIndex: visibleStartIndexRef.current,
406
+ visibleStartIndexRef,
416
407
  setScrollOffset: (offsetOrUpdater) => {
417
408
  const newOffset = typeof offsetOrUpdater === 'function'
418
409
  ? offsetOrUpdater(renderOffset)
419
410
  : offsetOrUpdater;
420
- updateRenderOffset(newOffset, true); // 程序性修正需同步到 ScrollView
411
+ updateRenderOffset(newOffset, true, 'scrollCorrection'); // 程序性修正需同步到 ScrollView
421
412
  }
422
413
  });
423
- // 小程序 + 动高(virtual-list 风格):测量变化后同帧重排,不做程序性 scrollTop 回拉
414
+ const scrollCorrectionRef = React.useRef(scrollCorrection);
415
+ scrollCorrectionRef.current = scrollCorrection;
416
+ const onScrollRef = React.useRef(onScroll);
417
+ onScrollRef.current = onScroll;
418
+ const onScrollToUpperRef = React.useRef(onScrollToUpper);
419
+ onScrollToUpperRef.current = onScrollToUpper;
420
+ const onScrollToLowerRef = React.useRef(onScrollToLower);
421
+ onScrollToLowerRef.current = onScrollToLower;
422
+ const thresholdRef = React.useRef({ upper: upperThreshold, lower: lowerThreshold });
423
+ thresholdRef.current = { upper: upperThreshold, lower: lowerThreshold };
424
+ const listContentLengthRef = React.useRef(0);
425
+ const inUpperZoneRef = React.useRef(true);
426
+ const inLowerZoneRef = React.useRef(false);
427
+ // 小程序 + 动高(virtual-list 风格):测量变化后同帧重排,不做程序性 scrollTop 回拉。
428
+ // 若用户正在滚动(惯性未结束),推迟到滚动停止后再触发,防止内容重排 + setData 同帧
429
+ // 导致 WeChat scrollAnchoring 失效或传滞后位置造成归顶。
424
430
  const scheduleWeappDynamicReflow = React.useCallback(() => {
425
431
  if (!isWeapp || props.useResizeObserver !== true)
426
432
  return;
433
+ // 滚动中:仅标记为待处理,等 onScrollEnd / 200ms 超时触发
434
+ if (isUserScrollingRef.current) {
435
+ pendingWeappReflowRef.current = true;
436
+ return;
437
+ }
427
438
  if (sizeCacheRafRef.current != null)
428
439
  return;
429
440
  sizeCacheRafRef.current = requestAnimationFrame(() => {
430
441
  sizeCacheRafRef.current = null;
431
- measureScrollProtectRef.current = true;
442
+ pendingWeappReflowRef.current = false;
432
443
  setSizeCacheVersion((v) => v + 1);
433
444
  });
434
445
  }, [isWeapp, props.useResizeObserver]);
446
+ // 每次渲染时更新 ref,保证 setTimeout 闭包内拿到最新版本
447
+ scheduleWeappDynamicReflowRef.current = scheduleWeappDynamicReflow;
435
448
  // ResizeObserver(当启用动态测量时)
436
449
  const resizeObserver = useResizeObserver({
437
450
  enabled: props.useResizeObserver === true,
438
451
  isHorizontal,
439
452
  listId,
453
+ selectorQueryScope: props.selectorQueryScope,
440
454
  onResize: (index, size) => {
441
455
  var _a;
442
456
  const oldSize = sizeCache.getItemSize(index);
443
457
  sizeCache.setItemSize(index, size);
444
- // 仅当尺寸实际变化(≥1px)时才批量驱动重渲染,避免重复/微小变化导致卡顿
445
458
  if (Math.abs(oldSize - size) >= 1) {
446
459
  if (isWeapp && props.useResizeObserver === true) {
447
460
  scheduleWeappDynamicReflow();
448
461
  }
449
- else {
450
- // 记录测量变化(用于 flush 时计算滚动修正)
451
- if (isWeapp) {
452
- weappRecordMeasurement(pendingMeasurementsRef, index, oldSize, size);
453
- scheduleWeappSettleFlushCheck();
454
- }
455
- if (sizeCacheRafRef.current == null) {
456
- sizeCacheRafRef.current = requestAnimationFrame(() => {
457
- sizeCacheRafRef.current = null;
458
- if (isWeapp) {
459
- weappSizeCacheVersionBump(isUserScrollingRef, pendingBumpRef, measureScrollProtectRef, setSizeCacheVersion);
460
- }
461
- else {
462
- setSizeCacheVersion((v) => v + 1);
463
- }
464
- });
465
- }
462
+ else if (sizeCacheRafRef.current == null) {
463
+ sizeCacheRafRef.current = requestAnimationFrame(() => {
464
+ sizeCacheRafRef.current = null;
465
+ setSizeCacheVersion((v) => v + 1);
466
+ });
466
467
  }
467
468
  }
468
469
  // 触发 ScrollTop 修正
@@ -470,92 +471,70 @@ const InnerList = (props, ref) => {
470
471
  // 小程序:延迟 onItemSizeChange,避免父组件重渲染导致 List remount
471
472
  // H5:直接回调
472
473
  if (isWeapp) {
473
- weappDeferItemSizeChange(isUserScrollingRef, pendingSizeChangesRef, index, size, props.onItemSizeChange);
474
+ weappDeferItemSizeChange(index, size, props.onItemSizeChange);
474
475
  }
475
476
  else {
476
477
  (_a = props.onItemSizeChange) === null || _a === void 0 ? void 0 : _a.call(props, index, size);
477
478
  }
478
479
  }
479
480
  });
480
- // 工具:获取 header 默认/估算尺寸
481
- const getDefaultHeaderSize = () => {
482
- if (isHorizontal) {
483
- if (typeof props.headerWidth === 'number')
484
- return props.headerWidth;
485
- if (typeof props.itemWidth === 'number')
486
- return props.itemWidth;
487
- if (typeof props.itemSize === 'number')
488
- return props.itemSize;
489
- if (typeof props.itemSize === 'function')
490
- return props.itemSize(0, props.itemData) || DEFAULT_ITEM_WIDTH;
491
- return DEFAULT_ITEM_WIDTH;
481
+ // 嵌套滚动:内层高度变化上报
482
+ const handleReportNestedHeightChange = React.useCallback((height, index) => {
483
+ if (height <= 0)
484
+ return;
485
+ const estimatedHeader = estimatedSize * 0.5;
486
+ const fullHeight = height + estimatedHeader;
487
+ const oldSize = sizeCache.getItemSize(index);
488
+ if (Math.abs(oldSize - fullHeight) < 1)
489
+ return;
490
+ sizeCache.setItemSize(index, fullHeight);
491
+ scrollCorrection.recordSizeChange(index, oldSize, fullHeight);
492
+ if (isWeapp && props.useResizeObserver === true) {
493
+ scheduleWeappDynamicReflow();
492
494
  }
493
- else {
494
- if (typeof props.headerHeight === 'number')
495
- return props.headerHeight;
496
- if (typeof props.itemHeight === 'number')
497
- return props.itemHeight;
498
- if (typeof props.itemSize === 'number')
499
- return props.itemSize;
500
- if (typeof props.itemSize === 'function')
501
- return props.itemSize(0, props.itemData) || DEFAULT_ITEM_HEIGHT;
502
- return DEFAULT_ITEM_HEIGHT;
495
+ else if (sizeCacheRafRef.current == null) {
496
+ sizeCacheRafRef.current = requestAnimationFrame(() => {
497
+ sizeCacheRafRef.current = null;
498
+ setSizeCacheVersion((v) => v + 1);
499
+ });
503
500
  }
504
- };
505
- // 工具:获取 header 尺寸(支持动态测量)
501
+ }, [sizeCache, scrollCorrection, estimatedSize, isWeapp, props.useResizeObserver, scheduleWeappDynamicReflow]);
502
+ const getDefaultHeaderSize = React.useCallback(() => {
503
+ const headerSize = normalizeSize(props.headerSize);
504
+ if (headerSize != null)
505
+ return headerSize;
506
+ return resolveItemSizeByIndex(0, defaultItemSize);
507
+ }, [props.headerSize, resolveItemSizeByIndex, defaultItemSize, normalizeSize]);
508
+ // 获取 header 尺寸(支持动态测量)
506
509
  const getHeaderSize = React.useCallback((sectionIndex) => {
507
- // 如果启用动态测量,优先从缓存读取
508
510
  if (props.useResizeObserver === true) {
509
511
  const cached = headerSizeCacheRef.current.get(sectionIndex);
510
512
  if (cached != null && cached > 0)
511
513
  return cached;
512
514
  }
513
- // 否则返回默认尺寸
514
515
  return getDefaultHeaderSize();
515
- }, [props.useResizeObserver, props.headerHeight, props.headerWidth, props.itemHeight, props.itemWidth, props.itemSize, props.itemData, isHorizontal]);
516
- // 工具:获取 item 尺寸,支持函数/props/默认值/动态测量
516
+ }, [props.useResizeObserver, getDefaultHeaderSize]);
517
517
  const getItemSize = React.useCallback((index) => {
518
- // 优先级1:如果启用动态测量,从缓存读取
519
518
  if (props.useResizeObserver === true) {
520
519
  return sizeCache.getItemSize(index);
521
520
  }
522
- // 优先级2:固定尺寸或函数计算
523
- if (isHorizontal) {
524
- if (typeof props.itemWidth === 'number')
525
- return props.itemWidth;
526
- if (typeof props.itemSize === 'number')
527
- return props.itemSize;
528
- if (typeof props.itemSize === 'function')
529
- return props.itemSize(index, props.itemData) || DEFAULT_ITEM_WIDTH;
530
- return DEFAULT_ITEM_WIDTH;
531
- }
532
- else {
533
- if (typeof props.itemHeight === 'number')
534
- return props.itemHeight;
535
- if (typeof props.itemSize === 'number')
536
- return props.itemSize;
537
- if (typeof props.itemSize === 'function')
538
- return props.itemSize(index, props.itemData) || DEFAULT_ITEM_HEIGHT;
539
- return DEFAULT_ITEM_HEIGHT;
540
- }
541
- }, [props.useResizeObserver, props.itemWidth, props.itemHeight, props.itemSize, props.itemData, isHorizontal, sizeCache]);
542
- // 计算分组累积高度/宽度(依赖 sizeCacheVersion,动态测量更新后重算,保证最外层容器高度与 item 定位正确)
521
+ return resolveItemSizeByIndex(index, defaultItemSize);
522
+ }, [props.useResizeObserver, sizeCache, resolveItemSizeByIndex, defaultItemSize]);
523
+ // 分组累积高度/宽度,sizeCacheVersion 变化时重算
543
524
  const sectionOffsets = React.useMemo(() => {
544
525
  const offsets = [0];
545
- let globalItemIndex = 0; // 累加全局索引
526
+ let globalItemIndex = 0;
546
527
  sections.forEach((section, sectionIdx) => {
547
528
  const headerSize = getHeaderSize(sectionIdx);
548
- // 使用全局索引计算每个 item 的尺寸
549
529
  const itemSizes = section.items.map((_, localIdx) => getItemSize(globalItemIndex + localIdx));
550
530
  const groupSize = (section.header ? headerSize : 0) +
551
531
  itemSizes.reduce((a, b) => a + b, 0) +
552
532
  Math.max(0, section.items.length) * space;
553
533
  offsets.push(offsets[offsets.length - 1] + groupSize);
554
- // 累加当前 section 的 item 数量
555
534
  globalItemIndex += section.items.length;
556
535
  });
557
536
  return offsets;
558
- }, [sections, space, isHorizontal, props.headerHeight, props.headerWidth, props.itemHeight, props.itemWidth, props.itemSize, props.itemData, getItemSize, getHeaderSize, sizeCacheVersion]);
537
+ }, [sections, space, getItemSize, getHeaderSize, sizeCacheVersion]);
559
538
  // 外层虚拟滚动:可见分组
560
539
  const [startSection, endSection] = React.useMemo(() => {
561
540
  let start = 0;
@@ -574,8 +553,7 @@ const InnerList = (props, ref) => {
574
553
  }
575
554
  return [start, end];
576
555
  }, [renderOffset, containerLength, sectionOffsets, sections.length, cacheCount]);
577
- // 计算视口内可见 item 的全局索引范围(用于 onScrollIndex:懒加载、埋点等)
578
- // 按视口 [renderOffset, renderOffset + containerLength] 精确计算,而非「可见 section 的整段 item」
556
+ // 视口内可见 item 的全局索引范围(供 onScrollIndex
579
557
  const [visibleStartItem, visibleEndItem] = React.useMemo(() => {
580
558
  const viewportTop = renderOffset;
581
559
  const viewportBottom = renderOffset + containerLength;
@@ -605,11 +583,9 @@ const InnerList = (props, ref) => {
605
583
  return [0, 0];
606
584
  return [firstVisible, lastVisible];
607
585
  }, [renderOffset, containerLength, sections, sectionOffsets, getHeaderSize, getItemSize, space]);
608
- // 触发 onScrollIndex 回调(带防重复)
609
586
  const lastVisibleRangeRef = React.useRef({ start: -1, end: -1 });
610
587
  React.useEffect(() => {
611
588
  if (props.onScrollIndex) {
612
- // 避免重复触发
613
589
  if (lastVisibleRangeRef.current.start !== visibleStartItem ||
614
590
  lastVisibleRangeRef.current.end !== visibleEndItem) {
615
591
  lastVisibleRangeRef.current = { start: visibleStartItem, end: visibleEndItem };
@@ -617,18 +593,8 @@ const InnerList = (props, ref) => {
617
593
  }
618
594
  }
619
595
  }, [visibleStartItem, visibleEndItem, props.onScrollIndex]);
620
- // 触顶/触底事件
621
- React.useEffect(() => {
622
- if (onScrollToUpper && renderOffset <= (upperThreshold > 0 ? sectionOffsets[upperThreshold] : 0)) {
623
- onScrollToUpper();
624
- }
625
- if (onScrollToLower && renderOffset + containerLength >= sectionOffsets[sectionOffsets.length - 1] - (lowerThreshold > 0 ? sectionOffsets[sectionOffsets.length - 1] - sectionOffsets[sections.length - lowerThreshold] : 0)) {
626
- onScrollToLower();
627
- }
628
- }, [renderOffset, containerLength, sectionOffsets, sections.length, upperThreshold, lowerThreshold, onScrollToUpper, onScrollToLower]);
629
- // 智能滚动处理函数(H5 下拉刷新时 scrollTop 含 Refresher 区,虚拟列表用列表内偏移)
630
596
  const handleScroll = React.useCallback((e) => {
631
- var _a, _b;
597
+ var _a, _b, _c, _d;
632
598
  let newOffset;
633
599
  if (e.detail) {
634
600
  newOffset = isHorizontal ? e.detail.scrollLeft : e.detail.scrollTop;
@@ -636,197 +602,108 @@ const InnerList = (props, ref) => {
636
602
  else {
637
603
  newOffset = isHorizontal ? e.scrollLeft : e.scrollTop;
638
604
  }
639
- // H5 顶栏悬浮:只滚列表,scrollTop 即列表偏移,无需 clamp
640
605
  const effectiveOffset = newOffset;
641
- lastScrollEventAtRef.current = Date.now();
642
- // 若新一轮用户滚动已开始,取消尚未执行的旧 restore(避免两个事务互相打架)
643
- if (isWeapp && props.useResizeObserver !== true && scrollRestoreRef.current !== null && !programmaticCooldownRef.current) {
644
- const restoreFrom = (_a = scrollRestoreFromRef.current) !== null && _a !== void 0 ? _a : lastScrollTopRef.current;
645
- if (Math.abs(effectiveOffset - restoreFrom) > 8) {
646
- scrollRestoreRef.current = null;
647
- scrollRestoreFromRef.current = null;
648
- }
649
- }
650
- // 小程序端:flush 重排后有一帧不传 scroll-top,原生会归 0 并触发 onScroll(0)。effect 内会立刻清掉 scrollRestoreRef,
651
- // onScroll(0) 在 effect 之后才触发会被误接受导致「第二次抖动:回顶+空白」。用 weappFlushRestorePendingRef 标记
652
- // flush nextTick restore 执行完的整段窗口,此期间忽略 effectiveOffset<=1。
653
- if (isWeapp &&
654
- props.useResizeObserver !== true &&
655
- (scrollRestoreRef.current !== null || weappFlushRestorePendingRef.current) &&
656
- effectiveOffset <= 1) {
657
- return;
658
- }
659
- // 小程序端:程序性恢复后的短窗口内,过滤异常“回顶/大幅反向跳变”噪声事件
660
- // 动高分支改走 virtual-list 风格,不在这里做程序性回拉。
661
- if (isWeapp && props.useResizeObserver !== true && suppressResetUntilRef.current > Date.now()) {
662
- const target = (_b = programmaticTargetOffsetRef.current) !== null && _b !== void 0 ? _b : lastScrollTopRef.current;
663
- const isResetToTop = effectiveOffset <= 1 && target > 40;
664
- if (isResetToTop) {
665
- const now = Date.now();
666
- // 仅“忽略”会导致原生位置与虚拟渲染位置脱节(空白)。
667
- // 这里改为回补一次目标位置,保持两者一致。
668
- if (now - guardHealAtRef.current > 120) {
669
- guardHealAtRef.current = now;
670
- updateRenderOffset(target, !isUserScrollingRef.current);
671
- }
672
- return;
673
- }
674
- }
606
+ const currentThreshold = thresholdRef.current;
607
+ const { upper, lower } = currentThreshold;
608
+ // WeChat 小程序节点不具备 clientHeight/clientWidth,需显式回退到 containerLength state
609
+ const rawContainerSize = containerRef.current
610
+ ? (isHorizontal ? containerRef.current.clientWidth : containerRef.current.clientHeight)
611
+ : null;
612
+ const currentContainerLength = (typeof rawContainerSize === 'number' && rawContainerSize > 0)
613
+ ? rawContainerSize
614
+ : containerLength;
615
+ const nowInUpper = effectiveOffset <= upper;
616
+ const currentContentLength = listContentLengthRef.current;
617
+ const nowInLower = currentContentLength > 0 && effectiveOffset + currentContainerLength >= currentContentLength - lower;
675
618
  const diff = effectiveOffset - lastScrollTopRef.current;
676
619
  scrollDiffListRef.current.shift();
677
620
  scrollDiffListRef.current.push(diff);
678
- if (isScrollingRef.current && isShaking(scrollDiffListRef.current)) {
621
+ const shaking = isScrollingRef.current && isShaking(scrollDiffListRef.current);
622
+ if (shaking) {
679
623
  return;
680
624
  }
681
- // 小程序端程序性滚动冷却期内:只更新 renderOffset 用于虚拟列表渲染,不触发同步逻辑,避免被"拉回"
682
- // 注意:programmaticCooldownRef 只在小程序端会被设为 true,H5 端不受影响
683
625
  if (programmaticCooldownRef.current) {
684
626
  lastScrollTopRef.current = effectiveOffset;
685
627
  setRenderOffset(effectiveOffset);
686
- onScroll === null || onScroll === void 0 ? void 0 : onScroll({
687
- scrollTop: isHorizontal ? 0 : newOffset,
688
- scrollLeft: isHorizontal ? newOffset : 0
689
- });
628
+ const scrollTop = isHorizontal ? 0 : newOffset;
629
+ const scrollLeft = isHorizontal ? newOffset : 0;
630
+ onScroll === null || onScroll === void 0 ? void 0 : onScroll({ scrollTop, scrollLeft, detail: { scrollTop, scrollLeft } });
631
+ if (nowInUpper && !inUpperZoneRef.current)
632
+ (_a = onScrollToUpperRef.current) === null || _a === void 0 ? void 0 : _a.call(onScrollToUpperRef);
633
+ if (nowInLower && !inLowerZoneRef.current)
634
+ (_b = onScrollToLowerRef.current) === null || _b === void 0 ? void 0 : _b.call(onScrollToLowerRef);
635
+ inUpperZoneRef.current = nowInUpper;
636
+ inLowerZoneRef.current = nowInLower;
690
637
  return;
691
638
  }
692
639
  scrollCorrection.markUserScrolling();
693
- updateRenderOffset(effectiveOffset, false);
694
- onScroll === null || onScroll === void 0 ? void 0 : onScroll({
695
- scrollTop: isHorizontal ? 0 : newOffset,
696
- scrollLeft: isHorizontal ? newOffset : 0
697
- });
698
- }, [isHorizontal, onScroll, updateRenderOffset, containerLength, scrollCorrection, props.useResizeObserver]);
699
- // 小程序端:优先使用原生 onScrollEnd 作为“滚动停止”信号,尽快触发 flush
700
- // timeout 仍保留作为兜底(某些场景 onScrollEnd 可能不稳定)
640
+ updateRenderOffset(effectiveOffset, false, 'onScroll');
641
+ const scrollTop = isHorizontal ? 0 : newOffset;
642
+ const scrollLeft = isHorizontal ? newOffset : 0;
643
+ onScroll === null || onScroll === void 0 ? void 0 : onScroll({ scrollTop, scrollLeft, detail: { scrollTop, scrollLeft } });
644
+ if (nowInUpper && !inUpperZoneRef.current)
645
+ (_c = onScrollToUpperRef.current) === null || _c === void 0 ? void 0 : _c.call(onScrollToUpperRef);
646
+ if (nowInLower && !inLowerZoneRef.current)
647
+ (_d = onScrollToLowerRef.current) === null || _d === void 0 ? void 0 : _d.call(onScrollToLowerRef);
648
+ inUpperZoneRef.current = nowInUpper;
649
+ inLowerZoneRef.current = nowInLower;
650
+ }, [isHorizontal, onScroll, updateRenderOffset, scrollCorrection, props.useResizeObserver, containerLength]);
651
+ // 小程序:onScrollEnd 优先结束 isUserScrolling,timeout 兜底
701
652
  const handleNativeScrollEnd = React.useCallback(() => {
702
653
  if (isWeapp) {
703
654
  if (scrollTimeoutRef.current) {
704
655
  clearTimeout(scrollTimeoutRef.current);
705
656
  scrollTimeoutRef.current = null;
706
657
  }
707
- if (settleCheckTimerRef.current) {
708
- clearTimeout(settleCheckTimerRef.current);
709
- settleCheckTimerRef.current = null;
710
- }
711
- const hasPending = props.useResizeObserver === true
712
- ? false
713
- : (pendingBumpRef.current || pendingMeasurementsRef.current.size > 0);
714
- if (isUserScrollingRef.current || hasPending) {
658
+ if (isUserScrollingRef.current) {
715
659
  isScrollingRef.current = false;
716
660
  isUserScrollingRef.current = false;
717
661
  setIsUserScrolling(false);
662
+ // 滚动结束后触发期间被延迟的 reflow(item 首次测量触发)
663
+ if (pendingWeappReflowRef.current) {
664
+ scheduleWeappDynamicReflowRef.current();
665
+ }
718
666
  }
719
667
  }
720
668
  onScrollEnd === null || onScrollEnd === void 0 ? void 0 : onScrollEnd();
721
669
  }, [isWeapp, onScrollEnd]);
722
- // 小程序端:用户停止滚动后 flush
723
- // 1. flush 延迟的 sizeCacheVersion bump(滚动期间 setData 更新 children 会导致 scroll-view 重置)
724
- // 2. flush 延迟的 onItemSizeChange(避免父组件重渲染导致 List remount)
725
- if (isWeapp) {
726
- // flush effect:用户停止滚动后执行延迟的 sizeCacheVersion bump
727
- // 条件增强:只要存在 pending bump 或 pending 测量记录,都在第一次停住时立即 flush
728
- React.useEffect(() => {
729
- if (!isUserScrolling) {
730
- if (props.useResizeObserver === true) {
731
- // 动高 + weapp 已改为“测量即重排、原生滚动主导”,不再走 flush/restore 事务。
732
- pendingBumpRef.current = false;
733
- if (pendingMeasurementsRef.current.size > 0) {
734
- pendingMeasurementsRef.current.clear();
735
- }
736
- return;
737
- }
738
- const hasPendingMeasurements = pendingMeasurementsRef.current.size > 0;
739
- if (pendingBumpRef.current || hasPendingMeasurements) {
740
- pendingBumpRef.current = false;
741
- // 计算视口上方 item 高度变化的累积 delta
742
- // visibleStartItem = 当前视口第一个可见 item(旧布局,因为 sizeCacheVersion 还没 bump)
743
- let heightDelta = 0;
744
- pendingMeasurementsRef.current.forEach(({ originalOldSize, latestNewSize }, index) => {
745
- if (index < visibleStartItem) {
746
- heightDelta += (latestNewSize - originalOldSize);
747
- }
748
- });
749
- pendingMeasurementsRef.current.clear();
750
- // 修正后的 scrollTop = 旧 scrollTop + 视口上方高度变化量(定高 / 非动高路径)
751
- const restoreFrom = lastScrollTopRef.current;
752
- const correctedScrollTop = restoreFrom + heightDelta;
753
- scrollRestoreFromRef.current = restoreFrom;
754
- scrollRestoreRef.current = correctedScrollTop;
755
- weappFlushRestorePendingRef.current = true;
756
- measureScrollProtectRef.current = true;
757
- setSizeCacheVersion((v) => v + 1);
758
- }
759
- else {
760
- // 没有 pending bump 但可能有残留的测量记录(scrolling 结束但没有新测量),清理
761
- if (pendingMeasurementsRef.current.size > 0) {
762
- pendingMeasurementsRef.current.clear();
763
- }
764
- }
765
- // flush 延迟的 onItemSizeChange(暂时禁用,验证 scroll-anchoring)
766
- // const pending = pendingSizeChangesRef.current
767
- // if (pending.length > 0) {
768
- // pendingSizeChangesRef.current = []
769
- // pending.forEach(({ index, size }) => props.onItemSizeChange?.(index, size))
770
- // }
771
- }
772
- }, [isUserScrolling, props.useResizeObserver]);
773
- // scrollRestoreEffect:sizeCacheVersion 变化后,在下一帧单独恢复 scroll 位置
774
- // 核心思想:第一次 setData 更新 children(不带 scroll-top),
775
- // 第二次 setData 仅恢复 scroll-top(children 不变),避免同批冲突
776
- React.useEffect(() => {
777
- if (props.useResizeObserver === true)
778
- return;
779
- if (scrollRestoreRef.current === null) {
780
- weappFlushRestorePendingRef.current = false;
781
- return;
782
- }
783
- const targetPos = scrollRestoreRef.current;
784
- const sourcePos = scrollRestoreFromRef.current;
785
- scrollRestoreRef.current = null;
786
- scrollRestoreFromRef.current = null;
787
- // 使用 Taro.nextTick 确保在上一次 setData 完成后执行
788
- Taro.nextTick(() => {
789
- try {
790
- // 若用户已开始下一次滚动,则取消这次过期 restore,避免回拉/回顶/空白
791
- const userContinued = sourcePos != null &&
792
- isUserScrollingRef.current &&
793
- Math.abs(lastScrollTopRef.current - sourcePos) > 8;
794
- if (userContinued) {
795
- return;
796
- }
797
- updateRenderOffset(targetPos, true);
798
- }
799
- finally {
800
- weappFlushRestorePendingRef.current = false;
801
- }
802
- });
803
- }, [sizeCacheVersion, updateRenderOffset]);
804
- // 小程序端:保持稳定优先,暂不回放 onItemSizeChange。
805
- React.useEffect(() => {
806
- pendingSizeChangesRef.current = [];
807
- }, [isUserScrolling, sizeCacheVersion]);
808
- }
809
- // 初始化后的延迟同步 - 确保 ScrollView 与虚拟列表窗口一致(scrollTop 受控跳转)
670
+ // 暴露 scrollRef 给父组件,供内层 List/WaterFlow 传入 scrollElement
671
+ React.useLayoutEffect(() => {
672
+ if (!scrollRefProp)
673
+ return;
674
+ const el = useScrollElementMode ? effectiveScrollElement === null || effectiveScrollElement === void 0 ? void 0 : effectiveScrollElement.current : containerRef.current;
675
+ if (el) {
676
+ scrollRefProp.current = el;
677
+ }
678
+ }, [scrollRefProp, useScrollElementMode, effectiveScrollElement]);
679
+ // controlledScrollTop 变化时同步到 ScrollView
810
680
  React.useEffect(() => {
811
681
  if (typeof controlledScrollTop === 'number') {
682
+ const sv = scrollViewOffsetRef.current;
683
+ const same = isWeapp && Math.abs(sv - controlledScrollTop) < 1;
684
+ if (same) {
685
+ const intermediate = controlledScrollTop > 0 ? controlledScrollTop - 0.01 : 0.01;
686
+ setRenderOffset(intermediate);
687
+ setScrollViewOffset(intermediate);
688
+ requestAnimationFrame(() => {
689
+ setRenderOffset(controlledScrollTop);
690
+ setScrollViewOffset(controlledScrollTop);
691
+ });
692
+ }
693
+ else {
694
+ setRenderOffset(controlledScrollTop);
695
+ setScrollViewOffset(controlledScrollTop);
696
+ }
812
697
  lastScrollTopRef.current = controlledScrollTop;
813
- setRenderOffset(controlledScrollTop);
814
- setScrollViewOffset(controlledScrollTop);
815
698
  programmaticScrollRef.current = true;
816
699
  }
817
700
  }, [controlledScrollTop]);
818
701
  // 清理定时器、ResizeObserver 与尺寸缓存 RAF(仅在卸载时执行)
819
- // 注意:不要依赖整个对象(resizeObserver/scrollCorrection),否则每次渲染都会触发 cleanup,
820
- // 进而反复清掉 pendingSizeFlushTimer,导致 onItemSizeChange 批量回放永远不执行。
821
702
  React.useEffect(() => {
822
703
  return () => {
823
704
  if (scrollTimeoutRef.current) {
824
705
  clearTimeout(scrollTimeoutRef.current);
825
706
  }
826
- if (settleCheckTimerRef.current) {
827
- clearTimeout(settleCheckTimerRef.current);
828
- settleCheckTimerRef.current = null;
829
- }
830
707
  // 小程序端冷却期定时器清理
831
708
  if (isWeapp && programmaticCooldownTimerRef.current) {
832
709
  clearTimeout(programmaticCooldownTimerRef.current);
@@ -838,7 +715,7 @@ const InnerList = (props, ref) => {
838
715
  resizeObserver.disconnect();
839
716
  scrollCorrection.clearQueue();
840
717
  };
841
- }, [isWeapp, resizeObserver.disconnect, scrollCorrection.clearQueue]);
718
+ }, []);
842
719
  // scrollIntoView:仅当 scrollIntoView 变化时执行一次跳动,统一转换为 scrollTop 路径(updateRenderOffset)。
843
720
  const lastScrollIntoViewRef = React.useRef(null);
844
721
  React.useEffect(() => {
@@ -875,34 +752,65 @@ const InnerList = (props, ref) => {
875
752
  currentGlobalIndex += section.items.length;
876
753
  }
877
754
  }
878
- updateRenderOffset(targetOffset, true);
755
+ const el = effectiveScrollElement === null || effectiveScrollElement === void 0 ? void 0 : effectiveScrollElement.current;
756
+ if (useScrollElementMode && el) {
757
+ const scrollTarget = targetOffset + (isWeapp ? effectiveStartOffsetRef.current : effectiveStartOffset);
758
+ if (isH5) {
759
+ if (isHorizontal) {
760
+ el.scrollTo({ left: scrollTarget });
761
+ }
762
+ else {
763
+ el.scrollTo({ top: scrollTarget });
764
+ }
765
+ updateRenderOffset(targetOffset, true, 'scrollIntoView');
766
+ }
767
+ else if (isWeapp) {
768
+ const scrollViewId = el.id || `_ls_${listId}`;
769
+ if (!el.id)
770
+ el.id = scrollViewId;
771
+ getScrollViewContextNode(`#${scrollViewId}`).then((node) => {
772
+ var _a, _b;
773
+ if (isHorizontal) {
774
+ (_a = node === null || node === void 0 ? void 0 : node.scrollTo) === null || _a === void 0 ? void 0 : _a.call(node, { left: scrollTarget, animated: false });
775
+ }
776
+ else {
777
+ (_b = node === null || node === void 0 ? void 0 : node.scrollTo) === null || _b === void 0 ? void 0 : _b.call(node, { top: scrollTarget, animated: false });
778
+ }
779
+ updateRenderOffset(targetOffset, true, 'scrollIntoView');
780
+ });
781
+ }
782
+ else {
783
+ updateRenderOffset(targetOffset, true, 'scrollIntoView');
784
+ }
785
+ }
786
+ else {
787
+ updateRenderOffset(targetOffset, true, 'scrollIntoView');
788
+ }
879
789
  }
880
- }, [props.scrollIntoView, totalItemCount, sections, getHeaderSize, getItemSize, space, updateRenderOffset]);
881
- // 容器样式:width/height 即视口宽高;H5 刷新中禁止容器滚动(参考《H5 下拉刷新如何实现》)
790
+ }, [props.scrollIntoView, totalItemCount, sections, getHeaderSize, getItemSize, space, updateRenderOffset, useScrollElementMode, effectiveScrollElement, effectiveStartOffset, effectiveStartOffsetRef, isH5, isWeapp, isHorizontal, listId]);
791
+ // 容器样式;H5 刷新中禁止滚动
882
792
  const containerStyle = Object.assign(Object.assign({ position: 'relative', boxSizing: 'border-box', height,
883
793
  width }, style), (isH5 && refresherConfig && !supportsNativeRefresher && h5RefresherProps.isRefreshing
884
794
  ? { overflow: 'hidden' }
885
795
  : {}));
886
- // ScrollView 属性:对齐 taro-components-react ScrollView(scrollTop/scrollLeft、upperThreshold/lowerThreshold、scrollWithAnimation)
887
- const scrollViewProps = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ scrollY: !scrollX && scrollY, scrollX, style: containerStyle, className, enhanced: true, showScrollbar,
796
+ // ScrollView 属性
797
+ const scrollViewProps = Object.assign(Object.assign(Object.assign(Object.assign({ scrollY: !scrollX && scrollY, scrollX, style: containerStyle, className, enhanced: true, showScrollbar,
888
798
  upperThreshold,
889
- lowerThreshold, scrollWithAnimation: false, onScroll: handleScroll, onScrollToUpper,
890
- onScrollToLower,
891
- onScrollStart, onScrollEnd: handleNativeScrollEnd, enableBackToTop }, (isWeapp ? { scrollAnchoring: true } : {})), (typeof cacheExtent === 'number' ? { cacheExtent } : {})), { 'data-testid': 'taro-list-container' }), scrollViewRefresherProps), scrollViewRefresherHandlers), (!supportsNativeRefresher && refresherConfig && !addImperativeTouchListeners ? h5RefresherProps.touchHandlers : {}));
799
+ lowerThreshold, scrollWithAnimation: false, onScroll: handleScroll, onScrollStart, onScrollEnd: handleNativeScrollEnd, enableBackToTop }, (isWeapp ? { scrollAnchoring: true } : {})), (typeof cacheExtent === 'number' ? { cacheExtent } : {})), scrollViewRefresherProps), scrollViewRefresherHandlers);
892
800
  // H5 对齐小程序:refresherTriggered=true 时顶部立即显示加载指示器,滚到顶部并锁定滚动直至设为 false
893
801
  React.useEffect(() => {
894
802
  if (!isH5 || !refresherConfig || supportsNativeRefresher || !h5RefresherProps.isRefreshing)
895
803
  return;
896
- updateRenderOffset(0, true);
804
+ updateRenderOffset(0, true, 'refresher');
897
805
  }, [h5RefresherProps.isRefreshing, isH5, refresherConfig, supportsNativeRefresher, updateRenderOffset]);
898
- // H5 下拉刷新:只挂载一次 touch 监听,用 ref 存 addImperativeTouchListeners 避免因 config 引用变化导致 effect 循环(attach/detach 刷屏)
806
+ // H5 下拉刷新:ref 存 addImperativeTouchListeners,避免 config 变化导致 effect 循环
899
807
  React.useLayoutEffect(() => {
900
808
  const attach = addImperativeTouchListenersRef.current;
901
809
  if (!attach)
902
810
  return;
903
811
  let teardown = null;
904
812
  const tryAttach = () => {
905
- const el = containerRef.current;
813
+ const el = useScrollElementMode ? effectiveScrollElement === null || effectiveScrollElement === void 0 ? void 0 : effectiveScrollElement.current : containerRef.current;
906
814
  if (el && !refresherTeardownRef.current) {
907
815
  teardown = attach(el);
908
816
  refresherTeardownRef.current = teardown;
@@ -917,28 +825,16 @@ const InnerList = (props, ref) => {
917
825
  }
918
826
  refresherTeardownRef.current = null;
919
827
  };
920
- }, []);
921
- // scrollTop 传递策略:
922
- // - weapp 定高:每帧传 scrollViewOffset(measureProtect 帧除外)
923
- // - weapp 动高:不常态传,保持原生滚动主导,仅程序性滚动时传
924
- // - H5:仅非用户滑动时传
828
+ }, [useScrollElementMode, effectiveScrollElement]);
829
+ scrollViewOffsetRef.current = scrollViewOffset;
830
+ // scrollTop 传递策略(对齐 virtual-list enhanced=true 的做法):
831
+ // - 程序性滚动帧(programmaticScrollRef=true):传 scrollViewOffset(目标位置)
832
+ // - 其余所有帧(用户滚动 / 测量重排 / 空闲):完全不传,依赖微信原生 + scrollAnchoring
833
+ // scrollAnchoring=true 负责在内容重排时自动维持视口位置,避免与显式 scrollTop
834
+ // 同帧冲突(冲突会导致 scrollAnchoring 失去锚点后归顶)
925
835
  if (isWeapp) {
926
- const isWeappDynamicHeight = props.useResizeObserver === true;
927
- if (measureScrollProtectRef.current) {
928
- measureScrollProtectRef.current = false;
929
- }
930
- else if (isWeappDynamicHeight) {
931
- // 动高:仅程序性滚动时传,其他时刻由原生滚动主导
932
- if (programmaticScrollRef.current) {
933
- if (isHorizontal)
934
- scrollViewProps.scrollLeft = scrollViewOffset;
935
- else
936
- scrollViewProps.scrollTop = scrollViewOffset;
937
- programmaticScrollRef.current = false;
938
- }
939
- }
940
- else {
941
- // 定高:每帧传,支持受控跳转
836
+ if (programmaticScrollRef.current) {
837
+ // 程序性滚动(imperative / scrollCorrection / scrollIntoView 等):传目标位置
942
838
  if (isHorizontal) {
943
839
  scrollViewProps.scrollLeft = scrollViewOffset;
944
840
  }
@@ -947,6 +843,7 @@ const InnerList = (props, ref) => {
947
843
  }
948
844
  programmaticScrollRef.current = false;
949
845
  }
846
+ // else:用户滚动 / 空闲帧不传 scrollTop,让微信原生 + scrollAnchoring 完全接管
950
847
  }
951
848
  else {
952
849
  // H5:非用户滑动时传
@@ -959,27 +856,40 @@ const InnerList = (props, ref) => {
959
856
  }
960
857
  }
961
858
  }
962
- // H5:仅程序性滚动时写回 DOM
963
- if (isH5) {
964
- React.useEffect(() => {
965
- if (isUserScrolling || !containerRef.current || typeof scrollViewOffset !== 'number')
966
- return;
967
- if (!programmaticScrollRef.current)
968
- return;
969
- const scrollValue = isHorizontal ? scrollViewOffset : scrollViewOffset;
970
- if (isHorizontal) {
971
- containerRef.current.scrollLeft = scrollValue;
972
- }
973
- else {
974
- containerRef.current.scrollTop = scrollValue;
975
- }
976
- programmaticScrollRef.current = false;
977
- }, [scrollViewOffset, isHorizontal, isUserScrolling]);
978
- }
979
- // 总高度/宽度(包含 NoMore);H5 顶栏悬浮不占滚动高度,只滚列表
859
+ // H5:程序性滚动时写回 DOM scrollTop
860
+ React.useEffect(() => {
861
+ if (!isH5)
862
+ return;
863
+ if (isUserScrolling || !containerRef.current || typeof scrollViewOffset !== 'number')
864
+ return;
865
+ if (!programmaticScrollRef.current)
866
+ return;
867
+ const scrollValue = scrollViewOffset;
868
+ if (isHorizontal) {
869
+ containerRef.current.scrollLeft = scrollValue;
870
+ }
871
+ else {
872
+ containerRef.current.scrollTop = scrollValue;
873
+ }
874
+ programmaticScrollRef.current = false;
875
+ }, [isH5, scrollViewOffset, isHorizontal, isUserScrolling]);
876
+ // 总高度/宽度(含 NoMore
980
877
  const noMoreHeight = (noMoreConfig === null || noMoreConfig === void 0 ? void 0 : noMoreConfig.visible) ? (noMoreConfig.height || 60) : 0;
981
878
  const listContentLength = sectionOffsets[sectionOffsets.length - 1] + noMoreHeight;
982
879
  const totalLength = listContentLength;
880
+ // scrollElement 模式下 onScrollToLower 需用内层内容高度判断,供 scroll handler 读取
881
+ listContentLengthRef.current = listContentLength;
882
+ const scrollAttachRefsRef = React.useRef(null);
883
+ scrollAttachRefsRef.current = {
884
+ scrollCorrection: scrollCorrectionRef.current,
885
+ onScroll: onScrollRef.current,
886
+ onScrollToUpper: onScrollToUpperRef.current,
887
+ onScrollToLower: onScrollToLowerRef.current,
888
+ threshold: thresholdRef.current,
889
+ listContentLength: listContentLengthRef.current,
890
+ };
891
+ useListScrollElementAttach(useScrollElementMode && isH5, effectiveScrollElement, effectiveStartOffset, isHorizontal, setContainerLength, updateRenderOffset, scrollRefProp, scrollAttachRefsRef);
892
+ useListScrollElementAttachWeapp(useScrollElementMode && isWeapp, effectiveScrollElement, effectiveStartOffsetRef, effectiveStartOffset, isHorizontal, setContainerLength, updateRenderOffset, scrollRefProp, scrollAttachRefsRef, initialContainerLength, props.selectorQueryScope);
983
893
  // 吸顶/吸左 header
984
894
  const stickyHeaderNode = React.useMemo(() => {
985
895
  if (!stickyHeader)
@@ -1005,7 +915,7 @@ const InnerList = (props, ref) => {
1005
915
  }
1006
916
  }
1007
917
  return null;
1008
- }, [stickyHeader, renderOffset, sectionOffsets, sections, isHorizontal, props.headerHeight, props.headerWidth, props.itemHeight, props.itemWidth, props.itemSize, props.itemData]);
918
+ }, [stickyHeader, renderOffset, sectionOffsets, sections]);
1009
919
  // 渲染分组+item双层虚拟滚动
1010
920
  const renderSections = () => {
1011
921
  const nodes = [];
@@ -1043,12 +953,7 @@ const InnerList = (props, ref) => {
1043
953
  else if (sizeCacheRafRef.current == null) {
1044
954
  sizeCacheRafRef.current = requestAnimationFrame(() => {
1045
955
  sizeCacheRafRef.current = null;
1046
- if (isWeapp) {
1047
- weappSizeCacheVersionBump(isUserScrollingRef, pendingBumpRef, measureScrollProtectRef, setSizeCacheVersion);
1048
- }
1049
- else {
1050
- setSizeCacheVersion((v) => v + 1);
1051
- }
956
+ setSizeCacheVersion((v) => v + 1);
1052
957
  });
1053
958
  }
1054
959
  }
@@ -1066,7 +971,7 @@ const InnerList = (props, ref) => {
1066
971
  Taro.nextTick(() => {
1067
972
  if (!headerRefsRef.current.has(sectionIndex))
1068
973
  return;
1069
- Taro.createSelectorQuery()
974
+ createSelectorQueryScoped(props.selectorQueryScope)
1070
975
  .select(`#${listId}-list-header-inner-${sectionIndex}`)
1071
976
  .boundingClientRect((rect) => {
1072
977
  if (rect) {
@@ -1098,6 +1003,7 @@ const InnerList = (props, ref) => {
1098
1003
  const headerInnerProps = {
1099
1004
  ref: headerRefCallback,
1100
1005
  style: headerContentStyle,
1006
+ 'data-index': String(-sectionIndex - 1), // 用于 unobserve 获取 index;与 observe(el, -sectionIndex-1) 对应
1101
1007
  };
1102
1008
  if (isWeapp) {
1103
1009
  headerInnerProps.id = `${listId}-list-header-inner-${sectionIndex}`;
@@ -1143,7 +1049,6 @@ const InnerList = (props, ref) => {
1143
1049
  for (let i = startItem; i <= endItem; i++) {
1144
1050
  const currentGlobalIndex = globalItemIndex + i;
1145
1051
  const itemId = `list-item-${currentGlobalIndex}`;
1146
- // 内联样式替代className
1147
1052
  const sectionItemStyle = Object.assign({ position: 'absolute', zIndex: 1, boxSizing: 'border-box', width: '100%', minHeight: '20px', overflow: 'hidden', lineHeight: 1 }, (isHorizontal
1148
1053
  ? {
1149
1054
  top: 0,
@@ -1157,8 +1062,7 @@ const InnerList = (props, ref) => {
1157
1062
  height: itemSizes[i],
1158
1063
  marginBottom: space
1159
1064
  }));
1160
- // ref 回调(用于 ResizeObserver):绑定到「内层内容容器」,测量的是内容真实高度
1161
- // 后加载项:挂载后单帧 RAF 备用测量;仅当尺寸实际变化时才 bump 版本,避免与 ResizeObserver 重复触发导致连续两次重渲染(卡顿)
1065
+ // ResizeObserver:绑定内层内容容器测量真实尺寸,尺寸变化时才 bump 版本
1162
1066
  const refCallback = (el) => {
1163
1067
  if (el) {
1164
1068
  const capturedIndex = currentGlobalIndex;
@@ -1170,7 +1074,6 @@ const InnerList = (props, ref) => {
1170
1074
  var _a;
1171
1075
  if (measured > 0) {
1172
1076
  const oldSize = sizeCache.getItemSize(capturedIndex);
1173
- // 尺寸未变化,跳过
1174
1077
  if (Math.abs(oldSize - measured) < 1)
1175
1078
  return;
1176
1079
  sizeCache.setItemSize(capturedIndex, measured);
@@ -1178,29 +1081,19 @@ const InnerList = (props, ref) => {
1178
1081
  if (isWeapp && props.useResizeObserver === true) {
1179
1082
  scheduleWeappDynamicReflow();
1180
1083
  }
1181
- else if (isWeapp) {
1182
- // 记录测量变化(用于 flush 时计算滚动修正)
1183
- weappRecordMeasurement(pendingMeasurementsRef, capturedIndex, oldSize, measured);
1184
- scheduleWeappSettleFlushCheck();
1084
+ else if (sizeCacheRafRef.current == null) {
1085
+ sizeCacheRafRef.current = requestAnimationFrame(() => {
1086
+ sizeCacheRafRef.current = null;
1087
+ setSizeCacheVersion((v) => v + 1);
1088
+ });
1185
1089
  }
1186
1090
  // 小程序:延迟 onItemSizeChange,避免父组件重渲染导致 List remount
1187
1091
  if (isWeapp) {
1188
- weappDeferItemSizeChange(isUserScrollingRef, pendingSizeChangesRef, capturedIndex, measured, props.onItemSizeChange);
1092
+ weappDeferItemSizeChange(capturedIndex, measured, props.onItemSizeChange);
1189
1093
  }
1190
1094
  else {
1191
1095
  (_a = props.onItemSizeChange) === null || _a === void 0 ? void 0 : _a.call(props, capturedIndex, measured);
1192
1096
  }
1193
- if (!(isWeapp && props.useResizeObserver === true) && sizeCacheRafRef.current == null) {
1194
- sizeCacheRafRef.current = requestAnimationFrame(() => {
1195
- sizeCacheRafRef.current = null;
1196
- if (isWeapp) {
1197
- weappSizeCacheVersionBump(isUserScrollingRef, pendingBumpRef, measureScrollProtectRef, setSizeCacheVersion);
1198
- }
1199
- else {
1200
- setSizeCacheVersion((v) => v + 1);
1201
- }
1202
- });
1203
- }
1204
1097
  }
1205
1098
  };
1206
1099
  if (isH5) {
@@ -1216,7 +1109,7 @@ const InnerList = (props, ref) => {
1216
1109
  // 注意:必须选 inner 容器(无固定高度,由内容撑开),不能选 outer(有虚拟列表设置的固定高度)
1217
1110
  // 已测量过的 item 跳过,避免 refCallback 重复触发导致无限循环
1218
1111
  // 滚动期间跳过 SelectorQuery(不加入 weappMeasuredItemsRef),避免异步查询干扰 scroll-view
1219
- // SelectorQuery 是只读查询,滚动期间执行安全;sizeCacheVersion bump 由 weappSizeCacheVersionBump 延迟到滚动结束
1112
+ // SelectorQuery 是只读查询,滚动期间执行安全
1220
1113
  if (shouldMeasureWeappItem(capturedIndex, weappMeasuredItemsRef.current)) {
1221
1114
  weappMeasuredItemsRef.current.add(capturedIndex);
1222
1115
  // 使用 Taro.nextTick 代替 setTimeout(50):等待下一帧渲染完成后再测量
@@ -1224,7 +1117,7 @@ const InnerList = (props, ref) => {
1224
1117
  Taro.nextTick(() => {
1225
1118
  if (!itemRefsRef.current.has(capturedIndex))
1226
1119
  return;
1227
- Taro.createSelectorQuery()
1120
+ createSelectorQueryScoped(props.selectorQueryScope)
1228
1121
  // 页面上可能同时存在多个 List,inner id 必须带 listId 前缀避免跨列表误命中
1229
1122
  .select(`#${listId}-list-item-inner-${capturedIndex}`)
1230
1123
  .boundingClientRect((rect) => {
@@ -1274,10 +1167,39 @@ const InnerList = (props, ref) => {
1274
1167
  innerProps.id = `${listId}-list-item-inner-${currentGlobalIndex}`;
1275
1168
  }
1276
1169
  innerProps['data-index'] = String(currentGlobalIndex);
1277
- nodes.push(React.createElement(View, outerItemProps, React.createElement(View, innerProps, section.items[i])));
1170
+ const itemNode = React.createElement(View, outerItemProps, React.createElement(View, innerProps, section.items[i]));
1171
+ // 任务 4.1:当 List 暴露 scrollRef 时,为每个 item 提供 Context,供内层 WaterFlow 使用
1172
+ const itemWithContext = scrollRefProp && !useScrollElementMode
1173
+ ? React.createElement(ScrollElementContextOrFallback.Provider, {
1174
+ key: outerItemProps.key,
1175
+ value: {
1176
+ scrollRef: scrollRefProp,
1177
+ containerHeight: containerLength,
1178
+ startOffset: offset + itemOffsets[i],
1179
+ reportNestedHeightChange: props.useResizeObserver
1180
+ ? (h) => handleReportNestedHeightChange(h, currentGlobalIndex)
1181
+ : undefined,
1182
+ },
1183
+ }, itemNode)
1184
+ : itemNode;
1185
+ nodes.push(itemWithContext);
1278
1186
  }
1279
1187
  else {
1280
- nodes.push(React.createElement(View, outerItemProps, section.items[i]));
1188
+ const itemNode = React.createElement(View, outerItemProps, section.items[i]);
1189
+ const itemWithContext = scrollRefProp && !useScrollElementMode
1190
+ ? React.createElement(ScrollElementContextOrFallback.Provider, {
1191
+ key: outerItemProps.key,
1192
+ value: {
1193
+ scrollRef: scrollRefProp,
1194
+ containerHeight: containerLength,
1195
+ startOffset: offset + itemOffsets[i],
1196
+ reportNestedHeightChange: props.useResizeObserver
1197
+ ? (h) => handleReportNestedHeightChange(h, currentGlobalIndex)
1198
+ : undefined,
1199
+ },
1200
+ }, itemNode)
1201
+ : itemNode;
1202
+ nodes.push(itemWithContext);
1281
1203
  }
1282
1204
  }
1283
1205
  globalItemIndex += section.items.length;
@@ -1294,16 +1216,17 @@ const InnerList = (props, ref) => {
1294
1216
  const defaultStyle = Object.assign(Object.assign(Object.assign({ position: 'absolute' }, (isHorizontal
1295
1217
  ? { left: listContentEnd, top: 0, width: noMoreHeightValue, height: '100%' }
1296
1218
  : { top: listContentEnd, left: 0, width: '100%', height: noMoreHeightValue })), { display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', color: '#999', fontSize: '14px', boxSizing: 'border-box' }), noMoreConfig.style);
1297
- return (jsx(View, { style: defaultStyle, "data-testid": "list-nomore", children: noMoreConfig.children || noMoreConfig.text || '没有更多了' }));
1219
+ return (jsx(View, { style: defaultStyle, children: noMoreConfig.children || noMoreConfig.text || '没有更多了' }));
1298
1220
  };
1299
1221
  // 空列表场景:仅显示 NoMore
1300
1222
  if (sections.length === 0 && (noMoreConfig === null || noMoreConfig === void 0 ? void 0 : noMoreConfig.visible)) {
1301
1223
  return (jsx(ScrollView, Object.assign({ ref: containerRef }, scrollViewProps, { children: jsx(View, { style: Object.assign({ minHeight: containerLength, display: 'flex', alignItems: 'center', justifyContent: 'center' }, containerStyle), children: renderNoMoreContent() }) })));
1302
1224
  }
1303
- // 可滚区域总尺寸(H5 顶栏悬浮时 = 仅列表高度,顶栏不占滚动)
1225
+ // 可滚区域总尺寸
1226
+ // H5 refresher 用负 translateY 隐藏,有上方混排时可能延伸到 sibling 区域;overflow:hidden 裁剪避免重叠
1227
+ const needsRefresherClip = refresherHeightForH5 > 0;
1304
1228
  const contentWrapperStyle = isHorizontal
1305
- ? { width: totalLength, position: 'relative', height: '100%' }
1306
- : { height: totalLength, position: 'relative', width: '100%' };
1229
+ ? Object.assign({ width: totalLength, position: 'relative', height: '100%' }, (needsRefresherClip && { overflow: 'hidden' })) : Object.assign({ height: totalLength, position: 'relative', width: '100%' }, (needsRefresherClip && { overflow: 'hidden' }));
1307
1230
  const listWrapperStyle = isHorizontal
1308
1231
  ? { width: listContentLength, position: 'relative', height: '100%' }
1309
1232
  : { height: listContentLength, position: 'relative', width: '100%' };
@@ -1311,7 +1234,7 @@ const InnerList = (props, ref) => {
1311
1234
  ? { transform: `translateY(${h5RefresherProps.pullDistance}px)` }
1312
1235
  : {};
1313
1236
  const h5RefresherTranslateY = -refresherHeightForH5 + h5RefresherProps.pullDistance;
1314
- // 内容区域渲染(提取为独立函数以降低主组件圈复杂度)
1237
+ // 内容区域渲染
1315
1238
  const renderContentArea = () => {
1316
1239
  var _a, _b;
1317
1240
  return (jsx(View, { style: contentWrapperStyle, children: refresherHeightForH5 > 0 ? (jsxs(Fragment, { children: [jsx(View, { style: {
@@ -1324,9 +1247,23 @@ const InnerList = (props, ref) => {
1324
1247
  transform: `translateY(${h5RefresherTranslateY}px)`,
1325
1248
  }, children: renderRefresherContent() }), jsxs(View, { style: Object.assign(Object.assign(Object.assign({}, listWrapperStyle), pullTranslate), { zIndex: 1, background: (_b = (_a = style === null || style === void 0 ? void 0 : style.background) !== null && _a !== void 0 ? _a : style === null || style === void 0 ? void 0 : style.backgroundColor) !== null && _b !== void 0 ? _b : '#fff' }), children: [stickyHeaderNode, renderSections(), renderNoMoreContent()] })] })) : (jsxs(Fragment, { children: [!supportsNativeRefresher && renderRefresherContent(), stickyHeaderNode, renderSections(), renderNoMoreContent()] })) }));
1326
1249
  };
1250
+ // useScrollElementMode 或 needAutoFind&&pending:渲染 View 以便监听外部滚动或 probe 阶段查找滚动父节点
1251
+ const renderView = useScrollElementMode || (!!needAutoFind && autoFindStatus === 'pending');
1252
+ if (renderView) {
1253
+ // 任务 2.4:恢复 refresher DOM 结构(refresher 层 + listWrapperStyle)
1254
+ return (jsx(View, { ref: contentWrapperRef, id: contentId, style: contentWrapperStyle, children: refresherHeightForH5 > 0 ? (jsxs(Fragment, { children: [jsx(View, { style: {
1255
+ position: 'absolute',
1256
+ top: 0,
1257
+ left: 0,
1258
+ right: 0,
1259
+ height: refresherHeightForH5,
1260
+ zIndex: 0,
1261
+ transform: `translateY(${h5RefresherTranslateY}px)`,
1262
+ }, children: renderRefresherContent() }), jsxs(View, { style: Object.assign(Object.assign(Object.assign({}, listWrapperStyle), pullTranslate), { zIndex: 1, background: (_b = (_a = style === null || style === void 0 ? void 0 : style.background) !== null && _a !== void 0 ? _a : style === null || style === void 0 ? void 0 : style.backgroundColor) !== null && _b !== void 0 ? _b : '#fff' }), children: [stickyHeaderNode, renderSections(), renderNoMoreContent()] })] })) : (jsxs(Fragment, { children: [stickyHeaderNode, renderSections(), renderNoMoreContent()] })) }));
1263
+ }
1327
1264
  return (jsxs(ScrollView, Object.assign({ ref: containerRef }, scrollViewProps, { id: listId, children: [supportsNativeRefresher && renderRefresherContent(), renderContentArea()] })));
1328
1265
  };
1329
1266
  const List = React.forwardRef(InnerList);
1330
1267
 
1331
- export { List, ListItem, NoMore, StickyHeader, StickySection, accumulate, List as default, isShaking };
1268
+ export { List, ListItem, ScrollElementContextOrFallback as ListScrollElementContext, NoMore, StickyHeader, StickySection, accumulate, List as default, isShaking };
1332
1269
  //# sourceMappingURL=index.js.map