@miot-rn/common-component 1.0.2 → 1.0.4

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 (39) hide show
  1. package/dist/service/specs/index.js +63 -32
  2. package/dist/service/specs/spec.js +6 -0
  3. package/dist/service/specs/std-spec.js +31 -22
  4. package/dist/service/specs/subscribe-logging.test.d.ts +17 -0
  5. package/dist/service/specs/subscribe-logging.test.js +217 -0
  6. package/dist/service/track/TrackFlatList.d.ts +3 -0
  7. package/dist/service/track/TrackFlatList.js +19 -0
  8. package/dist/service/track/TrackScrollContext.d.ts +10 -0
  9. package/dist/service/track/TrackScrollContext.js +2 -0
  10. package/dist/service/track/TrackScrollView.d.ts +3 -0
  11. package/dist/service/track/TrackScrollView.js +20 -0
  12. package/dist/service/track/TrackService.d.ts +10 -0
  13. package/dist/service/track/TrackService.js +104 -0
  14. package/dist/service/track/index.d.ts +12 -0
  15. package/dist/service/track/index.js +9 -0
  16. package/dist/service/track/intersect.d.ts +8 -0
  17. package/dist/service/track/intersect.js +15 -0
  18. package/dist/service/track/intersect.test.d.ts +1 -0
  19. package/dist/service/track/intersect.test.js +189 -0
  20. package/dist/service/track/types.d.ts +16 -0
  21. package/dist/service/track/types.js +1 -0
  22. package/dist/service/track/useCardTrack.d.ts +14 -0
  23. package/dist/service/track/useCardTrack.js +61 -0
  24. package/dist/service/track/useExposeOnVisible.d.ts +11 -0
  25. package/dist/service/track/useExposeOnVisible.js +74 -0
  26. package/dist/service/track/usePageTrack.d.ts +7 -0
  27. package/dist/service/track/usePageTrack.js +101 -0
  28. package/dist/service/track/useTrack.d.ts +7 -0
  29. package/dist/service/track/useTrack.js +23 -0
  30. package/dist/service/track/useTrackScrollProvider.d.ts +24 -0
  31. package/dist/service/track/useTrackScrollProvider.js +81 -0
  32. package/dist/specs/instance-parser.js +13 -11
  33. package/dist/store/useGlobalSpecManager.d.ts +5 -2
  34. package/dist/store/useGlobalSpecManager.js +402 -63
  35. package/dist/store/useGlobalSpecManager.test.d.ts +1 -0
  36. package/dist/store/useGlobalSpecManager.test.js +247 -0
  37. package/dist/utils/subscribe-log.d.ts +1 -0
  38. package/dist/utils/subscribe-log.js +37 -0
  39. package/package.json +2 -2
@@ -19,10 +19,11 @@ import { Device } from 'miot';
19
19
  import Subject from "./subject";
20
20
  import { getEventGenerator } from "./utils";
21
21
  import { encodeProp } from "../../utils/spec";
22
- import { OK, PLUGIN_NULL_ERROR } from "./error-code";
22
+ import { OK, PLUGIN_NULL_ERROR, PROP_NOT_FOUND } from "./error-code";
23
23
  import * as spec from "./spec";
24
24
  import specCache, { CACHE_SOURCE } from "./specCache";
25
25
  import { copy } from "../../utils/objects";
26
+ import { reportSubLog } from "../../utils/subscribe-log";
26
27
  // 事件主题 management
27
28
  const specSubject = new Subject(getEventGenerator('spec_subject', encodeProp));
28
29
  export { CACHE_SOURCE };
