@onekeyfe/react-native-native-list 6.7.0-alpha.405 → 6.7.0-alpha.406

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.
@@ -12,6 +12,7 @@ const MAX_HEADER_LENGTH = 4096;
12
12
  const MAX_SPACER_HEIGHT = 512;
13
13
  const MAX_SECTION_INDEX_TITLE_LENGTH = 8;
14
14
  const MAX_MARKET_BADGES = 3;
15
+ const MARKET_METRIC_FIELDS = ['key', 'text', 'textSegments', 'secondaryText', 'secondaryTextSegments', 'weight', 'alignment', 'tone', 'secondaryTone'];
15
16
  function isDevelopment() {
16
17
  return typeof __DEV__ !== 'undefined' && __DEV__;
17
18
  }
@@ -524,8 +525,56 @@ function assertWebMarketTable(table, path) {
524
525
  }
525
526
  });
526
527
  }
528
+ function assertMarketMetrics(metrics, path) {
529
+ if (metrics === undefined) return;
530
+ if (!Array.isArray(metrics) || ![2, 3].includes(metrics.length)) fail(path, 'must contain 2 or 3 columns');
531
+ const keys = new Set();
532
+ metrics.forEach((column, index) => {
533
+ const columnPath = `${path}[${index}]`;
534
+ const value = protocolObject(column, columnPath);
535
+ protocolKey(value.key, `${columnPath}.key`);
536
+ if (keys.has(column.key)) fail(`${columnPath}.key`, 'must be unique');
537
+ keys.add(column.key);
538
+ if (typeof value.text !== 'string') fail(`${columnPath}.text`, 'must be a string');
539
+ for (const field of ['textSegments', 'secondaryTextSegments']) {
540
+ const segments = column[field];
541
+ if (segments !== undefined && !Array.isArray(segments)) fail(`${columnPath}.${field}`, 'must be an array');
542
+ segments?.forEach((segment, segmentIndex) => {
543
+ const segmentPath = `${columnPath}.${field}[${segmentIndex}]`;
544
+ protocolObject(segment, segmentPath);
545
+ if (typeof segment.text !== 'string') fail(`${segmentPath}.text`, 'must be a string');
546
+ if (segment.style !== undefined && segment.style !== 'subscript') fail(`${segmentPath}.style`, 'must be subscript');
547
+ });
548
+ }
549
+ if (value.weight !== undefined && ![1, 2, 3].includes(value.weight)) fail(`${columnPath}.weight`, 'must be 1, 2, or 3');
550
+ if (value.alignment !== undefined && !['start', 'center', 'end'].includes(value.alignment)) fail(`${columnPath}.alignment`, 'must be start, center, or end');
551
+ if (value.secondaryText !== undefined && typeof value.secondaryText !== 'string') fail(`${columnPath}.secondaryText`, 'must be a string');
552
+ assertTextTone(value.tone, `${columnPath}.tone`);
553
+ assertTextTone(value.secondaryTone, `${columnPath}.secondaryTone`);
554
+ });
555
+ }
527
556
  function assertMarketRow(row, path) {
528
557
  assertWebMarketTable(row.webTable, `${path}.webTable`);
558
+ assertMarketMetrics(row.metrics, `${path}.metrics`);
559
+ if (row.metrics !== undefined) {
560
+ row.metrics.forEach((column, index) => {
561
+ const columnPath = `${path}.metrics[${index}]`;
562
+ const value = protocolObject(column, columnPath);
563
+ assertUnknownKeys(value, MARKET_METRIC_FIELDS, columnPath, 'Market metric key');
564
+ assertKey(column.key, `${columnPath}.key`);
565
+ assertText(column.text, `${columnPath}.text`);
566
+ assertText(column.secondaryText, `${columnPath}.secondaryText`);
567
+ assertTextTone(column.tone, `${columnPath}.tone`);
568
+ assertTextTone(column.secondaryTone, `${columnPath}.secondaryTone`);
569
+ for (const field of ['textSegments', 'secondaryTextSegments']) {
570
+ column[field]?.forEach((segment, segmentIndex) => {
571
+ const segmentPath = `${columnPath}.${field}[${segmentIndex}]`;
572
+ assertText(segment.text, `${segmentPath}.text`);
573
+ if (segment.style !== undefined && segment.style !== 'subscript') fail(`${segmentPath}.style`, 'must be subscript');
574
+ });
575
+ }
576
+ });
577
+ }
529
578
  if (!['token', 'stock', 'perp'].includes(row.variant)) {
530
579
  fail(`${path}.variant`, 'must be token, stock, or perp');
531
580
  }
@@ -1183,7 +1232,7 @@ const ROW_FIELDS = {
1183
1232
  activity: [...COMMON_ROW_FIELDS, 'leading', 'hideLeading', 'secondaryLeading', 'title', 'description', 'status', 'primaryAmount', 'secondaryAmount', 'amounts', 'badges', 'presentation', 'fee', 'descriptionActionKey', 'footerActions'],
1184
1233
  message: [...COMMON_ROW_FIELDS, 'presentation', 'unread', 'leading', 'title', 'body', 'bodyLines', 'time', 'thumbnail'],
1185
1234
  dataRow: [...COMMON_ROW_FIELDS, 'leading', 'columns', 'trailing', 'checkbox', 'index', 'badge', 'badges', 'favorite', 'favoriteActive'],
1186
- market: [...COMMON_ROW_FIELDS, 'variant', 'webTable', 'leadingAction', 'leading', 'title', 'subtitle', 'subtitlePrefix', 'subtitleSegments', 'price', 'priceSegments', 'change', 'badges', 'pressActionKey', 'pressInActionKey', 'longPressActionKey', 'diagnostics'],
1235
+ market: [...COMMON_ROW_FIELDS, 'variant', 'webTable', 'metrics', 'leadingAction', 'leading', 'title', 'subtitle', 'subtitlePrefix', 'subtitleSegments', 'price', 'priceSegments', 'change', 'badges', 'pressActionKey', 'pressInActionKey', 'longPressActionKey', 'diagnostics'],
1187
1236
  mediaTile: [...COMMON_ROW_FIELDS, 'variant', 'media', 'image', 'imageState', 'networkImage', 'title', 'subtitle', 'badge', 'closeActionKey'],
1188
1237
  metricCard: [...COMMON_ROW_FIELDS, 'variant', 'title', 'value', 'subtitle', 'trend', 'trendTone', 'visual', 'badge', 'metrics', 'progress'],
1189
1238
  sectionHeader: [...COMMON_ROW_FIELDS, 'sticky', 'valueActionTestID', 'valueSegments', 'titleActionKey', 'titleActionOnHover', 'presentation', 'indexTitle', 'variant', 'title', 'leadingBadge', 'titleLoading', 'subtitle', 'value', 'valueHelp', 'valueActionKey', 'titleIcon', 'valueIcon', 'checkbox'],
@@ -1198,7 +1247,7 @@ const PATCH_FIELDS = {
1198
1247
  activity: [...COMMON_PATCH_FIELDS, 'presentation', 'leading', 'title', 'badges', 'status', 'hideLeading', 'secondaryLeading', 'description', 'primaryAmount', 'secondaryAmount', 'amounts', 'fee', 'descriptionActionKey', 'footerActions'],
1199
1248
  message: [...COMMON_PATCH_FIELDS, 'presentation', 'title', 'unread', 'body', 'bodyLines', 'time', 'thumbnail'],
1200
1249
  dataRow: [...COMMON_PATCH_FIELDS, 'leading', 'badges', 'trailing', 'badge', 'columns', 'checkbox', 'index', 'favorite', 'favoriteActive'],
1201
- market: [...COMMON_PATCH_FIELDS, 'longPressActionKey', 'subtitleSegments', 'leading', 'leadingAction', 'title', 'subtitle', 'badges', 'webTable', 'price', 'priceSegments', 'change', 'pressActionKey', 'pressInActionKey', 'diagnostics'],
1250
+ market: [...COMMON_PATCH_FIELDS, 'longPressActionKey', 'subtitleSegments', 'leading', 'leadingAction', 'title', 'subtitle', 'badges', 'webTable', 'metrics', 'price', 'priceSegments', 'change', 'pressActionKey', 'pressInActionKey', 'diagnostics'],
1202
1251
  mediaTile: [...COMMON_PATCH_FIELDS, 'title', 'subtitle', 'badge', 'media', 'image', 'imageState', 'networkImage', 'closeActionKey'],
1203
1252
  metricCard: [...COMMON_PATCH_FIELDS, 'title', 'subtitle', 'visual', 'badge', 'variant', 'value', 'trend', 'trendTone', 'metrics', 'progress'],
1204
1253
  sectionHeader: [...COMMON_PATCH_FIELDS, 'titleActionKey', 'titleActionOnHover', 'title', 'titleIcon', 'subtitle', 'checkbox', 'variant', 'value', 'leadingBadge', 'titleLoading', 'valueHelp', 'valueActionKey', 'valueIcon'],
@@ -1326,6 +1375,7 @@ function assertRowProtocol(row, path) {
1326
1375
  }
1327
1376
  if (row.type === 'market') {
1328
1377
  protocolObject(row.change, `${path}.change`);
1378
+ assertMarketMetrics(row.metrics, `${path}.metrics`);
1329
1379
  if (row.webTable) {
1330
1380
  const {
1331
1381
  columns,
@@ -1633,6 +1683,16 @@ function sanitizeDecoration(value, field) {
1633
1683
  }
1634
1684
  return next;
1635
1685
  }
1686
+ function sanitizeMarketMetrics(value) {
1687
+ if (!Array.isArray(value)) return value;
1688
+ return value.map(metric => {
1689
+ const next = pickFields(metric, MARKET_METRIC_FIELDS);
1690
+ for (const field of ['textSegments', 'secondaryTextSegments']) {
1691
+ if (next[field] !== undefined) next[field] = sanitizeDecoration(next[field], field);
1692
+ }
1693
+ return next;
1694
+ });
1695
+ }
1636
1696
  function sanitizeRowForProduction(row) {
1637
1697
  const object = protocolObject(row, 'row');
1638
1698
  const fields = ROW_FIELDS[row.type];
@@ -1644,7 +1704,7 @@ function sanitizeRowForProduction(row) {
1644
1704
  continue;
1645
1705
  }
1646
1706
  if (field === 'parent' && isRecord(value)) next[field] = sanitizeRowForProduction(value);else if (field === 'children' && Array.isArray(value)) next[field] = value.map(child => sanitizeRowForProduction(child));else {
1647
- const normalized = sanitizeDecoration(value, field);
1707
+ const normalized = row.type === 'market' && field === 'metrics' ? sanitizeMarketMetrics(value) : sanitizeDecoration(value, field);
1648
1708
  if (normalized === undefined) delete next[field];else next[field] = normalized;
1649
1709
  }
1650
1710
  }
@@ -1665,7 +1725,7 @@ function normalizeEmptyImages(value, field = '') {
1665
1725
  const next = {};
1666
1726
  for (const [key, item] of Object.entries(object)) {
1667
1727
  const optional = object.type && !['key', 'type', ...(REQUIRED_ROW_FIELDS[object.type] ?? [])].includes(key);
1668
- if (item === undefined || item === null && optional || ['priceSegments', 'subtitleSegments'].includes(key) && Array.isArray(item) && item.length === 0) {
1728
+ if (item === undefined || item === null && optional && !(object.type === 'market' && key === 'metrics') || ['priceSegments', 'subtitleSegments'].includes(key) && Array.isArray(item) && item.length === 0) {
1669
1729
  changed = true;
1670
1730
  continue;
1671
1731
  }
@@ -1718,6 +1778,11 @@ export function validateSnapshot(snapshot) {
1718
1778
  assertPlainSerializable(snapshot, 'snapshot', new Set(), false);
1719
1779
  protocolObject(snapshot, 'snapshot');
1720
1780
  const normalized = normalizeEmptyImages(snapshot);
1781
+ if (Array.isArray(normalized.rows)) {
1782
+ normalized.rows.forEach((row, index) => {
1783
+ if (row.type === 'market') assertMarketMetrics(row.metrics, `snapshot.rows[${index}].metrics`);
1784
+ });
1785
+ }
1721
1786
  const canonical = isDevelopment() ? normalized : normalizeSnapshotForProduction(normalized);
1722
1787
  assertPlainSerializable(canonical, 'snapshot', new Set());
1723
1788
  if (isDevelopment()) assertSnapshotContract(canonical);
@@ -2009,6 +2074,7 @@ function normalizePatch(patch, index) {
2009
2074
  continue;
2010
2075
  }
2011
2076
  if (value === null) {
2077
+ if (patch.type === 'market' && field === 'metrics') fail(`${path}.changes.metrics`, 'use a full snapshot to remove metrics');
2012
2078
  if (REQUIRED_ROW_FIELDS[patch.type].includes(field) || patch.type === 'system' && field === 'message') {
2013
2079
  // The concrete system variant is checked after merging with its baseline.
2014
2080
  if (patch.type !== 'system') fail(`${path}.changes.${field}`, 'required field cannot be cleared');
@@ -2016,6 +2082,7 @@ function normalizePatch(patch, index) {
2016
2082
  changes[field] = null;
2017
2083
  continue;
2018
2084
  }
2085
+ if (patch.type === 'market' && field === 'metrics') assertMarketMetrics(value, `${path}.changes.metrics`);
2019
2086
  if (['priceSegments', 'subtitleSegments'].includes(field) && Array.isArray(value) && value.length === 0) {
2020
2087
  changes[field] = null;
2021
2088
  continue;
@@ -2025,7 +2092,7 @@ function normalizePatch(patch, index) {
2025
2092
  if (!REQUIRED_ROW_FIELDS[patch.type].includes(field)) changes[field] = null;
2026
2093
  continue;
2027
2094
  }
2028
- const canonical = isDevelopment() ? normalized : sanitizeDecoration(normalized, field);
2095
+ const canonical = isDevelopment() ? normalized : patch.type === 'market' && field === 'metrics' ? sanitizeMarketMetrics(normalized) : sanitizeDecoration(normalized, field);
2029
2096
  if (canonical !== undefined) changes[field] = canonical;else if (field === 'titleImage') changes[field] = null;
2030
2097
  }
2031
2098
  if (patch.type === 'market' && Array.isArray(changes.priceSegments) && changes.priceSegments.length > 0 && (typeof changes.price !== 'string' || !changes.price.trim())) fail(`${path}.changes.price`, 'is required when priceSegments is updated');
@@ -2040,6 +2107,7 @@ function normalizePatch(patch, index) {
2040
2107
  }
2041
2108
  }
2042
2109
  const values = Object.fromEntries(Object.entries(changes).filter(([, value]) => value !== null));
2110
+ if (patch.type === 'market' && 'metrics' in changes) assertMarketMetrics(changes.metrics, `${path}.changes.metrics`);
2043
2111
  assertPlainSerializable(values, `${path}.changes`, new Set());
2044
2112
  assertProtocolFields(values, `${path}.changes`);
2045
2113
  if (isDevelopment()) assertPatchChanges({
@@ -602,6 +602,8 @@ export type MarketRow = RowBase & Readonly<{
602
602
  variant: 'token' | 'stock' | 'perp';
603
603
  /** Web-only bounded table presentation; native reads the ordinary Market fields. */
604
604
  webTable?: WebMarketTablePresentation;
605
+ /** Two or three ordered native text metrics; omitting them keeps the compact quote. */
606
+ metrics?: readonly Pick<DataColumn, 'key' | 'text' | 'textSegments' | 'secondaryText' | 'secondaryTextSegments' | 'weight' | 'alignment' | 'tone' | 'secondaryTone'>[];
605
607
  leadingAction?: Extract<TrailingAccessory, {
606
608
  kind: 'icon';
607
609
  }>;
@@ -898,7 +900,7 @@ export type RowPatch = Readonly<{
898
900
  }> | Readonly<{
899
901
  type: 'market';
900
902
  key: string;
901
- changes: PatchClearableChanges<MarketRow, CommonPatchFields | 'leadingAction' | 'leading' | 'title' | 'subtitle' | 'subtitleSegments' | 'price' | 'priceSegments' | 'change' | 'badges' | 'pressActionKey' | 'pressInActionKey' | 'longPressActionKey' | 'diagnostics' | 'webTable' | 'style'>;
903
+ changes: PatchClearableChanges<MarketRow, CommonPatchFields | 'leadingAction' | 'leading' | 'title' | 'subtitle' | 'subtitleSegments' | 'price' | 'priceSegments' | 'change' | 'badges' | 'pressActionKey' | 'pressInActionKey' | 'longPressActionKey' | 'diagnostics' | 'webTable' | 'metrics' | 'style'>;
902
904
  }> | Readonly<{
903
905
  type: 'mediaTile';
904
906
  key: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-native-list",
3
- "version": "6.7.0-alpha.405",
3
+ "version": "6.7.0-alpha.406",
4
4
  "description": "Template-driven native RecyclerView and UICollectionView for React Native",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./lib/module/index.js",
@@ -86,8 +86,8 @@
86
86
  "typescript": "^5.9.2"
87
87
  },
88
88
  "peerDependencies": {
89
- "@onekeyfe/react-native-image": "6.7.0-alpha.405",
90
- "@onekeyfe/react-native-native-logger": "6.7.0-alpha.405",
89
+ "@onekeyfe/react-native-image": "6.7.0-alpha.406",
90
+ "@onekeyfe/react-native-native-logger": "6.7.0-alpha.406",
91
91
  "react": "*",
92
92
  "react-native": "*",
93
93
  "react-native-nitro-modules": "0.37.0"
@@ -149,6 +149,7 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
149
149
  forwardedRef
150
150
  ) {
151
151
  const snapshotRef = useRef<NativeListSnapshot>(emptyNativeListSnapshot());
152
+ const needsNativeSnapshotRef = useRef(false);
152
153
  const renderedSnapshotRef = useRef<NativeListSnapshot | undefined>(
153
154
  undefined
154
155
  );
@@ -588,10 +589,11 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
588
589
  return;
589
590
  }
590
591
  pendingDisclosureSnapshotRef.current = undefined;
591
- const nextSnapshotJson = JSON.stringify(accepted);
592
- nativeRef.current?.applySnapshot(nextSnapshotJson);
592
+ const native = nativeRef.current;
593
+ if (native) native.applySnapshot(JSON.stringify(accepted));
593
594
  snapshotRef.current = accepted;
594
- if (nativeRef.current && avatarLifecycle.current !== 'unmounted') {
595
+ needsNativeSnapshotRef.current = !native;
596
+ if (native && avatarLifecycle.current !== 'unmounted') {
595
597
  avatarModelRef.current?.replaceSnapshot(accepted.rows);
596
598
  updateAvatarPrefetchRef.current();
597
599
  }
@@ -614,12 +616,15 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
614
616
  return;
615
617
  }
616
618
  const native = nativeRef.current;
617
- const dispatch = native
618
- ? () => {
619
- native.applyPatches(accepted.json);
620
- snapshotRef.current = nextSnapshot;
621
- }
622
- : undefined;
619
+ if (!native) {
620
+ snapshotRef.current = nextSnapshot;
621
+ needsNativeSnapshotRef.current = true;
622
+ return;
623
+ }
624
+ const dispatch = () => {
625
+ native.applyPatches(accepted.json);
626
+ snapshotRef.current = nextSnapshot;
627
+ };
623
628
  if (avatarLifecycle.current === 'unmounted') {
624
629
  dispatch?.();
625
630
  return;
@@ -646,7 +651,10 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
646
651
  };
647
652
  if (pending)
648
653
  pendingDisclosureSnapshotRef.current = { ...pending, owned: next };
649
- else snapshotRef.current = next;
654
+ else {
655
+ snapshotRef.current = next;
656
+ if (!nativeRef.current) needsNativeSnapshotRef.current = true;
657
+ }
650
658
  }
651
659
  },
652
660
  scrollToKey(paramsOrKey, animated, alignment) {
@@ -712,7 +720,19 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
712
720
  const nativeCallbacks = useMemo(
713
721
  () => ({
714
722
  hybridRef: callback((ref: NativeListMethods) => {
723
+ const previous = nativeRef.current;
715
724
  nativeRef.current = ref;
725
+ if (
726
+ needsNativeSnapshotRef.current ||
727
+ (previous && previous !== ref)
728
+ ) {
729
+ // The parent can publish values before Fabric delivers the host.
730
+ const latest = snapshotRef.current;
731
+ ref.applySnapshot(JSON.stringify(latest));
732
+ needsNativeSnapshotRef.current = false;
733
+ avatarModelRef.current?.replaceSnapshot(latest.rows);
734
+ updateAvatarPrefetchRef.current();
735
+ }
716
736
  applyInitialScrollCallbackRef.current();
717
737
  }),
718
738
  onRowAction: callback((payloadJson: string) => {
package/src/models.ts CHANGED
@@ -755,6 +755,19 @@ export type MarketRow = RowBase &
755
755
  variant: 'token' | 'stock' | 'perp';
756
756
  /** Web-only bounded table presentation; native reads the ordinary Market fields. */
757
757
  webTable?: WebMarketTablePresentation;
758
+ /** Two or three ordered native text metrics; omitting them keeps the compact quote. */
759
+ metrics?: readonly Pick<
760
+ DataColumn,
761
+ | 'key'
762
+ | 'text'
763
+ | 'textSegments'
764
+ | 'secondaryText'
765
+ | 'secondaryTextSegments'
766
+ | 'weight'
767
+ | 'alignment'
768
+ | 'tone'
769
+ | 'secondaryTone'
770
+ >[];
758
771
  leadingAction?: Extract<TrailingAccessory, { kind: 'icon' }>;
759
772
  leading: LeadingVisual;
760
773
  title: string;
@@ -1178,6 +1191,7 @@ export type RowPatch =
1178
1191
  | 'longPressActionKey'
1179
1192
  | 'diagnostics'
1180
1193
  | 'webTable'
1194
+ | 'metrics'
1181
1195
  | 'style'
1182
1196
  >;
1183
1197
  }>
package/src/validation.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  WebMarketTableCell,
20
20
  WebMarketTablePresentation,
21
21
  WebMarketTableText,
22
+ ValueTextSegment,
22
23
  } from './models';
23
24
 
24
25
  const MAX_KEY_LENGTH = 256;
@@ -32,6 +33,17 @@ const MAX_HEADER_LENGTH = 4096;
32
33
  const MAX_SPACER_HEIGHT = 512;
33
34
  const MAX_SECTION_INDEX_TITLE_LENGTH = 8;
34
35
  const MAX_MARKET_BADGES = 3;
36
+ const MARKET_METRIC_FIELDS = [
37
+ 'key',
38
+ 'text',
39
+ 'textSegments',
40
+ 'secondaryText',
41
+ 'secondaryTextSegments',
42
+ 'weight',
43
+ 'alignment',
44
+ 'tone',
45
+ 'secondaryTone',
46
+ ] as const;
35
47
 
36
48
  function isDevelopment(): boolean {
37
49
  return typeof __DEV__ !== 'undefined' && __DEV__;
@@ -982,8 +994,88 @@ function assertWebMarketTable(
982
994
  });
983
995
  }
984
996
 
997
+ function assertMarketMetrics(
998
+ metrics: MarketRow['metrics'],
999
+ path: string
1000
+ ): void {
1001
+ if (metrics === undefined) return;
1002
+ if (!Array.isArray(metrics) || ![2, 3].includes(metrics.length))
1003
+ fail(path, 'must contain 2 or 3 columns');
1004
+ const keys = new Set<string>();
1005
+ metrics.forEach(
1006
+ (column: NonNullable<MarketRow['metrics']>[number], index: number) => {
1007
+ const columnPath = `${path}[${index}]`;
1008
+ const value = protocolObject(column, columnPath);
1009
+ protocolKey(value.key, `${columnPath}.key`);
1010
+ if (keys.has(column.key)) fail(`${columnPath}.key`, 'must be unique');
1011
+ keys.add(column.key);
1012
+ if (typeof value.text !== 'string')
1013
+ fail(`${columnPath}.text`, 'must be a string');
1014
+ for (const field of ['textSegments', 'secondaryTextSegments'] as const) {
1015
+ const segments = column[field];
1016
+ if (segments !== undefined && !Array.isArray(segments))
1017
+ fail(`${columnPath}.${field}`, 'must be an array');
1018
+ segments?.forEach((segment: ValueTextSegment, segmentIndex: number) => {
1019
+ const segmentPath = `${columnPath}.${field}[${segmentIndex}]`;
1020
+ protocolObject(segment, segmentPath);
1021
+ if (typeof segment.text !== 'string')
1022
+ fail(`${segmentPath}.text`, 'must be a string');
1023
+ if (segment.style !== undefined && segment.style !== 'subscript')
1024
+ fail(`${segmentPath}.style`, 'must be subscript');
1025
+ });
1026
+ }
1027
+ if (
1028
+ value.weight !== undefined &&
1029
+ ![1, 2, 3].includes(value.weight as number)
1030
+ )
1031
+ fail(`${columnPath}.weight`, 'must be 1, 2, or 3');
1032
+ if (
1033
+ value.alignment !== undefined &&
1034
+ !['start', 'center', 'end'].includes(value.alignment as string)
1035
+ )
1036
+ fail(`${columnPath}.alignment`, 'must be start, center, or end');
1037
+ if (
1038
+ value.secondaryText !== undefined &&
1039
+ typeof value.secondaryText !== 'string'
1040
+ )
1041
+ fail(`${columnPath}.secondaryText`, 'must be a string');
1042
+ assertTextTone(value.tone as string | undefined, `${columnPath}.tone`);
1043
+ assertTextTone(
1044
+ value.secondaryTone as string | undefined,
1045
+ `${columnPath}.secondaryTone`
1046
+ );
1047
+ }
1048
+ );
1049
+ }
1050
+
985
1051
  function assertMarketRow(row: MarketRow, path: string): void {
986
1052
  assertWebMarketTable(row.webTable, `${path}.webTable`);
1053
+ assertMarketMetrics(row.metrics, `${path}.metrics`);
1054
+ if (row.metrics !== undefined) {
1055
+ row.metrics.forEach((column, index) => {
1056
+ const columnPath = `${path}.metrics[${index}]`;
1057
+ const value = protocolObject(column, columnPath);
1058
+ assertUnknownKeys(
1059
+ value,
1060
+ MARKET_METRIC_FIELDS,
1061
+ columnPath,
1062
+ 'Market metric key'
1063
+ );
1064
+ assertKey(column.key, `${columnPath}.key`);
1065
+ assertText(column.text, `${columnPath}.text`);
1066
+ assertText(column.secondaryText, `${columnPath}.secondaryText`);
1067
+ assertTextTone(column.tone, `${columnPath}.tone`);
1068
+ assertTextTone(column.secondaryTone, `${columnPath}.secondaryTone`);
1069
+ for (const field of ['textSegments', 'secondaryTextSegments'] as const) {
1070
+ column[field]?.forEach((segment, segmentIndex) => {
1071
+ const segmentPath = `${columnPath}.${field}[${segmentIndex}]`;
1072
+ assertText(segment.text, `${segmentPath}.text`);
1073
+ if (segment.style !== undefined && segment.style !== 'subscript')
1074
+ fail(`${segmentPath}.style`, 'must be subscript');
1075
+ });
1076
+ }
1077
+ });
1078
+ }
987
1079
  if (!['token', 'stock', 'perp'].includes(row.variant)) {
988
1080
  fail(`${path}.variant`, 'must be token, stock, or perp');
989
1081
  }
@@ -2386,6 +2478,7 @@ const ROW_FIELDS: Readonly<Record<string, readonly string[]>> = {
2386
2478
  ...COMMON_ROW_FIELDS,
2387
2479
  'variant',
2388
2480
  'webTable',
2481
+ 'metrics',
2389
2482
  'leadingAction',
2390
2483
  'leading',
2391
2484
  'title',
@@ -2557,6 +2650,7 @@ const PATCH_FIELDS: Readonly<Record<string, readonly string[]>> = {
2557
2650
  'subtitle',
2558
2651
  'badges',
2559
2652
  'webTable',
2653
+ 'metrics',
2560
2654
  'price',
2561
2655
  'priceSegments',
2562
2656
  'change',
@@ -2942,6 +3036,7 @@ function assertRowProtocol(row: RowModel, path: string): void {
2942
3036
  }
2943
3037
  if (row.type === 'market') {
2944
3038
  protocolObject(row.change, `${path}.change`);
3039
+ assertMarketMetrics(row.metrics, `${path}.metrics`);
2945
3040
  if (row.webTable) {
2946
3041
  const { columns, cells } = row.webTable;
2947
3042
  if (
@@ -3805,6 +3900,21 @@ function sanitizeDecoration(value: unknown, field: string): unknown {
3805
3900
  return next;
3806
3901
  }
3807
3902
 
3903
+ function sanitizeMarketMetrics(value: unknown): unknown {
3904
+ if (!Array.isArray(value)) return value;
3905
+ return value.map((metric) => {
3906
+ const next = pickFields(
3907
+ metric as Record<string, unknown>,
3908
+ MARKET_METRIC_FIELDS
3909
+ );
3910
+ for (const field of ['textSegments', 'secondaryTextSegments'] as const) {
3911
+ if (next[field] !== undefined)
3912
+ next[field] = sanitizeDecoration(next[field], field);
3913
+ }
3914
+ return next;
3915
+ });
3916
+ }
3917
+
3808
3918
  function sanitizeRowForProduction(row: RowModel): RowModel {
3809
3919
  const object = protocolObject(row, 'row');
3810
3920
  const fields = ROW_FIELDS[row.type];
@@ -3826,7 +3936,10 @@ function sanitizeRowForProduction(row: RowModel): RowModel {
3826
3936
  sanitizeRowForProduction(child as RowModel)
3827
3937
  );
3828
3938
  else {
3829
- const normalized = sanitizeDecoration(value, field);
3939
+ const normalized =
3940
+ row.type === 'market' && field === 'metrics'
3941
+ ? sanitizeMarketMetrics(value)
3942
+ : sanitizeDecoration(value, field);
3830
3943
  if (normalized === undefined) delete next[field];
3831
3944
  else next[field] = normalized;
3832
3945
  }
@@ -3871,7 +3984,9 @@ function normalizeEmptyImages(value: unknown, field = ''): unknown {
3871
3984
  ].includes(key);
3872
3985
  if (
3873
3986
  item === undefined ||
3874
- (item === null && optional) ||
3987
+ (item === null &&
3988
+ optional &&
3989
+ !(object.type === 'market' && key === 'metrics')) ||
3875
3990
  (['priceSegments', 'subtitleSegments'].includes(key) &&
3876
3991
  Array.isArray(item) &&
3877
3992
  item.length === 0)
@@ -3993,6 +4108,12 @@ export function validateSnapshot(
3993
4108
  assertPlainSerializable(snapshot, 'snapshot', new Set<object>(), false);
3994
4109
  protocolObject(snapshot, 'snapshot');
3995
4110
  const normalized = normalizeEmptyImages(snapshot) as NativeListSnapshot;
4111
+ if (Array.isArray(normalized.rows)) {
4112
+ normalized.rows.forEach((row, index) => {
4113
+ if (row.type === 'market')
4114
+ assertMarketMetrics(row.metrics, `snapshot.rows[${index}].metrics`);
4115
+ });
4116
+ }
3996
4117
  const canonical = isDevelopment()
3997
4118
  ? normalized
3998
4119
  : normalizeSnapshotForProduction(normalized);
@@ -4423,6 +4544,11 @@ function normalizePatch(patch: RowPatch, index: number): RowPatch | undefined {
4423
4544
  continue;
4424
4545
  }
4425
4546
  if (value === null) {
4547
+ if (patch.type === 'market' && field === 'metrics')
4548
+ fail(
4549
+ `${path}.changes.metrics`,
4550
+ 'use a full snapshot to remove metrics'
4551
+ );
4426
4552
  if (
4427
4553
  REQUIRED_ROW_FIELDS[patch.type].includes(field) ||
4428
4554
  (patch.type === 'system' && field === 'message')
@@ -4434,6 +4560,11 @@ function normalizePatch(patch: RowPatch, index: number): RowPatch | undefined {
4434
4560
  changes[field] = null;
4435
4561
  continue;
4436
4562
  }
4563
+ if (patch.type === 'market' && field === 'metrics')
4564
+ assertMarketMetrics(
4565
+ value as MarketRow['metrics'],
4566
+ `${path}.changes.metrics`
4567
+ );
4437
4568
  if (
4438
4569
  ['priceSegments', 'subtitleSegments'].includes(field) &&
4439
4570
  Array.isArray(value) &&
@@ -4450,6 +4581,8 @@ function normalizePatch(patch: RowPatch, index: number): RowPatch | undefined {
4450
4581
  }
4451
4582
  const canonical = isDevelopment()
4452
4583
  ? normalized
4584
+ : patch.type === 'market' && field === 'metrics'
4585
+ ? sanitizeMarketMetrics(normalized)
4453
4586
  : sanitizeDecoration(normalized, field);
4454
4587
  if (canonical !== undefined) changes[field] = canonical;
4455
4588
  else if (field === 'titleImage') changes[field] = null;
@@ -4475,6 +4608,11 @@ function normalizePatch(patch: RowPatch, index: number): RowPatch | undefined {
4475
4608
  const values = Object.fromEntries(
4476
4609
  Object.entries(changes).filter(([, value]) => value !== null)
4477
4610
  );
4611
+ if (patch.type === 'market' && 'metrics' in changes)
4612
+ assertMarketMetrics(
4613
+ changes.metrics as MarketRow['metrics'],
4614
+ `${path}.changes.metrics`
4615
+ );
4478
4616
  assertPlainSerializable(values, `${path}.changes`, new Set<object>());
4479
4617
  assertProtocolFields(values, `${path}.changes`);
4480
4618
  if (isDevelopment())