@onekeyfe/react-native-native-list 6.7.0-alpha.398 → 6.7.0-alpha.399

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.
@@ -47,12 +47,12 @@ export function emptyNativeListSnapshot() {
47
47
  rows: []
48
48
  };
49
49
  }
50
- function assertPlainSerializable(value, path, seen) {
50
+ function assertPlainSerializable(value, path, seen, requireFinite = true) {
51
51
  if (value === null || value === undefined) return;
52
52
  const valueType = typeof value;
53
53
  if (valueType === 'string' || valueType === 'boolean') return;
54
54
  if (valueType === 'number') {
55
- if (!Number.isFinite(value)) fail(path, 'numbers must be finite');
55
+ if (requireFinite && !Number.isFinite(value)) fail(path, 'numbers must be finite');
56
56
  return;
57
57
  }
58
58
  if (valueType !== 'object') fail(path, 'must contain only plain serializable data');
@@ -60,13 +60,16 @@ function assertPlainSerializable(value, path, seen) {
60
60
  if (seen.has(objectValue)) fail(path, 'must not contain circular references');
61
61
  seen.add(objectValue);
62
62
  if (Array.isArray(value)) {
63
- value.forEach((item, index) => assertPlainSerializable(item, `${path}[${index}]`, seen));
63
+ value.forEach((item, index) => assertPlainSerializable(item, `${path}[${index}]`, seen, requireFinite));
64
64
  } else {
65
65
  const prototype = Object.getPrototypeOf(value);
66
66
  if (prototype !== Object.prototype && prototype !== null) {
67
67
  fail(path, 'must contain plain objects only');
68
68
  }
69
- Object.entries(value).forEach(([key, item]) => assertPlainSerializable(item, `${path}.${key}`, seen));
69
+ Object.entries(value).forEach(([key, item]) => {
70
+ if (key === '__proto__' || key === 'constructor' && typeof item === 'object') fail(path, 'unsafe object key');
71
+ assertPlainSerializable(item, `${path}.${key}`, seen, requireFinite);
72
+ });
70
73
  }
71
74
  seen.delete(objectValue);
72
75
  }
@@ -662,12 +665,28 @@ function assertIdentityExtras(row, path) {
662
665
  if (titleIcon.accessibilityLabel !== undefined && typeof titleIcon.accessibilityLabel !== 'string') fail(`${iconPath}.accessibilityLabel`, 'must be a string');
663
666
  if (titleIcon.hoverActionKey !== undefined) assertKey(titleIcon.hoverActionKey, `${iconPath}.hoverActionKey`);
664
667
  }
668
+ if (row.titleImage !== undefined) {
669
+ const imagePath = `${path}.titleImage`;
670
+ const descriptor = protocolObject(row.titleImage, imagePath);
671
+ assertUnknownKeys(descriptor, ['image', 'accessibilityLabel', 'hoverActionKey'], imagePath, 'title image key');
672
+ protocolObject(row.titleImage.image, `${imagePath}.image`);
673
+ if (typeof row.titleImage.image.uri !== 'string') fail(`${imagePath}.image.uri`, 'must be a string');
674
+ assertImage(row.titleImage.image, `${imagePath}.image`);
675
+ const {
676
+ width,
677
+ height
678
+ } = row.titleImage.image;
679
+ if (width !== height || width < 1 || width > 40) fail(`${imagePath}.image`, 'must be square within 1...40');
680
+ if (row.titleImage.accessibilityLabel !== undefined && typeof row.titleImage.accessibilityLabel !== 'string') fail(`${imagePath}.accessibilityLabel`, 'must be a string');
681
+ if (row.titleImage.hoverActionKey !== undefined) assertKey(row.titleImage.hoverActionKey, `${imagePath}.hoverActionKey`);
682
+ }
665
683
  const ratio = row.style?.leadingContentMaxWidthRatio;
666
684
  if (ratio !== undefined && (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0 || ratio > 1)) fail(`${path}.style.leadingContentMaxWidthRatio`, 'must be within (0, 1]');
667
685
  assertTrailingAccessories(row.trailing, `${path}.trailing`);
668
686
  }
669
687
  function assertImage(image, path) {
670
688
  if (!image) return;
689
+ if (!isAllowedImageUri(image.uri)) fail(`${path}.uri`, 'unsupported image URI');
671
690
  if (!image.uri.trim()) fail(`${path}.uri`, 'must not be empty');
672
691
  if (image.fallbackUris !== undefined) {
673
692
  if (!Array.isArray(image.fallbackUris)) fail(`${path}.fallbackUris`, 'must be an array');
@@ -826,6 +845,7 @@ function assertRowType(type, path) {
826
845
  }
827
846
  function assertRow(row, index, path = `rows[${index}]`) {
828
847
  assertRowType(row.type, path);
848
+ assertUnknownKeys(row, ROW_FIELDS[row.type], path, 'row key');
829
849
  assertKey(row.key, `${path}.key`);
830
850
  // OneKey patch: explicit dimensions and opacity cannot corrupt list layout.
831
851
  if (row.height !== undefined && (row.height < 0 || row.height > 4096)) fail(`${path}.height`, 'must be within 0...4096');
@@ -1155,6 +1175,37 @@ function assertSnapshotContract(snapshot) {
1155
1175
  }
1156
1176
  return snapshot;
1157
1177
  }
1178
+ const COMMON_ROW_FIELDS = ['height', 'heightRounding', 'testID', 'opacity', 'backgroundColor', 'backgroundFullWidth', 'pressDisabled', 'key', 'revision', 'sectionKey', 'groupId', 'groupPosition', 'size', 'density', 'disabled', 'selected', 'accessibilityLabel', 'separator', 'type', 'style'];
1179
+ const ROW_FIELDS = {
1180
+ identity: [...COMMON_ROW_FIELDS, 'contentHeight', 'longPressActionKey', 'titleMatch', 'titleActionKey', 'titleActionOnHover', 'subtitleSegments', 'presentation', 'leading', 'leadingAction', 'title', 'titleSuffixes', 'titleIcon', 'secondaryTitleIcon', 'titleImage', 'subtitle', 'tertiary', 'tertiaryTone', 'titleLines', 'subtitleLines', 'badges', 'trailing', 'draggable'],
1181
+ walletGroup: [...COMMON_ROW_FIELDS, 'parent', 'children', 'draggable'],
1182
+ rail: [...COMMON_ROW_FIELDS, 'visual', 'title', 'status', 'badge', 'draggable'],
1183
+ activity: [...COMMON_ROW_FIELDS, 'leading', 'hideLeading', 'secondaryLeading', 'title', 'description', 'status', 'primaryAmount', 'secondaryAmount', 'amounts', 'badges', 'presentation', 'fee', 'descriptionActionKey', 'footerActions'],
1184
+ message: [...COMMON_ROW_FIELDS, 'presentation', 'unread', 'leading', 'title', 'body', 'bodyLines', 'time', 'thumbnail'],
1185
+ 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'],
1187
+ mediaTile: [...COMMON_ROW_FIELDS, 'variant', 'media', 'image', 'imageState', 'networkImage', 'title', 'subtitle', 'badge', 'closeActionKey'],
1188
+ metricCard: [...COMMON_ROW_FIELDS, 'variant', 'title', 'value', 'subtitle', 'trend', 'trendTone', 'visual', 'badge', 'metrics', 'progress'],
1189
+ sectionHeader: [...COMMON_ROW_FIELDS, 'sticky', 'valueActionTestID', 'valueSegments', 'titleActionKey', 'titleActionOnHover', 'presentation', 'indexTitle', 'variant', 'title', 'leadingBadge', 'titleLoading', 'subtitle', 'value', 'valueHelp', 'valueActionKey', 'titleIcon', 'valueIcon', 'checkbox'],
1190
+ action: [...COMMON_ROW_FIELDS, 'presentation', 'title', 'actionKey', 'tone', 'icon', 'checkbox', 'trailing'],
1191
+ buttonGroup: [...COMMON_ROW_FIELDS, 'buttons'],
1192
+ system: [...COMMON_ROW_FIELDS, 'variant', 'presentation', 'loadingStyle', 'message', 'actionKey', 'actionText', 'title', 'borderColor']
1193
+ };
1194
+ const COMMON_PATCH_FIELDS = ['height', 'heightRounding', 'opacity', 'pressDisabled', 'revision', 'disabled', 'selected', 'accessibilityLabel', 'separator', 'style'];
1195
+ const PATCH_FIELDS = {
1196
+ identity: [...COMMON_PATCH_FIELDS, 'contentHeight', 'longPressActionKey', 'titleMatch', 'titleActionKey', 'titleActionOnHover', 'subtitleSegments', 'leading', 'leadingAction', 'title', 'titleSuffixes', 'titleIcon', 'secondaryTitleIcon', 'titleImage', 'subtitle', 'tertiary', 'tertiaryTone', 'subtitleLines', 'badges', 'trailing'],
1197
+ rail: [...COMMON_PATCH_FIELDS, 'title', 'visual', 'status', 'badge'],
1198
+ activity: [...COMMON_PATCH_FIELDS, 'presentation', 'leading', 'title', 'badges', 'status', 'hideLeading', 'secondaryLeading', 'description', 'primaryAmount', 'secondaryAmount', 'amounts', 'fee', 'descriptionActionKey', 'footerActions'],
1199
+ message: [...COMMON_PATCH_FIELDS, 'presentation', 'title', 'unread', 'body', 'bodyLines', 'time', 'thumbnail'],
1200
+ 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'],
1202
+ mediaTile: [...COMMON_PATCH_FIELDS, 'title', 'subtitle', 'badge', 'media', 'image', 'imageState', 'networkImage', 'closeActionKey'],
1203
+ metricCard: [...COMMON_PATCH_FIELDS, 'title', 'subtitle', 'visual', 'badge', 'variant', 'value', 'trend', 'trendTone', 'metrics', 'progress'],
1204
+ sectionHeader: [...COMMON_PATCH_FIELDS, 'titleActionKey', 'titleActionOnHover', 'title', 'titleIcon', 'subtitle', 'checkbox', 'variant', 'value', 'leadingBadge', 'titleLoading', 'valueHelp', 'valueActionKey', 'valueIcon'],
1205
+ action: [...COMMON_PATCH_FIELDS, 'title', 'trailing', 'checkbox', 'tone', 'icon'],
1206
+ buttonGroup: [...COMMON_PATCH_FIELDS, 'buttons'],
1207
+ system: ['revision', 'disabled', 'message']
1208
+ };
1158
1209
  function protocolObject(value, path) {
1159
1210
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail(path, 'must be an object');
1160
1211
  return value;
@@ -1193,6 +1244,13 @@ function assertProtocolFields(value, path, parentField = '') {
1193
1244
  if (!path.includes('.style.') && ['image', 'networkImage', 'thumbnail', 'images', 'hoverImages'].includes(parentField) && typeof object.uri !== 'string') fail(`${path}.uri`, 'must be a string');
1194
1245
  Object.entries(value).forEach(([field, item]) => {
1195
1246
  if (item === undefined) return;
1247
+ if (field === 'headers') {
1248
+ const headers = protocolObject(item, `${path}.headers`);
1249
+ for (const [name, headerValue] of Object.entries(headers)) {
1250
+ if (!name.trim() || typeof headerValue !== 'string') fail(`${path}.headers`, 'must contain string headers');
1251
+ }
1252
+ return;
1253
+ }
1196
1254
  const fieldPath = `${path}.${field}`;
1197
1255
  if (!path.endsWith('.style') && PROTOCOL_ARRAY_FIELDS.has(field) && !Array.isArray(item)) fail(fieldPath, 'must be an array');
1198
1256
  if (PROTOCOL_NUMBER_FIELDS.has(field) && typeof item !== 'number') fail(fieldPath, 'must be a finite number');
@@ -1200,7 +1258,7 @@ function assertProtocolFields(value, path, parentField = '') {
1200
1258
  if (parentField === 'overlays' && field === 'fontWeight' && !['regular', 'medium', 'semibold', 'bold'].includes(item)) fail(fieldPath, 'must be regular, medium, semibold, or bold');
1201
1259
  if (field === 'buttonStyle' && item !== 'tertiary') fail(fieldPath, 'must be tertiary');
1202
1260
  if (field === 'hideLeading' && typeof item !== 'boolean') fail(fieldPath, 'must be boolean');
1203
- if (field === 'key' || field === 'sectionKey' || field === 'actionKey' || field.endsWith('ActionKey')) protocolKey(item, fieldPath);
1261
+ if (field === 'key' || field === 'sectionKey' || field === 'groupId' || field === 'actionKey' || field.endsWith('ActionKey')) protocolKey(item, fieldPath);
1204
1262
  if ((field === 'uri' || field === 'fallbackUri') && typeof item !== 'string') fail(fieldPath, 'must be a string');
1205
1263
  if (field === 'style' && !(item === 'subscript' && /[Ss]egments\[/.test(path)) || field === 'container') protocolObject(item, fieldPath);
1206
1264
  if (path.endsWith('.style') && ['title', 'subtitle', 'tertiary', 'body', 'time', 'price', 'change', 'columns', 'columnSecondary', 'value', 'valueSecondary', 'badge', 'description', 'status', 'primaryAmount', 'secondaryAmount', 'trend', 'message', 'actionText'].includes(field)) protocolObject(item, fieldPath);
@@ -1211,6 +1269,7 @@ function assertRowProtocol(row, path) {
1211
1269
  protocolObject(row, path);
1212
1270
  assertRowType(row.type, path);
1213
1271
  protocolKey(row.key, `${path}.key`);
1272
+ assertCoreRow(row, path);
1214
1273
  assertProtocolFields(row, path);
1215
1274
  for (const field of ['leading', 'visual', 'secondaryLeading']) {
1216
1275
  const visual = row[field];
@@ -1225,7 +1284,7 @@ function assertRowProtocol(row, path) {
1225
1284
  }
1226
1285
  if (row.height !== undefined && row.height < 0) fail(`${path}.height`, 'must be non-negative');
1227
1286
  if (row.type === 'identity') {
1228
- assertIdentityExtras(row, path);
1287
+ if (isDevelopment()) assertIdentityExtras(row, path);
1229
1288
  if (row.titleSuffixes !== undefined && !Array.isArray(row.titleSuffixes)) fail(`${path}.titleSuffixes`, 'must be an array');
1230
1289
  row.titleSuffixes?.forEach((suffix, index) => {
1231
1290
  protocolObject(suffix, `${path}.titleSuffixes[${index}]`);
@@ -1238,8 +1297,8 @@ function assertRowProtocol(row, path) {
1238
1297
  end = match.end;
1239
1298
  });
1240
1299
  }
1241
- if (row.type === 'buttonGroup') assertButtonGroup(row, path);
1242
- if (row.type === 'sectionHeader') assertTextHelp(row.valueHelp, `${path}.valueHelp`);
1300
+ if (row.type === 'buttonGroup' && isDevelopment()) assertButtonGroup(row, path);
1301
+ if (row.type === 'sectionHeader' && isDevelopment()) assertTextHelp(row.valueHelp, `${path}.valueHelp`);
1243
1302
  if (row.type === 'mediaTile' && row.media) {
1244
1303
  protocolObject(row.media.source, `${path}.media.source`);
1245
1304
  if (!Array.isArray(row.media.probeOrder)) fail(`${path}.media.probeOrder`, 'must be an array');
@@ -1261,9 +1320,9 @@ function assertRowProtocol(row, path) {
1261
1320
  row.columns.forEach((column, index) => {
1262
1321
  const columnPath = `${path}.columns[${index}]`;
1263
1322
  protocolObject(column, columnPath);
1264
- assertIdentityExtras(column, columnPath);
1323
+ if (isDevelopment()) assertIdentityExtras(column, columnPath);
1265
1324
  });
1266
- assertTrailingAccessories(row.trailing, `${path}.trailing`);
1325
+ if (isDevelopment()) assertTrailingAccessories(row.trailing, `${path}.trailing`);
1267
1326
  }
1268
1327
  if (row.type === 'market') {
1269
1328
  protocolObject(row.change, `${path}.change`);
@@ -1329,38 +1388,341 @@ function assertSnapshotProtocol(snapshot) {
1329
1388
  });
1330
1389
  }
1331
1390
  }
1391
+ function isRecord(value) {
1392
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1393
+ }
1394
+ function isAllowedImageUri(value) {
1395
+ return typeof value === 'string' && value.trim().length > 0 && (!/^[a-z][a-z\d+.-]*:/i.test(value) || /^(https?|file|content|ph|assets-library|asset|res|blob):/i.test(value) || /^data:image\//i.test(value));
1396
+ }
1397
+ const IMAGE_FIELDS = ['uri', 'width', 'height', 'headers', 'contentFit', 'cachePolicy', 'autoplay', 'optimizeTos', 'overscan', 'loadingStrategy', 'fallbackUri', 'fallbackUris', 'retryTimes'];
1398
+ const ICON_FIELDS = ['name', 'tintColor', 'backgroundColor', 'size', 'accessibilityLabel', 'hoverActionKey'];
1399
+ const VISUAL_FIELDS = ['kind', 'name', 'image', 'images', 'networkImage', 'shape', 'tintColor', 'backgroundColor', 'fallbackIcon', 'fallbackText', 'fallbackTextColor', 'overlays', 'borderStyle', 'borderColor', 'cornerIcon'];
1400
+ const TEXT_STYLE_FIELDS = ['token', 'fontSize', 'fontWeight', 'color', 'lineHeight', 'lines', 'truncate', 'alignment', 'verticalAlignment', 'offsetY'];
1401
+ const ACCESSORY_FIELDS = {
1402
+ value: ['kind', 'text', 'secondary', 'textSegments'],
1403
+ valuePair: ['kind', 'primary', 'secondary', 'primaryFontWeight', 'primaryHelp', 'primaryTextSegments', 'secondaryTextSegments', 'primaryTone', 'secondaryTone'],
1404
+ button: ['kind', 'label', 'actionKey', 'disabled', 'testID', 'accessibilityLabel', 'textColor', 'backgroundColor', 'pressedBackgroundColor', 'hoverBackgroundColor', 'focusRingColor'],
1405
+ icon: ['kind', 'name', 'tintColor', 'buttonStyle', 'disabled', 'actionKey', 'testID', 'hoverActionKey', 'accessibilityLabel'],
1406
+ checkbox: ['kind', 'state', 'disabled', 'loading', 'target', 'actionKey'],
1407
+ radio: ['kind', 'checked', 'disabled', 'actionKey'],
1408
+ switch: ['kind', 'value', 'disabled', 'actionKey'],
1409
+ chevron: ['kind', 'actionKey'],
1410
+ menu: ['kind', 'actionKey'],
1411
+ drag: ['kind', 'actionKey'],
1412
+ spinner: ['kind'],
1413
+ progress: ['kind', 'value']
1414
+ };
1415
+ const REQUIRED_ROW_FIELDS = {
1416
+ identity: ['title', 'leading'],
1417
+ walletGroup: ['parent', 'children'],
1418
+ rail: ['title', 'visual'],
1419
+ activity: ['title', 'leading'],
1420
+ message: ['title', 'body', 'time'],
1421
+ dataRow: ['columns'],
1422
+ market: ['title', 'leading', 'variant', 'price', 'change'],
1423
+ mediaTile: ['title', 'variant'],
1424
+ metricCard: ['title', 'value'],
1425
+ sectionHeader: ['title', 'sectionKey'],
1426
+ action: ['title', 'actionKey'],
1427
+ buttonGroup: ['buttons'],
1428
+ system: ['variant']
1429
+ };
1430
+ function assertCoreRow(row, path) {
1431
+ const object = row;
1432
+ for (const key of REQUIRED_ROW_FIELDS[row.type]) {
1433
+ const value = object[key];
1434
+ if (value === undefined || value === null) fail(`${path}.${key}`, 'required field');
1435
+ if (['title', 'body', 'time', 'price', 'value', 'variant'].includes(key) && typeof value !== 'string') fail(`${path}.${key}`, 'must be a string');
1436
+ }
1437
+ if (row.type === 'market') {
1438
+ if (!row.price.trim()) fail(`${path}.price`, 'must be nonblank');
1439
+ if (typeof row.change?.text !== 'string') fail(`${path}.change.text`, 'must be a string');
1440
+ }
1441
+ if (row.type === 'activity') {
1442
+ row.amounts?.forEach((amount, index) => {
1443
+ protocolKey(amount.key, `${path}.amounts[${index}].key`);
1444
+ if (typeof amount.text !== 'string') fail(`${path}.amounts[${index}].text`, 'required financial text');
1445
+ });
1446
+ if (row.fee && typeof row.fee.primary !== 'string') fail(`${path}.fee.primary`, 'required financial text');
1447
+ row.footerActions?.forEach((action, index) => {
1448
+ protocolKey(action.key, `${path}.footerActions[${index}].key`);
1449
+ protocolKey(action.label, `${path}.footerActions[${index}].label`);
1450
+ });
1451
+ }
1452
+ if (row.type === 'action') protocolKey(row.actionKey, `${path}.actionKey`);
1453
+ if (row.type === 'sectionHeader') protocolKey(row.sectionKey, `${path}.sectionKey`);
1454
+ if (row.type === 'system' && ['retry', 'warning', 'noMatch'].includes(row.variant)) {
1455
+ if (typeof object.message !== 'string') fail(`${path}.message`, 'must be a string');
1456
+ if (row.variant === 'retry') protocolKey(row.actionKey, `${path}.actionKey`);
1457
+ }
1458
+ if (row.type === 'dataRow') {
1459
+ if (!Array.isArray(row.columns) || row.columns.length === 0) fail(`${path}.columns`, 'required columns');
1460
+ row.columns.forEach((column, index) => {
1461
+ protocolObject(column, `${path}.columns[${index}]`);
1462
+ protocolKey(column.key, `${path}.columns[${index}].key`);
1463
+ if (typeof column.text !== 'string') fail(`${path}.columns[${index}].text`, 'must be a string');
1464
+ });
1465
+ }
1466
+ if (row.type === 'buttonGroup') {
1467
+ if (!Array.isArray(row.buttons) || row.buttons.length === 0) fail(`${path}.buttons`, 'required buttons');
1468
+ const keys = new Set();
1469
+ row.buttons.forEach((button, index) => {
1470
+ const field = `${path}.buttons[${index}]`;
1471
+ protocolObject(button, field);
1472
+ protocolKey(button.key, `${field}.key`);
1473
+ protocolKey(button.actionKey, `${field}.actionKey`);
1474
+ protocolKey(button.label, `${field}.label`);
1475
+ if (keys.has(button.key)) fail(field, 'duplicate button identity');
1476
+ keys.add(button.key);
1477
+ });
1478
+ }
1479
+ for (const field of ['trailing', 'leadingAction', 'titleIcon', 'valueIcon']) {
1480
+ const value = object[field];
1481
+ if (value === undefined) continue;
1482
+ // Identity title icons are decorative descriptors, not trailing controls.
1483
+ if (field === 'titleIcon' && row.type !== 'sectionHeader') continue;
1484
+ const accessories = field === 'trailing' ? value : [value];
1485
+ if (!Array.isArray(accessories)) fail(`${path}.${field}`, 'must be an array');
1486
+ accessories.forEach((accessory, index) => {
1487
+ const entry = protocolObject(accessory, `${path}.${field}[${index}]`);
1488
+ for (const key of entry.kind === 'value' ? ['text'] : entry.kind === 'valuePair' ? ['primary', 'secondary'] : []) {
1489
+ if (typeof entry[key] !== 'string') fail(`${path}.${field}[${index}].${key}`, 'required financial text');
1490
+ }
1491
+ if (['button', 'menu', 'switch'].includes(entry.kind) || 'actionKey' in entry) {
1492
+ protocolKey(entry.actionKey, `${path}.${field}[${index}].actionKey`);
1493
+ if (entry.kind === 'button') protocolKey(entry.label, `${path}.${field}[${index}].label`);
1494
+ }
1495
+ if (entry.kind === 'icon') protocolKey(entry.name, `${path}.${field}[${index}].name`);
1496
+ });
1497
+ }
1498
+ }
1499
+ function pickFields(object, fields) {
1500
+ return Object.fromEntries(Object.entries(object).filter(([key]) => fields.includes(key)));
1501
+ }
1502
+ function sanitizeImage(value) {
1503
+ if (!isRecord(value) || !isAllowedImageUri(value.uri) || typeof value.width !== 'number' || !Number.isFinite(value.width) || value.width <= 0 || typeof value.height !== 'number' || !Number.isFinite(value.height) || value.height <= 0) return undefined;
1504
+ const next = pickFields(value, IMAGE_FIELDS);
1505
+ next.width = Math.min(MAX_IMAGE_EDGE, value.width);
1506
+ next.height = Math.min(MAX_IMAGE_EDGE, value.height);
1507
+ if (next.headers !== undefined) {
1508
+ if (!isRecord(next.headers)) delete next.headers;else next.headers = Object.fromEntries(Object.entries(next.headers).filter(([key, item]) => key.trim() && key.length <= MAX_HEADER_LENGTH && typeof item === 'string' && item.length <= MAX_HEADER_LENGTH).slice(0, MAX_IMAGE_HEADERS));
1509
+ }
1510
+ if (next.fallbackUris !== undefined) {
1511
+ if (Array.isArray(next.fallbackUris)) next.fallbackUris = next.fallbackUris.filter(isAllowedImageUri);else delete next.fallbackUris;
1512
+ }
1513
+ if (next.fallbackUri !== undefined && !isAllowedImageUri(next.fallbackUri)) delete next.fallbackUri;
1514
+ if (next.overscan !== undefined && (typeof next.overscan !== 'number' || !Number.isFinite(next.overscan) || next.overscan < 1 || next.overscan > 4)) delete next.overscan;
1515
+ if (next.retryTimes !== undefined && (typeof next.retryTimes !== 'number' || !Number.isFinite(next.retryTimes) || next.retryTimes < 0)) delete next.retryTimes;
1516
+ const options = {
1517
+ contentFit: ['cover', 'contain', 'fill', 'center'],
1518
+ cachePolicy: ['memory-disk', 'memory', 'disk', 'none'],
1519
+ loadingStrategy: ['static', 'skeleton', 'none']
1520
+ };
1521
+ for (const [key, allowed] of Object.entries(options)) if (next[key] !== undefined && !allowed.includes(next[key])) delete next[key];
1522
+ for (const key of ['autoplay', 'optimizeTos']) if (next[key] !== undefined && typeof next[key] !== 'boolean') delete next[key];
1523
+ return next;
1524
+ }
1525
+ const NESTED_DECORATION_FIELDS = {
1526
+ webTable: ['columns', 'cells', 'testID', 'draggable', 'contextMenuActionKey'],
1527
+ columns: ['key', 'text', 'textSegments', 'titleIcon', 'secondaryTitleIcon', 'secondaryLeadingText', 'secondaryText', 'secondaryTextSegments', 'weight', 'alignment', 'tone', 'secondaryTone', 'width', 'minWidth', 'flex', 'paddingStart', 'paddingEnd'],
1528
+ cells: ['key', 'kind', 'active', 'disabled', 'testID', 'hoverActionKey', 'actionKey', 'accessibilityLabel', 'leading', 'title', 'secondary', 'lineGap', 'secondaryBadges', 'hoverImages', 'hoverSecondary', 'hoverActionLabel', 'badges', 'primary', 'partGap', 'points', 'width', 'height', 'strokeColor', 'fillColor'],
1529
+ badges: ['key', 'text', 'tone', 'iconName', 'icon', 'textColor', 'backgroundColor', 'actionKey', 'accessibilityLabel', 'style'],
1530
+ overlays: ['position', 'size', 'width', 'height', 'padding', 'offset', 'offsetX', 'offsetY', 'image', 'fallbackIcon', 'name', 'text', 'fontWeight', 'tintColor', 'backgroundColor'],
1531
+ primaryHelp: ['actionKey', 'presentation', 'underline'],
1532
+ valueHelp: ['actionKey', 'presentation', 'underline']
1533
+ };
1534
+ function sanitizeDecoration(value, field) {
1535
+ if (value === undefined || value === null) return undefined;
1536
+ if (!Array.isArray(value) && ['image', 'networkImage', 'thumbnail', 'source', 'images', 'hoverImages'].includes(field)) return sanitizeImage(value);
1537
+ if (['titleImage', 'titleIcon', 'secondaryTitleIcon', 'fallbackIcon', 'cornerIcon', 'media'].includes(field) && !isRecord(value)) return undefined;
1538
+ if (field === 'style' && typeof value !== 'string' && !isRecord(value)) return undefined;
1539
+ if (['badges', 'overlays'].includes(field) && !Array.isArray(value) && !isRecord(value)) return undefined;
1540
+ if (field === 'style' && typeof value === 'string') return value === 'subscript' ? value : undefined;
1541
+ if (Array.isArray(value)) {
1542
+ const items = value.map(item => sanitizeDecoration(item, field));
1543
+ return field === 'hoverImages' ? items.map(item => item ?? null) : items.filter(item => item !== undefined);
1544
+ }
1545
+ if (!isRecord(value)) return value;
1546
+ let object = value;
1547
+ if (field === 'titleImage') {
1548
+ const image = sanitizeImage(value.image);
1549
+ if (!image) return undefined;
1550
+ const edge = Math.min(40, Math.max(1, Math.round(Math.min(image.width, image.height))));
1551
+ object = {
1552
+ ...pickFields(value, ['image', 'accessibilityLabel', 'hoverActionKey']),
1553
+ image: {
1554
+ ...image,
1555
+ width: edge,
1556
+ height: edge
1557
+ }
1558
+ };
1559
+ } else if (['titleIcon', 'secondaryTitleIcon', 'fallbackIcon', 'cornerIcon'].includes(field) && value.kind === undefined) {
1560
+ if (typeof value.name !== 'string' || !value.name.trim()) return undefined;
1561
+ object = pickFields(value, ICON_FIELDS);
1562
+ } else if (['leading', 'visual', 'secondaryLeading'].includes(field)) {
1563
+ object = pickFields(value, VISUAL_FIELDS);
1564
+ } else if (field === 'media') {
1565
+ object = pickFields(value, ['source', 'probeOrder']);
1566
+ }
1567
+ if (field === 'trailing' || ['leadingAction', 'titleIcon', 'valueIcon'].includes(field) && object.kind !== undefined) {
1568
+ const fields = ACCESSORY_FIELDS[object.kind];
1569
+ if (fields) object = pickFields(object, fields);
1570
+ } else if (field === 'buttons') object = pickFields(object, ['key', 'label', 'actionKey', 'disabled', 'loading', 'testID', 'accessibilityLabel']);else if (field === 'container') object = pickFields(object, ['height', 'backgroundColor', 'opacity', 'cornerRadius', 'borderWidth', 'borderColor', 'contentVerticalAlignment']);else if (field === 'imageStyle') object = pickFields(object, ['width', 'height', 'shape', 'cornerRadius', 'contentFit']);else if (field === 'style') object = pickFields(object, [...TEXT_STYLE_FIELDS, 'container', ...new Set(Object.values(ROW_BOX_STYLE_KEYS_BY_TYPE).flat()), ...new Set(Object.values(TEXT_STYLE_KEYS_BY_ROW_TYPE).flat()), ...new Set(Object.values(EXTRA_STYLE_KEYS_BY_ROW_TYPE).flat()), 'height']);else if (['title', 'subtitle', 'tertiary', 'body', 'time', 'price', 'change', 'value', 'valueSecondary', 'columnSecondary', 'primaryAmount', 'secondaryAmount', 'description', 'status', 'trend', 'message', 'actionText'].includes(field) && !('text' in object)) {
1571
+ object = pickFields(object, TEXT_STYLE_FIELDS);
1572
+ }
1573
+ if (NESTED_DECORATION_FIELDS[field]) object = pickFields(object, NESTED_DECORATION_FIELDS[field]);
1574
+ if (['textSegments', 'primaryTextSegments', 'secondaryTextSegments', 'priceSegments', 'segments'].includes(field)) object = pickFields(object, ['text', 'style']);
1575
+ if (field === 'subtitleSegments') object = pickFields(object, ['text', 'style', 'textSegments', 'tone', 'separatorBefore']);
1576
+ const styleObject = ['style', 'imageStyle', 'container'].includes(field) || TEXT_STYLE_FIELDS.some(key => key in object);
1577
+ const next = {};
1578
+ for (const [key, item] of Object.entries(object)) {
1579
+ if (item === undefined || item === null) continue;
1580
+ if ((/color$/i.test(key) || /background$/i.test(key)) && (typeof item !== 'string' || !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(item))) continue;
1581
+ if (/TextSegments$/.test(key) || ['textSegments', 'priceSegments', 'subtitleSegments', 'segments'].includes(key)) {
1582
+ if (!Array.isArray(item) || item.some(segment => !isRecord(segment) || typeof segment.text !== 'string')) continue;
1583
+ }
1584
+ if (styleObject && PROTOCOL_NUMBER_FIELDS.has(key) && typeof item !== 'number') continue;
1585
+ if (key === 'token' && (typeof item !== 'string' || !Object.hasOwn(TYPOGRAPHY_TOKENS, item))) continue;
1586
+ if (key === 'fontWeight' && !['regular', 'medium', 'semibold', 'bold'].includes(item)) continue;
1587
+ if (['disabled', 'loading', 'checked', 'selected', 'separator', 'hideLeading', 'pressDisabled', 'draggable', 'active', 'autoplay'].includes(key) && typeof item !== 'boolean') continue;
1588
+ if (key === 'primaryFontWeight' && item !== 'medium') continue;
1589
+ if (key === 'buttonStyle' && item !== 'tertiary') continue;
1590
+ if (key === 'hoverActionKey' && (typeof item !== 'string' || !item.trim())) continue;
1591
+ if (key === 'accessibilityLabel' && typeof item !== 'string') continue;
1592
+ if ((field === 'style' || PROTOCOL_NUMBER_FIELDS.has(key)) && typeof item !== 'object' && typeof item === 'number' && (!Number.isFinite(item) || item < 0 && !['offsetX', 'offsetY'].includes(key))) continue;
1593
+ if (field === 'style' && PROTOCOL_NUMBER_FIELDS.has(key) && typeof item !== 'number') continue;
1594
+ if (key === 'leadingContentMaxWidthRatio' && (typeof item !== 'number' || item <= 0 || item > 1)) continue;
1595
+ const normalized = sanitizeDecoration(item, field === 'style' && key === 'image' ? 'imageStyle' : key);
1596
+ if (normalized !== undefined) next[key] = normalized;
1597
+ }
1598
+ if (field === 'titleImage') {
1599
+ // The nested image was already normalized with title-specific geometry.
1600
+ if (!next.image) return undefined;
1601
+ }
1602
+ if (field === 'media' && (!next.source || !Array.isArray(next.probeOrder))) return undefined;
1603
+ if (['leading', 'visual', 'secondaryLeading'].includes(field)) {
1604
+ if (next.kind === 'image' && !next.image) return {
1605
+ kind: 'token',
1606
+ ...pickFields(next, ['fallbackIcon', 'fallbackText', 'fallbackTextColor'])
1607
+ };
1608
+ if (next.kind === 'stackedImages' && (!Array.isArray(next.images) || next.images.length < 2)) return {
1609
+ kind: 'token'
1610
+ };
1611
+ if (next.kind === 'icon' && (typeof next.name !== 'string' || !next.name.trim())) return {
1612
+ kind: 'token'
1613
+ };
1614
+ if (!['token', 'account', 'wallet', 'network', 'image', 'icon', 'stackedImages'].includes(next.kind)) return {
1615
+ kind: 'token'
1616
+ };
1617
+ }
1618
+ if (field === 'overlays' && (!['topLeft', 'bottomRight'].includes(next.position) || !next.image && !next.name && !next.text)) return undefined;
1619
+ if (field === 'trailing' && next.kind === 'icon' && !('actionKey' in value) && (typeof next.name !== 'string' || !next.name.trim())) return undefined;
1620
+ if (field === 'trailing' && !['value', 'valuePair', 'button', 'checkbox', 'radio', 'switch', 'chevron', 'menu', 'drag', 'icon', 'spinner', 'progress'].includes(next.kind) && !('actionKey' in value)) return undefined;
1621
+ if (field === 'badges' && (typeof next.key !== 'string' || !next.text && !next.iconName && !next.icon)) return undefined;
1622
+ if (next.kind === 'sparkline') {
1623
+ const points = Array.isArray(value.points) ? value.points.filter(point => typeof point === 'number' && Number.isFinite(point)).slice(0, 256) : [];
1624
+ // Preserve the table's column identity even when its optional drawing fails.
1625
+ return points.length < 2 ? {
1626
+ key: next.key,
1627
+ kind: 'text',
1628
+ primary: []
1629
+ } : {
1630
+ ...next,
1631
+ points
1632
+ };
1633
+ }
1634
+ return next;
1635
+ }
1636
+ function sanitizeRowForProduction(row) {
1637
+ const object = protocolObject(row, 'row');
1638
+ const fields = ROW_FIELDS[row.type];
1639
+ if (!fields) return row;
1640
+ const next = pickFields(object, fields);
1641
+ for (const [field, value] of Object.entries(next)) {
1642
+ if ((/color$/i.test(field) || /background$/i.test(field)) && (typeof value !== 'string' || !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value))) {
1643
+ delete next[field];
1644
+ continue;
1645
+ }
1646
+ 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);
1648
+ if (normalized === undefined) delete next[field];else next[field] = normalized;
1649
+ }
1650
+ }
1651
+ if (isRecord(next.style)) next.style = pickFields(next.style, ['container', ...ROW_BOX_STYLE_KEYS_BY_TYPE[row.type], ...(TEXT_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []), ...(EXTRA_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? [])]);
1652
+ return sameJsonValue(row, next) ? row : next;
1653
+ }
1332
1654
 
1333
- /** Empty optional image descriptors must take the same path as absent images. */
1655
+ /** Empty images and optional nulls share one meaning before strict assertions. */
1334
1656
  function normalizeEmptyImages(value, field = '') {
1335
- if (!value || typeof value !== 'object') return value;
1657
+ if (value === null || value === undefined || typeof value !== 'object') return value;
1336
1658
  if (Array.isArray(value)) {
1337
- const next = value.map(item => normalizeEmptyImages(item));
1338
- const filtered = field === 'images' ? next.filter(item => item !== undefined) : next.map(item => item === undefined ? null : item);
1339
- return filtered.length === value.length && filtered.every((item, index) => item === value[index]) ? value : filtered;
1659
+ const next = value.map(item => normalizeEmptyImages(item, field));
1660
+ return next.every((item, index) => item === value[index]) ? value : next.filter(item => item !== undefined);
1340
1661
  }
1341
1662
  const object = value;
1342
1663
  if (typeof object.uri === 'string' && !object.uri.trim() && 'width' in object && 'height' in object) return undefined;
1343
1664
  let changed = false;
1344
- const next = {
1345
- ...object
1346
- };
1347
- Object.entries(object).forEach(([key, item]) => {
1348
- const normalized = normalizeEmptyImages(item, key);
1349
- if (normalized !== item) {
1350
- // Omitted JSON patch fields must also be absent from the JS merge.
1351
- if (field === 'changes' && normalized === undefined) delete next[key];else next[key] = normalized;
1665
+ const next = {};
1666
+ for (const [key, item] of Object.entries(object)) {
1667
+ 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) {
1352
1669
  changed = true;
1670
+ continue;
1353
1671
  }
1354
- });
1355
- if (field === 'media' && next.source === undefined) return undefined;
1672
+ const normalized = field === 'headers' ? item : normalizeEmptyImages(item, key);
1673
+ if (normalized !== undefined) next[key] = normalized;
1674
+ if (normalized !== item) changed = true;
1675
+ }
1676
+ if (field === 'media' && object.source !== undefined && next.source === undefined || field === 'titleImage' && object.image !== undefined && next.image === undefined) return undefined;
1677
+ if (['leading', 'visual', 'secondaryLeading'].includes(field) && next.kind === 'image' && !next.image) return {
1678
+ kind: 'token'
1679
+ };
1680
+ if (field === 'overlays' && object.image !== undefined && !next.image) return undefined;
1356
1681
  return changed ? next : value;
1357
1682
  }
1683
+ function normalizeSnapshotForProduction(snapshot) {
1684
+ if (!Array.isArray(snapshot.rows)) return snapshot;
1685
+ let rows = snapshot.rows.map(sanitizeRowForProduction);
1686
+ rows = rows.map((row, index) => {
1687
+ if (!row.groupId) return row;
1688
+ const previous = rows[index - 1]?.groupId === row.groupId;
1689
+ const following = rows[index + 1]?.groupId === row.groupId;
1690
+ return {
1691
+ ...row,
1692
+ groupPosition: previous ? following ? 'middle' : 'last' : following ? 'first' : 'single'
1693
+ };
1694
+ });
1695
+ const next = {
1696
+ ...snapshot,
1697
+ rows,
1698
+ ...(snapshot.emptyState ? {
1699
+ emptyState: sanitizeRowForProduction(snapshot.emptyState)
1700
+ } : {}),
1701
+ ...(snapshot.fixedFooter ? {
1702
+ fixedFooter: sanitizeRowForProduction(snapshot.fixedFooter)
1703
+ } : {})
1704
+ };
1705
+ if (snapshot.theme) next.theme = sanitizeDecoration(snapshot.theme, 'theme');
1706
+ if (snapshot.listStyle) next.listStyle = sanitizeDecoration(snapshot.listStyle, 'style');
1707
+ if (snapshot.selection && Array.isArray(snapshot.selection.selectedKeys)) {
1708
+ const selectable = new Set(rows.filter(row => !row.disabled && !['sectionHeader', 'action', 'system', 'buttonGroup', 'market', 'walletGroup'].includes(row.type)).map(row => row.key));
1709
+ const selectedKeys = [...new Set(snapshot.selection.selectedKeys.filter(key => selectable.has(key)))];
1710
+ next.selection = {
1711
+ ...snapshot.selection,
1712
+ selectedKeys: snapshot.selection.mode === 'none' ? [] : snapshot.selection.mode === 'single' ? selectedKeys.slice(0, 1) : selectedKeys
1713
+ };
1714
+ }
1715
+ return sameJsonValue(snapshot, next) ? snapshot : next;
1716
+ }
1358
1717
  export function validateSnapshot(snapshot) {
1359
- assertPlainSerializable(snapshot, 'snapshot', new Set());
1360
- if (isDevelopment()) assertSnapshotContract(snapshot);
1361
- const normalized = isDevelopment() ? snapshot : normalizeEmptyImages(snapshot);
1362
- assertSnapshotProtocol(normalized);
1363
- return normalizeSnapshotStyles(normalized);
1718
+ assertPlainSerializable(snapshot, 'snapshot', new Set(), false);
1719
+ protocolObject(snapshot, 'snapshot');
1720
+ const normalized = normalizeEmptyImages(snapshot);
1721
+ const canonical = isDevelopment() ? normalized : normalizeSnapshotForProduction(normalized);
1722
+ assertPlainSerializable(canonical, 'snapshot', new Set());
1723
+ if (isDevelopment()) assertSnapshotContract(canonical);
1724
+ assertSnapshotProtocol(canonical);
1725
+ return normalizeSnapshotStyles(canonical);
1364
1726
  }
1365
1727
  const ownedNativeSnapshots = new WeakMap();
1366
1728
  function capturePlainData(value, seen = new Map()) {
@@ -1429,8 +1791,12 @@ function normalizeSnapshotStyles(snapshot) {
1429
1791
  return {
1430
1792
  ...snapshot,
1431
1793
  rows,
1432
- emptyState,
1433
- fixedFooter
1794
+ ...(emptyState !== undefined ? {
1795
+ emptyState
1796
+ } : {}),
1797
+ ...(fixedFooter !== undefined ? {
1798
+ fixedFooter
1799
+ } : {})
1434
1800
  };
1435
1801
  }
1436
1802
  function assertPatchChanges(patch, index) {
@@ -1626,28 +1992,116 @@ function assertPatchChanges(patch, index) {
1626
1992
  fail(`patches[${index}].type`, 'is not supported');
1627
1993
  }
1628
1994
  }
1995
+ function normalizePatch(patch, index) {
1996
+ const path = `patches[${index}]`;
1997
+ protocolObject(patch, path);
1998
+ protocolKey(patch.key, `${path}.key`);
1999
+ if (isDevelopment()) assertKey(patch.key, `${path}.key`);
2000
+ assertRowType(patch.type, path);
2001
+ const raw = protocolObject(patch.changes, `${path}.changes`);
2002
+ assertPlainSerializable(raw, `${path}.changes`, new Set(), false);
2003
+ const fields = PATCH_FIELDS[patch.type];
2004
+ const changes = {};
2005
+ for (const [field, value] of Object.entries(raw)) {
2006
+ if (value === undefined) continue;
2007
+ if (!fields?.includes(field)) {
2008
+ if (isDevelopment()) fail(`${path}.changes.${field}`, 'unsupported patch field');
2009
+ continue;
2010
+ }
2011
+ if (value === null) {
2012
+ if (REQUIRED_ROW_FIELDS[patch.type].includes(field) || patch.type === 'system' && field === 'message') {
2013
+ // The concrete system variant is checked after merging with its baseline.
2014
+ if (patch.type !== 'system') fail(`${path}.changes.${field}`, 'required field cannot be cleared');
2015
+ }
2016
+ changes[field] = null;
2017
+ continue;
2018
+ }
2019
+ if (['priceSegments', 'subtitleSegments'].includes(field) && Array.isArray(value) && value.length === 0) {
2020
+ changes[field] = null;
2021
+ continue;
2022
+ }
2023
+ const normalized = normalizeEmptyImages(value, field);
2024
+ if (normalized === undefined) {
2025
+ if (!REQUIRED_ROW_FIELDS[patch.type].includes(field)) changes[field] = null;
2026
+ continue;
2027
+ }
2028
+ const canonical = isDevelopment() ? normalized : sanitizeDecoration(normalized, field);
2029
+ if (canonical !== undefined) changes[field] = canonical;else if (field === 'titleImage') changes[field] = null;
2030
+ }
2031
+ 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');
2032
+ if (!isDevelopment()) {
2033
+ const sanitized = sanitizeRowForProduction({
2034
+ type: patch.type,
2035
+ ...changes
2036
+ });
2037
+ for (const [field, value] of Object.entries(changes)) {
2038
+ if (value === null) continue;
2039
+ if (sanitized[field] === undefined) delete changes[field];else changes[field] = sanitized[field];
2040
+ }
2041
+ }
2042
+ const values = Object.fromEntries(Object.entries(changes).filter(([, value]) => value !== null));
2043
+ assertPlainSerializable(values, `${path}.changes`, new Set());
2044
+ assertProtocolFields(values, `${path}.changes`);
2045
+ if (isDevelopment()) assertPatchChanges({
2046
+ ...patch,
2047
+ changes: values
2048
+ }, index);
2049
+ const normalizedPatch = {
2050
+ type: patch.type,
2051
+ key: patch.key,
2052
+ changes
2053
+ };
2054
+ return Object.keys(changes).length > 0 ? normalizePatchStyles([normalizedPatch])[0] : undefined;
2055
+ }
2056
+ function reportRejectedPatch(index) {
2057
+ console.warn(`NativeList rejected invalid patch unit at patches[${index}]`);
2058
+ }
1629
2059
  export function validatePatches(patches) {
1630
- assertPlainSerializable(patches, 'patches', new Set());
1631
2060
  if (!Array.isArray(patches)) fail('patches', 'must be an array');
1632
- const keys = new Set();
1633
- for (const [index, patch] of patches.entries()) {
1634
- protocolObject(patch, `patches[${index}]`);
1635
- protocolKey(patch.key, `patches[${index}].key`);
1636
- if (keys.has(patch.key)) fail(`patches[${index}].key`, `duplicate key "${patch.key}"`);
1637
- keys.add(patch.key);
1638
- protocolObject(patch.changes, `patches[${index}].changes`);
1639
- assertRowType(patch.type, `patches[${index}]`);
1640
- assertProtocolFields(patch.changes, `patches[${index}].changes`);
1641
- if (patch.type === 'identity') assertIdentityExtras(patch.changes, `patches[${index}].changes`);
1642
- if (!isDevelopment()) continue;
1643
- assertKey(patch.key, `patches[${index}].key`);
1644
- if (Object.keys(patch.changes).length === 0) {
1645
- fail(`patches[${index}].changes`, 'must contain at least one field');
2061
+ const units = new Map();
2062
+ const rejectedKeys = new Set();
2063
+ const reject = (key, index, error) => {
2064
+ if (isDevelopment()) throw error;
2065
+ rejectedKeys.add(key);
2066
+ reportRejectedPatch(index);
2067
+ };
2068
+ patches.forEach((input, index) => {
2069
+ const key = isRecord(input) && typeof input.key === 'string' ? input.key : '';
2070
+ try {
2071
+ protocolObject(input, `patches[${index}]`);
2072
+ protocolKey(input.key, `patches[${index}].key`);
2073
+ assertRowType(input.type, `patches[${index}]`);
2074
+ const changes = capturePlainData(protocolObject(input.changes, `patches[${index}].changes`));
2075
+ assertPlainSerializable(changes, `patches[${index}].changes`, new Set(), false);
2076
+ const previous = units.get(key);
2077
+ if (previous && previous.patch.type !== input.type) fail(`patches[${index}].type`, 'conflicting row types');
2078
+ const defined = Object.fromEntries(Object.entries(changes).filter(([, value]) => value !== undefined));
2079
+ units.set(key, {
2080
+ index,
2081
+ patch: {
2082
+ key,
2083
+ type: input.type,
2084
+ changes: {
2085
+ ...previous?.patch.changes,
2086
+ ...defined
2087
+ }
2088
+ }
2089
+ });
2090
+ } catch (error) {
2091
+ reject(key, index, error);
2092
+ }
2093
+ });
2094
+ const accepted = [];
2095
+ for (const [key, unit] of units) {
2096
+ if (rejectedKeys.has(key)) continue;
2097
+ try {
2098
+ const patch = normalizePatch(unit.patch, unit.index);
2099
+ if (patch) accepted.push(patch);
2100
+ } catch (error) {
2101
+ reject(key, unit.index, error);
1646
2102
  }
1647
- assertPatchChanges(patch, index);
1648
2103
  }
1649
- const normalized = isDevelopment() ? patches : normalizeEmptyImages(patches);
1650
- return normalizePatchStyles(normalized.filter(patch => Object.keys(patch.changes).length > 0));
2104
+ return accepted;
1651
2105
  }
1652
2106
 
1653
2107
  /** Patches reach the native side without passing through a snapshot. */
@@ -1759,79 +2213,138 @@ export function planNativeListPatches(previous, next) {
1759
2213
  }
1760
2214
  return patches.length === 0 ? EMPTY_ROW_PATCHES : patches;
1761
2215
  }
1762
- export function applyRowPatches(snapshot, patches) {
1763
- const normalizedPatches = validatePatches(patches);
1764
- const patchByKey = new Map(normalizedPatches.map(patch => [patch.key, patch]));
1765
- const knownKeys = new Set(snapshot.rows.map(row => row.key));
1766
- normalizedPatches.forEach(patch => {
1767
- if (!knownKeys.has(patch.key)) fail('patches', `unknown row key "${patch.key}"`);
1768
- });
1769
- const rows = snapshot.rows.map(row => {
1770
- const patch = patchByKey.get(row.key);
1771
- if (!patch) return row;
1772
- if (patch.type !== row.type) {
1773
- fail('patches', `row "${row.key}" is ${row.type}, not ${patch.type}`);
2216
+ function mergeAndValidateSingleRow(previous, patch, index) {
2217
+ if (previous.key !== patch.key) fail(`patches[${index}].key`, 'does not match row identity');
2218
+ if (previous.type !== patch.type) fail(`patches[${index}].type`, 'does not match row type');
2219
+ const changes = {
2220
+ ...patch.changes
2221
+ };
2222
+ if (previous.type === 'market') {
2223
+ if (typeof changes.price === 'string' && !('priceSegments' in changes) && previous.priceSegments !== undefined) changes.priceSegments = null;
2224
+ if ((previous.webTable !== undefined || 'webTable' in changes) && ['price', 'priceSegments', 'change'].some(field => field in changes) && changes.webTable === undefined) fail(`patches[${index}].changes.webTable`, 'table quote changes require visible table cells');
2225
+ }
2226
+ if (previous.type === 'identity' || previous.type === 'market') {
2227
+ if (typeof changes.subtitle === 'string' && !('subtitleSegments' in changes) && previous.subtitleSegments !== undefined) changes.subtitleSegments = null;
2228
+ if (Array.isArray(changes.subtitleSegments) && changes.subtitleSegments.length > 0 && !('subtitle' in changes) && previous.subtitle !== undefined) changes.subtitle = null;
2229
+ }
2230
+ const merged = {
2231
+ ...previous
2232
+ };
2233
+ for (const [field, value] of Object.entries(changes)) {
2234
+ if (value === null) delete merged[field];else if (value !== undefined) merged[field] = value;
2235
+ }
2236
+ const next = normalizeRowStyles(merged);
2237
+ assertPlainSerializable(next, `rows[${index}]`, new Set());
2238
+ if (isDevelopment()) assertRow(next, index);
2239
+ assertRowProtocol(next, `rows[${index}]`);
2240
+ return {
2241
+ row: next,
2242
+ patch: {
2243
+ type: patch.type,
2244
+ key: patch.key,
2245
+ changes
1774
2246
  }
1775
- if (row.type === 'market' && row.webTable && patch.type === 'market' && (patch.changes.price !== undefined || patch.changes.priceSegments !== undefined || patch.changes.change !== undefined) && patch.changes.webTable === undefined) fail('patches', 'table quote changes must include the visible table cells');
1776
- return {
1777
- ...row,
1778
- ...patch.changes,
1779
- key: row.key,
1780
- type: row.type
1781
- };
1782
- });
1783
- return validateSnapshot({
1784
- ...snapshot,
1785
- rows
1786
- });
2247
+ };
1787
2248
  }
1788
2249
 
1789
- /** Native wrapper only: preserve full public validation for unsupported patches. */
1790
- export function applyOwnedNativePatches(snapshot, patches) {
1791
- const acceptedPatches = deepFreeze(validatePatches(capturePlainData(patches)));
1792
- const rowIndexes = ownedNativeSnapshots.get(snapshot);
1793
- const canPatchRows = rowIndexes !== undefined && acceptedPatches.every(patch => {
1794
- const index = rowIndexes.get(patch.key);
1795
- if (index === undefined || snapshot.rows[index].type !== patch.type) return false;
1796
- const allowed = patch.type === 'identity' ? ['subtitle', 'subtitleSegments', 'trailing'] : patch.type === 'dataRow' ? ['columns', 'trailing'] : undefined;
1797
- return allowed !== undefined && Object.entries(patch.changes).every(([key, value]) => allowed.includes(key) && (value !== undefined || snapshot.rows[index][key] === undefined));
2250
+ /** One canonical batch owns both accepted rows and the native wire payload. */
2251
+ export function applyValidatedRowPatches(snapshot, patches) {
2252
+ if (!Array.isArray(patches)) fail('patches', 'must be an array');
2253
+ const owned = ownedNativeSnapshots.has(snapshot);
2254
+ const rowIndexes = ownedNativeSnapshots.get(snapshot) ?? validatedRowIndexes.get(snapshot) ?? new Map(snapshot.rows.map((row, index) => [row.key, index]));
2255
+ validatedRowIndexes.set(snapshot, rowIndexes);
2256
+ const units = new Map();
2257
+ const rejected = new Set();
2258
+ const rejectedGroups = new Set();
2259
+ const reject = (key, index, error) => {
2260
+ if (isDevelopment()) throw error;
2261
+ rejected.add(key);
2262
+ const rowIndex = rowIndexes.get(key);
2263
+ const group = rowIndex === undefined ? undefined : snapshot.rows[rowIndex].groupId;
2264
+ if (group) rejectedGroups.add(group);
2265
+ reportRejectedPatch(index);
2266
+ };
2267
+ patches.forEach((input, index) => {
2268
+ const key = isRecord(input) && typeof input.key === 'string' ? input.key : '';
2269
+ try {
2270
+ protocolObject(input, `patches[${index}]`);
2271
+ protocolKey(input.key, `patches[${index}].key`);
2272
+ const rowIndex = rowIndexes.get(input.key);
2273
+ if (rowIndex === undefined) fail(`patches[${index}].key`, 'unknown row identity');
2274
+ if (snapshot.rows[rowIndex].type !== input.type) fail(`patches[${index}].type`, 'does not match row type');
2275
+ const changes = protocolObject(input.changes, `patches[${index}].changes`);
2276
+ const captured = capturePlainData(changes);
2277
+ assertPlainSerializable(captured, `patches[${index}].changes`, new Set(), false);
2278
+ const previous = units.get(input.key);
2279
+ // Undefined is omitted at every merge, so it cannot overwrite an earlier change.
2280
+ const defined = Object.fromEntries(Object.entries(captured).filter(([, value]) => value !== undefined));
2281
+ units.set(input.key, {
2282
+ index,
2283
+ type: input.type,
2284
+ changes: {
2285
+ ...previous?.changes,
2286
+ ...defined
2287
+ }
2288
+ });
2289
+ } catch (error) {
2290
+ reject(key, index, error);
2291
+ }
1798
2292
  });
1799
- if (!canPatchRows) {
1800
- const baseline = rowIndexes ? snapshot : capturePlainData(snapshot);
1801
- return {
1802
- patches: acceptedPatches,
1803
- snapshot: ownValidatedNativeSnapshot(applyRowPatches(baseline, acceptedPatches)),
1804
- json: JSON.stringify(acceptedPatches)
1805
- };
2293
+ const pending = new Map();
2294
+ for (const [key, unit] of units) {
2295
+ if (rejected.has(key)) continue;
2296
+ try {
2297
+ const patch = normalizePatch({
2298
+ key,
2299
+ type: unit.type,
2300
+ changes: unit.changes
2301
+ }, unit.index);
2302
+ if (!patch) continue;
2303
+ const merged = mergeAndValidateSingleRow(snapshot.rows[rowIndexes.get(key)], patch, rowIndexes.get(key));
2304
+ if (merged.row.disabled && snapshot.selection?.selectedKeys.includes(key)) fail(`patches[${unit.index}]`, 'cannot disable a selected row');
2305
+ pending.set(key, merged);
2306
+ } catch (error) {
2307
+ reject(key, unit.index, error);
2308
+ }
1806
2309
  }
1807
- const nextRows = snapshot.rows.slice();
1808
- for (const patch of acceptedPatches) {
1809
- const index = rowIndexes.get(patch.key);
1810
- const previous = nextRows[index];
1811
- const merged = {
1812
- ...previous,
1813
- ...patch.changes,
1814
- key: previous.key,
1815
- type: previous.type
1816
- };
1817
- const path = `rows[${index}]`;
1818
- assertPlainSerializable(merged, path, new Set());
1819
- if (isDevelopment()) assertRow(merged, index);
1820
- const normalized = isDevelopment() ? merged : normalizeEmptyImages(merged);
1821
- assertRowProtocol(normalized, path);
1822
- nextRows[index] = deepFreeze(normalizeRowStyles(normalized));
1823
- }
1824
- const nextSnapshot = Object.freeze({
2310
+ const accepted = [];
2311
+ let rows;
2312
+ for (const [key, value] of pending) {
2313
+ const index = rowIndexes.get(key);
2314
+ const group = snapshot.rows[index].groupId;
2315
+ if (rejected.has(key) || group && rejectedGroups.has(group)) continue;
2316
+ if (!rows) rows = snapshot.rows.slice();
2317
+ rows[index] = owned ? deepFreeze(value.row) : value.row;
2318
+ accepted.push(owned ? deepFreeze(value.patch) : value.patch);
2319
+ }
2320
+ if (!rows) return {
2321
+ patches: EMPTY_ROW_PATCHES,
2322
+ snapshot,
2323
+ json: '[]'
2324
+ };
2325
+ const next = owned ? Object.freeze({
1825
2326
  ...snapshot,
1826
- rows: Object.freeze(nextRows)
1827
- });
1828
- ownedNativeSnapshots.set(nextSnapshot, rowIndexes);
2327
+ rows: Object.freeze(rows)
2328
+ }) : {
2329
+ ...snapshot,
2330
+ rows
2331
+ };
2332
+ if (owned) ownedNativeSnapshots.set(next, rowIndexes);else validatedRowIndexes.set(next, rowIndexes);
1829
2333
  return {
1830
- patches: acceptedPatches,
1831
- snapshot: nextSnapshot,
1832
- json: JSON.stringify(acceptedPatches)
2334
+ patches: accepted,
2335
+ snapshot: next,
2336
+ json: JSON.stringify(accepted)
1833
2337
  };
1834
2338
  }
2339
+ const validatedRowIndexes = new WeakMap();
2340
+ export function applyOwnedNativePatches(snapshot, patches) {
2341
+ const owned = ownedNativeSnapshots.has(snapshot) ? snapshot : ownValidatedNativeSnapshot(validateSnapshot(capturePlainData(snapshot)));
2342
+ return applyValidatedRowPatches(owned, patches);
2343
+ }
2344
+ export function applyRowPatches(snapshot, patches) {
2345
+ const baseline = ownedNativeSnapshots.has(snapshot) ? snapshot : validateSnapshot(snapshot);
2346
+ return applyValidatedRowPatches(baseline, patches).snapshot;
2347
+ }
1835
2348
  export function serializeSnapshot(snapshot) {
1836
2349
  return JSON.stringify(validateSnapshot(snapshot));
1837
2350
  }