@@ -38,6 +39,29 @@ export function isCacheEnabled() {
38
39
 
39
40
  // Type definitions
40
41
 
42
+ /**
43
+ * 对齐 native 层返回的结果数组。
44
+ *
45
+ * native 层在遇到未识别的 siid/piid 时可能整体丢弃返回项,导致返回数组比入参数组短,
46
+ * 或对应位置为 undefined。这里按 props 长度做对齐,未命中位置补 PROP_NOT_FOUND 占位,
47
+ * 避免上层 `results.forEach(r => r.code)` 抛 TypeError。
48
+ */
49
+ function alignResultsToProps(props, rawResults) {
50
+ const arr = Array.isArray(rawResults) ? rawResults : [];
51
+ return props.map((p, i) => {
52
+ const r = arr[i];
53
+ if (r && typeof r === 'object') {
54
+ return r;
55
+ }
56
+ return {
57
+ siid: p.siid,
58
+ piid: p.piid,
59
+ code: PROP_NOT_FOUND,
60
+ message: 'native returned no result'
61
+ };
62
+ });
63
+ }
64
+
41
65
  /**
42
66
  * 读取属性值
43
67
  *
@@ -108,43 +132,31 @@ export function getProps(props, options = {}) {
108
132
 
109
133
  // 从源获取数据并更新缓存
110
134
  const fetchFromSourceAndUpdateCache = cachedData => {
111
- // 如果强制刷新或缓存未命中,从源获取
112
- if (forceRefresh || !cachedData || !useCache) {
113
- return spec.getProps(props).then(results => {
114
- // 更新缓存
115
- return ensureCache().then(() => {
116
- results.forEach(result => {
117
- if (result.code === OK && result.value !== undefined) {
118
- const key = specCache.getCacheKey(result);
119
- specCache.put(key, result.value, result.updateTime || Math.floor(Date.now() / 1000), CACHE_SOURCE.GET);
120
- }
121
- });
122
- return results.map(r => ({
123
- ...r,
124
- specDataSource: r.specDataSource || CACHE_SOURCE.GET
125
- }));
135
+ const runFetch = () => spec.getProps(props).then(rawResults => {
136
+ // 与入参对齐,缺失位置补 PROP_NOT_FOUND 占位,避免 undefined 传导到上层
137
+ const results = alignResultsToProps(props, rawResults);
138
+ return ensureCache().then(() => {
139
+ results.forEach(result => {
140
+ if (result && result.code === OK && result.value !== undefined) {
141
+ const key = specCache.getCacheKey(result);
142
+ specCache.put(key, result.value, result.updateTime || Math.floor(Date.now() / 1000), CACHE_SOURCE.GET);
143
+ }
126
144
  });
145
+ return results.map(r => ({
146
+ ...r,
147
+ specDataSource: r.specDataSource || CACHE_SOURCE.GET
148
+ }));
127
149
  });
150
+ });
151
+
152
+ // 如果强制刷新或缓存未命中,从源获取
153
+ if (forceRefresh || !cachedData || !useCache) {
154
+ return runFetch();
128
155
  }
129
156
 
130
157
  // 如果缓存中有数据,且fromSourceIfNeed为true,仍然从源获取并更新
131
158
  if (fromSourceIfNeed && cachedData) {
132
- return spec.getProps(props).then(results => {
133
- // 更新缓存
134
- return ensureCache().then(() => {
135
- results.forEach(result => {
136
- if (result.code === OK && result.value !== undefined) {
137
- const key = specCache.getCacheKey(result);
138
- specCache.put(key, result.value, result.updateTime || Math.floor(Date.now() / 1000), CACHE_SOURCE.GET);
139
- }
140
- });
141
- // 返回设备最新数据,缓存仅作为提前通知使用
142
- return results.map(r => ({
143
- ...r,
144
- specDataSource: r.specDataSource || CACHE_SOURCE.GET
145
- }));
146
- });
147
- });
159
+ return runFetch();
148
160
  }
149
161
 
150
162
  // 直接返回缓存数据
@@ -338,6 +350,10 @@ export function subscribe(props, listener, options = {}) {
338
350
  // 受全局开关控制
339
351
  updateCache = cacheEnabled // 受全局开关控制
340
352
  } = options;
353
+ const subT0 = Date.now();
354
+ const subStage = (name, extra = '') => {
355
+ reportSubLog(`[SPEC-SUB][specs.subscribe][${name}][${new Date().toISOString()}] propCount=${props.length}, cost=${Date.now() - subT0}ms, publishImmediately=${publishImmediately}${extra}`);
356
+ };
341
357
 
342
358
  // 确保缓存已初始化
343
359
  const ensureCache = () => {
@@ -347,9 +363,12 @@ export function subscribe(props, listener, options = {}) {
347
363
  // 如果需要立即发布当前值,从缓存读取并通知
348
364
  const publishFromCache = () => {
349
365
  if (!publishImmediately) {
366
+ subStage('cache-skip');
350
367
  return Promise.resolve();
351
368
  }
369
+ subStage('cache-init-enter');
352
370
  return ensureCache().then(() => {
371
+ subStage('cache-init-done');
353
372
  // 从缓存读取并通知
354
373
  props.forEach(prop => {
355
374
  const key = specCache.getCacheKey(prop);
@@ -391,21 +410,33 @@ export function subscribe(props, listener, options = {}) {
391
410
 
392
411
  // 执行订阅
393
412
  const executeSubscribe = () => {
413
+ subStage('exec-subscribe-enter');
394
414
  return spec.subscribe(props, wrappedListener);
395
415
  };
396
416
 
417
+ // 看门狗:整条链路若长时间不 settle(例如 specCache.init / Host.storage.get 一直 pending),
418
+ // 成功和失败两个分支都不会执行,日志表现为彻底静默。这里用超时把"卡住"这种状态显式打出来。
419
+ const stuckTimer = setTimeout(() => {
420
+ subStage('STUCK', ', 订阅流程超过 8s 未完成,疑似 specCache.init/Host.storage.get 未 settle');
421
+ }, 8000);
422
+
397
423
  // 执行流程
398
424
  return publishFromCache().then(() => executeSubscribe()).then(result => {
425
+ clearTimeout(stuckTimer);
426
+ subStage('success', `, subscribedPropCount=${result?.props?.length}`);
399
427
  // 返回订阅对象,包含取消订阅方法
400
428
  return {
401
429
  props: result.props,
402
430
  unsubscribe: () => {
431
+ reportSubLog(`[SPEC-SUB][specs.unsubscribe][${new Date().toISOString()}] 取消订阅, propCount=${props.length}`);
403
432
  if (result && result.unsubscribe) {
404
433
  result.unsubscribe();
405
434
  }
406
435
  }
407
436
  };
408
437
  }).catch(e => {
438
+ clearTimeout(stuckTimer);
439
+ subStage('fail', `, code=${e?.code}, message=${e?.message}, raw=${JSON.stringify(e)}`);
409
440
  console.log('subscribe error:', props, e);
410
441
  return Promise.reject(e);
411
442
  });
@@ -18,6 +18,7 @@ import { reduceResults } from "./utils";
18
18
  import { isSuccess } from "./error-code";
19
19
  // 引入std-spec
20
20
  import * as stdSpec from "./std-spec";
21
+ import { reportSubLog } from "../../utils/subscribe-log";
21
22
  export const ServiceSourceType = {
22
23
  WIFI: 1,
23
24
  BLE: 2,
@@ -91,9 +92,14 @@ export function subscribe(props, listener) {
91
92
  const specSubscribeProps = current(props);
92
93
  const ps = specs.map((spec, index) => spec.subscribe(specSubscribeProps[index], listener));
93
94
  return Promise.all(ps).then(arr => {
95
+ // arr[0].props 为 undefined 说明底层订阅走了失败分支(被 mapError 转成了 resolve)
96
+ if (!arr[0] || !arr[0].props) {
97
+ reportSubLog(`[SPEC-SUB][spec.subscribe][fail][${new Date().toISOString()}] 底层返回结构异常, propCount=${props.length}, raw=${JSON.stringify(arr[0])}`);
98
+ }
94
99
  const result = {
95
100
  props: arr[0].props,
96
101
  unsubscribe: () => {
102
+ reportSubLog(`[SPEC-SUB][spec.unsubscribe][${new Date().toISOString()}] 取消订阅, subCount=${arr.length}, propCount=${props.length}`);
97
103
  subscriptions.delete(result);
98
104
  arr.forEach(sub => {
99
105
  if (sub && sub.unsubscribe) {
@@ -3,9 +3,30 @@ import Subject from "./subject";
3
3
  import { genError, getEventGenerator, mapError } from "./utils";
4
4
  import { isTypeOf } from "../../utils/types";
5
5
  import { encodeProp, decodeProp, specsToSubscribeKeys, specAccess } from "../../utils/spec";
6
+ import { reportSubLog } from "../../utils/subscribe-log";
6
7
  import { OK } from "./error-code";
7
8
  const specSubject = new Subject(getEventGenerator('std_spec_subject', encodeProp));
8
9
  let receivedMessagesSubscription = null;
10
+ function fillInfo(props) {
11
+ const propsNew = props.filter(element => {
12
+ return element !== null;
13
+ });
14
+ return propsNew.map(prop => {
15
+ const {
16
+ did,
17
+ miid,
18
+ ...rest
19
+ } = prop;
20
+ const t = {
21
+ ...rest,
22
+ did: did || Device.deviceID
23
+ };
24
+ if (miid) {
25
+ t.miid = miid;
26
+ }
27
+ return t;
28
+ });
29
+ }
9
30
  export function getProps(props, chunkFn) {
10
31
  // console.log('getProps---props', props);
11
32
  // 处理固件不上报的情况,此时须将dataSource 设置为2,强行从固件读取
@@ -16,7 +37,7 @@ export function getProps(props, chunkFn) {
16
37
  siid,
17
38
  piid,
18
39
  did = Device.deviceID,
19
- dataSource = prop.fromSource ? 1 : 2
40
+ dataSource = 1
20
41
  } = prop;
21
42
  const p = {
22
43
  siid,
@@ -183,32 +204,14 @@ function subscribeReceivedMessage() {
183
204
  });
184
205
  });
185
206
  }
186
- function fillInfo(props) {
187
- const propsNew = props.filter(element => {
188
- return element !== null;
189
- });
190
- return propsNew.map(prop => {
191
- const {
192
- did,
193
- miid,
194
- ...rest
195
- } = prop;
196
- const t = {
197
- ...rest,
198
- did: did || Device.deviceID
199
- };
200
- if (miid) {
201
- t.miid = miid;
202
- }
203
- return t;
204
- });
205
- }
206
207
  export function subscribe(props, listener) {
207
208
  if (!receivedMessagesSubscription) {
208
209
  receivedMessagesSubscription = subscribeReceivedMessage();
209
210
  }
210
211
  props = fillInfo(props);
211
212
  const keys = specsToSubscribeKeys(props);
213
+ const t0 = Date.now();
214
+ reportSubLog(`[SPEC-SUB][std-spec.subscribe][enter][${new Date().toISOString()}] 即将调用原生 listenMessages, keyCount=${keys.length}, keys=${JSON.stringify(keys)}`);
212
215
  // return Device.getDeviceWifi().subscribeMessages(...keys).then(() => {
213
216
  return Device.getDeviceWifi().listenMessages(...keys).then(() => {
214
217
  const subscription = specSubject.subscribe(props, listener);
@@ -218,11 +221,17 @@ export function subscribe(props, listener) {
218
221
  code: OK
219
222
  };
220
223
  });
224
+ reportSubLog(`[SPEC-SUB][std-spec.subscribe][success][${new Date().toISOString()}] 原生 listenMessages 成功, cost=${Date.now() - t0}ms, keyCount=${keys.length}`);
221
225
  return {
222
226
  props: subscribed,
223
227
  unsubscribe: () => {
228
+ reportSubLog(`[SPEC-SUB][std-spec.unsubscribe][${new Date().toISOString()}] 取消本地监听, keyCount=${keys.length}, keys=${JSON.stringify(keys)}`);
224
229
  subscription && subscription.unsubscribe && subscription.unsubscribe();
225
230
  }
226
231
  };
227
- }).catch(mapError(props));
232
+ }).catch(e => {
233
+ // 注意:这里沿用 mapError 的既有行为(返回值而非 rethrow,Promise 仍会 resolve),仅补充失败日志
234
+ reportSubLog(`[SPEC-SUB][std-spec.subscribe][fail][${new Date().toISOString()}] 原生 listenMessages 失败, cost=${Date.now() - t0}ms, keyCount=${keys.length}, code=${e?.code}, message=${e?.message}, raw=${JSON.stringify(e)}`);
235
+ return mapError(props)(e);
236
+ });
228
237
  }
@@ -0,0 +1,17 @@
1
+ declare let mockListenMessagesImpl: (...keys: string[]) => Promise<any>;
2
+ declare let mockStorageGetImpl: (key: string) => Promise<any>;
3
+ declare const mockListenMessagesCalls: string[][];
4
+ declare const mockReportLogCalls: Array<{
5
+ model: string;
6
+ line: string;
7
+ }>;
8
+ declare const SPECS: {
9
+ siid: number;
10
+ piid: number;
11
+ access: string[];
12
+ }[];
13
+ declare let logs: string[];
14
+ declare const collect: (...args: unknown[]) => void;
15
+ declare const specSubLogs: (stage: string) => string[];
16
+ declare const isDoubleWritten: (line: string) => boolean;
17
+ declare const expectAllSpecSubReported: () => void;
@@ -0,0 +1,217 @@
1
+ /// <reference types="jest" />
2
+
3
+ /**
4
+ * 复现 spec 订阅的两类静默失败,并验证 [SPEC-SUB] 日志能把它们打出来
5
+ *
6
+ * 1. listenMessages 失败:被 std-spec 的 mapError 转成 resolve,上层看到的是"假成功"
7
+ * 2. Host.storage.get 不 settle:publishFromCache 永不完成,listenMessages 根本不会被调用
8
+ * (对应线上 6 次进插件里 2 次原生零 manageSub 的现象)
9
+ *
10
+ * 注:变量必须带 mock 前缀,否则 jest.mock 的 factory 不允许引用。
11
+ */
12
+
13
+ /* 每个用例都要 resetModules 后拿到全新的模块实例(模块级 cacheEnabled / receivedMessagesSubscription
14
+ 都是单例状态),因此必须用惰性 require 而非顶层 import。 */
15
+ /* eslint-disable global-require, @typescript-eslint/no-var-requires */
16
+
17
+ let mockListenMessagesImpl = () => Promise.resolve();
18
+ let mockStorageGetImpl = () => Promise.resolve({});
19
+ const mockListenMessagesCalls = [];
20
+ /* 反馈日志上报的入参记录:console 只有连着调试工具才看得见,
21
+ 订阅链路必须同时落到 reportLog 才能被测试同学上传回来。 */
22
+ const mockReportLogCalls = [];
23
+ jest.mock('miot', () => ({
24
+ Device: {
25
+ deviceID: 'test-did',
26
+ model: 'micar.car.kunlun',
27
+ getDeviceWifi: () => ({
28
+ listenMessages: (...keys) => {
29
+ mockListenMessagesCalls.push(keys);
30
+ return mockListenMessagesImpl(...keys);
31
+ }
32
+ })
33
+ },
34
+ DeviceEvent: {
35
+ deviceReceivedMessages: {
36
+ addListener: () => ({
37
+ remove: () => {}
38
+ })
39
+ }
40
+ },
41
+ // specCache 在模块加载期就读 Service.account.ID 拼 STORAGE_KEY,必须提供
42
+ Service: {
43
+ account: {
44
+ ID: 'test-account'
45
+ },
46
+ smarthome: {
47
+ reportLog: (model, line) => {
48
+ mockReportLogCalls.push({
49
+ model,
50
+ line
51
+ });
52
+ }
53
+ }
54
+ },
55
+ Host: {
56
+ storage: {
57
+ get: key => mockStorageGetImpl(key),
58
+ set: () => Promise.resolve()
59
+ }
60
+ }
61
+ }), {
62
+ virtual: true
63
+ });
64
+ const SPECS = [{
65
+ siid: 6,
66
+ piid: 1,
67
+ access: ['read', 'notify']
68
+ }, {
69
+ siid: 6,
70
+ piid: 8,
71
+ access: ['read', 'notify']
72
+ }];
73
+ let logs = [];
74
+ const collect = (...args) => {
75
+ logs.push(args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '));
76
+ };
77
+ const specSubLogs = stage => logs.filter(l => l.includes('[SPEC-SUB]') && l.includes(stage));
78
+
79
+ /* reportSubLog 自身的降级提示是 console-only 的,不参与双写校验 */
80
+ const isDoubleWritten = line => line.includes('[SPEC-SUB]') && !line.includes('[reportSubLog]');
81
+
82
+ /**
83
+ * 断言订阅链路日志确实双写到了反馈日志。
84
+ *
85
+ * console 只有连着调试工具才看得见,测试同学复现后捞不到;只有 reportLog 才会落到
86
+ * 可上传的反馈日志里。这里不逐个枚举阶段名,而是校验"凡是打到 console 的
87
+ * [SPEC-SUB] 都必须同时出现在 reportLog 中"——将来新增日志点若漏接 reportSubLog,
88
+ * 会直接被这条断言拦住。
89
+ */
90
+ const expectAllSpecSubReported = () => {
91
+ const consoleLines = logs.filter(isDoubleWritten);
92
+ const reportedLines = mockReportLogCalls.map(c => c.line);
93
+ expect(consoleLines.length).toBeGreaterThan(0);
94
+ consoleLines.forEach(line => expect(reportedLines).toContain(line));
95
+ // 反馈日志里的 model 必须是设备 model,否则日志归不到设备上
96
+ mockReportLogCalls.forEach(c => expect(c.model).toBe('micar.car.kunlun'));
97
+ };
98
+ beforeEach(() => {
99
+ jest.resetModules();
100
+ logs = [];
101
+ mockListenMessagesCalls.length = 0;
102
+ mockReportLogCalls.length = 0;
103
+ mockListenMessagesImpl = () => Promise.resolve();
104
+ mockStorageGetImpl = () => Promise.resolve({});
105
+ jest.spyOn(console, 'log').mockImplementation(collect);
106
+ jest.spyOn(console, 'error').mockImplementation(collect);
107
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
108
+ });
109
+ afterEach(() => {
110
+ jest.restoreAllMocks();
111
+ jest.useRealTimers();
112
+ });
113
+ describe('订阅成功路径', () => {
114
+ it('打出 cache-init / listenMessages / success 全链路日志,取消订阅也有日志', async () => {
115
+ const {
116
+ subscribe
117
+ } = require("./index");
118
+ const result = await subscribe(SPECS, () => {}, {
119
+ publishImmediately: true
120
+ });
121
+
122
+ // 原生确实被调到,且两个 notify 属性都进了订阅 key
123
+ expect(mockListenMessagesCalls).toHaveLength(1);
124
+ expect(mockListenMessagesCalls[0]).toHaveLength(2);
125
+ expect(mockListenMessagesCalls[0].every(k => k.startsWith('prop.'))).toBe(true);
126
+ expect(specSubLogs('[cache-init-enter]')).toHaveLength(1);
127
+ expect(specSubLogs('[cache-init-done]')).toHaveLength(1);
128
+ expect(specSubLogs('[exec-subscribe-enter]')).toHaveLength(1);
129
+ expect(specSubLogs('[std-spec.subscribe][enter]')).toHaveLength(1);
130
+ expect(specSubLogs('[std-spec.subscribe][success]')).toHaveLength(1);
131
+ expect(specSubLogs('[specs.subscribe][success]')).toHaveLength(1);
132
+ // 成功路径不应出现任何 fail
133
+ expect(specSubLogs('[fail]')).toHaveLength(0);
134
+
135
+ // 订阅链路的日志必须同时进反馈日志,否则测试同学复现后捞不到
136
+ expectAllSpecSubReported();
137
+
138
+ // 取消订阅:三层各一条
139
+ logs = [];
140
+ result.unsubscribe();
141
+ expect(specSubLogs('[specs.unsubscribe]')).toHaveLength(1);
142
+ expect(specSubLogs('[spec.unsubscribe]')).toHaveLength(1);
143
+ expect(specSubLogs('[std-spec.unsubscribe]')).toHaveLength(1);
144
+
145
+ // 取消订阅这三条同样要能被上传回来
146
+ expectAllSpecSubReported();
147
+ });
148
+ });
149
+ describe('失败形态 1:listenMessages 失败被 mapError 吞掉', () => {
150
+ it('Promise 仍 resolve 成"假成功",但 fail 日志已能定位', async () => {
151
+ mockListenMessagesImpl = () => Promise.reject(Object.assign(new Error('listen timeout'), {
152
+ code: -70012
153
+ }));
154
+ const {
155
+ subscribe
156
+ } = require("./index");
157
+ const result = await subscribe(SPECS, () => {}, {
158
+ publishImmediately: true
159
+ });
160
+
161
+ // 复现核心:整条链路没有 reject,调用方拿到的是"成功"
162
+ expect(result).toBeDefined();
163
+ expect(result.props).toBeUndefined(); // 结构已损坏——真正的证据
164
+
165
+ // 新日志把失败点钉在 std-spec,并在 spec 层标出结构异常
166
+ const stdFail = specSubLogs('[std-spec.subscribe][fail]');
167
+ expect(stdFail).toHaveLength(1);
168
+ expect(stdFail[0]).toContain('code=-70012');
169
+ expect(stdFail[0]).toContain('listen timeout');
170
+ expect(specSubLogs('[spec.subscribe][fail]')).toHaveLength(1);
171
+
172
+ // index 层仍误报 success(既有行为,日志里 subscribedPropCount=undefined 可辨识)
173
+ const idxSuccess = specSubLogs('[specs.subscribe][success]');
174
+ expect(idxSuccess).toHaveLength(1);
175
+ expect(idxSuccess[0]).toContain('subscribedPropCount=undefined');
176
+
177
+ // 失败分支尤其要能上传回来——这是定位「偶现收不到推送」的唯一现场
178
+ expectAllSpecSubReported();
179
+ expect(mockReportLogCalls.map(c => c.line).join('\n')).toContain('[std-spec.subscribe][fail]');
180
+ });
181
+ });
182
+ describe('失败形态 2:Host.storage.get 不 settle', () => {
183
+ it('listenMessages 永不调用,STUCK 看门狗把静默卡死打出来', async () => {
184
+ jest.useFakeTimers();
185
+ mockStorageGetImpl = () => new Promise(() => {}); // 永不 settle
186
+
187
+ const {
188
+ subscribe
189
+ } = require("./index");
190
+ let settled = false;
191
+ subscribe(SPECS, () => {}, {
192
+ publishImmediately: true
193
+ }).then(() => {
194
+ settled = true;
195
+ }, () => {
196
+ settled = true;
197
+ });
198
+ await Promise.resolve();
199
+ jest.advanceTimersByTime(8000);
200
+
201
+ // 复现线上现象:原生零调用、Promise 不 settle、成功和失败分支都不执行
202
+ expect(mockListenMessagesCalls).toHaveLength(0);
203
+ expect(settled).toBe(false);
204
+ expect(specSubLogs('[specs.subscribe][success]')).toHaveLength(0);
205
+ expect(specSubLogs('[specs.subscribe][fail]')).toHaveLength(0);
206
+
207
+ // 唯一能说明问题的就是看门狗
208
+ const stuck = specSubLogs('[STUCK]');
209
+ expect(stuck).toHaveLength(1);
210
+ expect(stuck[0]).toContain('specCache.init/Host.storage.get');
211
+
212
+ // 卡在 cache-init,没有进到 exec-subscribe
213
+ expect(specSubLogs('[cache-init-enter]')).toHaveLength(1);
214
+ expect(specSubLogs('[cache-init-done]')).toHaveLength(0);
215
+ expect(specSubLogs('[exec-subscribe-enter]')).toHaveLength(0);
216
+ });
217
+ });
@@ -0,0 +1,3 @@
1
+ import React from 'react';
2
+ import { FlatListProps } from 'react-native';
3
+ export declare function TrackFlatList<ItemT>(props: FlatListProps<ItemT>): React.JSX.Element;
@@ -0,0 +1,19 @@
1
+ function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
2
+ import React from 'react';
3
+ import { FlatList } from 'react-native';
4
+ import { useTrackScrollProvider } from "./useTrackScrollProvider";
5
+
6
+ /**
7
+ * RN FlatList 的 ergonomic wrapper:内部建立 TrackScrollContext。
8
+ *
9
+ * 后续可改用官方 onViewableItemsChanged + viewabilityConfig,绕过 measureInWindow,
10
+ * 性能更好;本期先走统一路径降低复杂度。
11
+ */
12
+ export function TrackFlatList(props) {
13
+ const tracker = useTrackScrollProvider({
14
+ onScroll: props.onScroll,
15
+ onLayout: props.onLayout,
16
+ onMomentumScrollEnd: props.onMomentumScrollEnd
17
+ });
18
+ return /*#__PURE__*/React.createElement(tracker.Provider, null, /*#__PURE__*/React.createElement(FlatList, _extends({}, props, tracker.scrollProps)));
19
+ }
@@ -0,0 +1,10 @@
1
+ import { RefObject } from 'react';
2
+ export type ScrollListener = () => void;
3
+ export interface MeasurableNode {
4
+ measureInWindow: (cb: (x: number, y: number, width: number, height: number) => void) => void;
5
+ }
6
+ export interface TrackScrollContextValue {
7
+ containerRef: RefObject<MeasurableNode | null>;
8
+ subscribe: (cb: ScrollListener) => () => void;
9
+ }
10
+ export declare const TrackScrollContext: import("react").Context<TrackScrollContextValue | null>;
@@ -0,0 +1,2 @@
1
+ import { createContext } from 'react';
2
+ export const TrackScrollContext = /*#__PURE__*/createContext(null);
@@ -0,0 +1,3 @@
1
+ import React from 'react';
2
+ import { ScrollViewProps } from 'react-native';
3
+ export declare function TrackScrollView(props: ScrollViewProps): React.JSX.Element;
@@ -0,0 +1,20 @@
1
+ function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
2
+ import React from 'react';
3
+ import { ScrollView } from 'react-native';
4
+ import { useTrackScrollProvider } from "./useTrackScrollProvider";
5
+
6
+ /**
7
+ * 直接消费 RN ScrollView 的 ergonomic wrapper:内部建立 TrackScrollContext,
8
+ * 业务用法与原生 <ScrollView> 完全一致。
9
+ *
10
+ * 包装层 / SDK 容器(SubpageLayout / PageLayout)不要用本组件——
11
+ * 改用 useTrackScrollProvider 取 scrollProps,把 ref/onScroll 透传到容器暴露的接缝。
12
+ */
13
+ export function TrackScrollView(props) {
14
+ const tracker = useTrackScrollProvider({
15
+ onScroll: props.onScroll,
16
+ onLayout: props.onLayout,
17
+ onMomentumScrollEnd: props.onMomentumScrollEnd
18
+ });
19
+ return /*#__PURE__*/React.createElement(tracker.Provider, null, /*#__PURE__*/React.createElement(ScrollView, _extends({}, props, tracker.scrollProps)));
20
+ }
@@ -0,0 +1,10 @@
1
+ import type { TrackEventType, TrackParams, TrackServiceConfig } from './types';
2
+ export declare function initTrackService(config: TrackServiceConfig): void;
3
+ export declare function getCommonParams(): Record<string, unknown>;
4
+ export declare function setPageRef(ref: string, subRef?: string): void;
5
+ export declare function reportTrack(eventTypes: TrackEventType[], params?: TrackParams): void;
6
+ export declare function getTrackLog(): {
7
+ time: number;
8
+ event: string;
9
+ params: Record<string, unknown>;
10
+ }[];