@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.
package/src/validation.ts CHANGED
@@ -74,13 +74,15 @@ export function emptyNativeListSnapshot(): NativeListSnapshot {
74
74
  function assertPlainSerializable(
75
75
  value: unknown,
76
76
  path: string,
77
- seen: Set<object>
77
+ seen: Set<object>,
78
+ requireFinite = true
78
79
  ): void {
79
80
  if (value === null || value === undefined) return;
80
81
  const valueType = typeof value;
81
82
  if (valueType === 'string' || valueType === 'boolean') return;
82
83
  if (valueType === 'number') {
83
- if (!Number.isFinite(value)) fail(path, 'numbers must be finite');
84
+ if (requireFinite && !Number.isFinite(value))
85
+ fail(path, 'numbers must be finite');
84
86
  return;
85
87
  }
86
88
  if (valueType !== 'object')
@@ -91,16 +93,21 @@ function assertPlainSerializable(
91
93
  seen.add(objectValue);
92
94
  if (Array.isArray(value)) {
93
95
  value.forEach((item, index) =>
94
- assertPlainSerializable(item, `${path}[${index}]`, seen)
96
+ assertPlainSerializable(item, `${path}[${index}]`, seen, requireFinite)
95
97
  );
96
98
  } else {
97
99
  const prototype = Object.getPrototypeOf(value);
98
100
  if (prototype !== Object.prototype && prototype !== null) {
99
101
  fail(path, 'must contain plain objects only');
100
102
  }
101
- Object.entries(value as Record<string, unknown>).forEach(([key, item]) =>
102
- assertPlainSerializable(item, `${path}.${key}`, seen)
103
- );
103
+ Object.entries(value as Record<string, unknown>).forEach(([key, item]) => {
104
+ if (
105
+ key === '__proto__' ||
106
+ (key === 'constructor' && typeof item === 'object')
107
+ )
108
+ fail(path, 'unsafe object key');
109
+ assertPlainSerializable(item, `${path}.${key}`, seen, requireFinite);
110
+ });
104
111
  }
105
112
  seen.delete(objectValue);
106
113
  }
@@ -1248,7 +1255,7 @@ function assertButtonGroup(
1248
1255
  function assertIdentityExtras(
1249
1256
  row: Pick<
1250
1257
  IdentityRow,
1251
- 'titleIcon' | 'secondaryTitleIcon' | 'trailing' | 'style'
1258
+ 'titleIcon' | 'secondaryTitleIcon' | 'titleImage' | 'trailing' | 'style'
1252
1259
  >,
1253
1260
  path: string
1254
1261
  ): void {
@@ -1279,6 +1286,30 @@ function assertIdentityExtras(
1279
1286
  if (titleIcon.hoverActionKey !== undefined)
1280
1287
  assertKey(titleIcon.hoverActionKey, `${iconPath}.hoverActionKey`);
1281
1288
  }
1289
+ if (row.titleImage !== undefined) {
1290
+ const imagePath = `${path}.titleImage`;
1291
+ const descriptor = protocolObject(row.titleImage, imagePath);
1292
+ assertUnknownKeys(
1293
+ descriptor,
1294
+ ['image', 'accessibilityLabel', 'hoverActionKey'],
1295
+ imagePath,
1296
+ 'title image key'
1297
+ );
1298
+ protocolObject(row.titleImage.image, `${imagePath}.image`);
1299
+ if (typeof row.titleImage.image.uri !== 'string')
1300
+ fail(`${imagePath}.image.uri`, 'must be a string');
1301
+ assertImage(row.titleImage.image, `${imagePath}.image`);
1302
+ const { width, height } = row.titleImage.image;
1303
+ if (width !== height || width < 1 || width > 40)
1304
+ fail(`${imagePath}.image`, 'must be square within 1...40');
1305
+ if (
1306
+ row.titleImage.accessibilityLabel !== undefined &&
1307
+ typeof row.titleImage.accessibilityLabel !== 'string'
1308
+ )
1309
+ fail(`${imagePath}.accessibilityLabel`, 'must be a string');
1310
+ if (row.titleImage.hoverActionKey !== undefined)
1311
+ assertKey(row.titleImage.hoverActionKey, `${imagePath}.hoverActionKey`);
1312
+ }
1282
1313
  const ratio = row.style?.leadingContentMaxWidthRatio;
1283
1314
  if (
1284
1315
  ratio !== undefined &&
@@ -1293,6 +1324,8 @@ function assertIdentityExtras(
1293
1324
 
1294
1325
  function assertImage(image: ImageSource | undefined, path: string): void {
1295
1326
  if (!image) return;
1327
+ if (!isAllowedImageUri(image.uri))
1328
+ fail(`${path}.uri`, 'unsupported image URI');
1296
1329
  if (!image.uri.trim()) fail(`${path}.uri`, 'must not be empty');
1297
1330
  if (image.fallbackUris !== undefined) {
1298
1331
  if (!Array.isArray(image.fallbackUris))
@@ -1634,6 +1667,7 @@ function assertRow(
1634
1667
  path = `rows[${index}]`
1635
1668
  ): void {
1636
1669
  assertRowType(row.type, path);
1670
+ assertUnknownKeys(row, ROW_FIELDS[row.type], path, 'row key');
1637
1671
  assertKey(row.key, `${path}.key`);
1638
1672
  // OneKey patch: explicit dimensions and opacity cannot corrupt list layout.
1639
1673
  if (row.height !== undefined && (row.height < 0 || row.height > 4096))
@@ -2250,6 +2284,338 @@ function assertSnapshotContract(
2250
2284
  return snapshot;
2251
2285
  }
2252
2286
 
2287
+ const COMMON_ROW_FIELDS = [
2288
+ 'height',
2289
+ 'heightRounding',
2290
+ 'testID',
2291
+ 'opacity',
2292
+ 'backgroundColor',
2293
+ 'backgroundFullWidth',
2294
+ 'pressDisabled',
2295
+ 'key',
2296
+ 'revision',
2297
+ 'sectionKey',
2298
+ 'groupId',
2299
+ 'groupPosition',
2300
+ 'size',
2301
+ 'density',
2302
+ 'disabled',
2303
+ 'selected',
2304
+ 'accessibilityLabel',
2305
+ 'separator',
2306
+ 'type',
2307
+ 'style',
2308
+ ];
2309
+
2310
+ const ROW_FIELDS: Readonly<Record<string, readonly string[]>> = {
2311
+ identity: [
2312
+ ...COMMON_ROW_FIELDS,
2313
+ 'contentHeight',
2314
+ 'longPressActionKey',
2315
+ 'titleMatch',
2316
+ 'titleActionKey',
2317
+ 'titleActionOnHover',
2318
+ 'subtitleSegments',
2319
+ 'presentation',
2320
+ 'leading',
2321
+ 'leadingAction',
2322
+ 'title',
2323
+ 'titleSuffixes',
2324
+ 'titleIcon',
2325
+ 'secondaryTitleIcon',
2326
+ 'titleImage',
2327
+ 'subtitle',
2328
+ 'tertiary',
2329
+ 'tertiaryTone',
2330
+ 'titleLines',
2331
+ 'subtitleLines',
2332
+ 'badges',
2333
+ 'trailing',
2334
+ 'draggable',
2335
+ ],
2336
+ walletGroup: [...COMMON_ROW_FIELDS, 'parent', 'children', 'draggable'],
2337
+ rail: [
2338
+ ...COMMON_ROW_FIELDS,
2339
+ 'visual',
2340
+ 'title',
2341
+ 'status',
2342
+ 'badge',
2343
+ 'draggable',
2344
+ ],
2345
+ activity: [
2346
+ ...COMMON_ROW_FIELDS,
2347
+ 'leading',
2348
+ 'hideLeading',
2349
+ 'secondaryLeading',
2350
+ 'title',
2351
+ 'description',
2352
+ 'status',
2353
+ 'primaryAmount',
2354
+ 'secondaryAmount',
2355
+ 'amounts',
2356
+ 'badges',
2357
+ 'presentation',
2358
+ 'fee',
2359
+ 'descriptionActionKey',
2360
+ 'footerActions',
2361
+ ],
2362
+ message: [
2363
+ ...COMMON_ROW_FIELDS,
2364
+ 'presentation',
2365
+ 'unread',
2366
+ 'leading',
2367
+ 'title',
2368
+ 'body',
2369
+ 'bodyLines',
2370
+ 'time',
2371
+ 'thumbnail',
2372
+ ],
2373
+ dataRow: [
2374
+ ...COMMON_ROW_FIELDS,
2375
+ 'leading',
2376
+ 'columns',
2377
+ 'trailing',
2378
+ 'checkbox',
2379
+ 'index',
2380
+ 'badge',
2381
+ 'badges',
2382
+ 'favorite',
2383
+ 'favoriteActive',
2384
+ ],
2385
+ market: [
2386
+ ...COMMON_ROW_FIELDS,
2387
+ 'variant',
2388
+ 'webTable',
2389
+ 'leadingAction',
2390
+ 'leading',
2391
+ 'title',
2392
+ 'subtitle',
2393
+ 'subtitlePrefix',
2394
+ 'subtitleSegments',
2395
+ 'price',
2396
+ 'priceSegments',
2397
+ 'change',
2398
+ 'badges',
2399
+ 'pressActionKey',
2400
+ 'pressInActionKey',
2401
+ 'longPressActionKey',
2402
+ 'diagnostics',
2403
+ ],
2404
+ mediaTile: [
2405
+ ...COMMON_ROW_FIELDS,
2406
+ 'variant',
2407
+ 'media',
2408
+ 'image',
2409
+ 'imageState',
2410
+ 'networkImage',
2411
+ 'title',
2412
+ 'subtitle',
2413
+ 'badge',
2414
+ 'closeActionKey',
2415
+ ],
2416
+ metricCard: [
2417
+ ...COMMON_ROW_FIELDS,
2418
+ 'variant',
2419
+ 'title',
2420
+ 'value',
2421
+ 'subtitle',
2422
+ 'trend',
2423
+ 'trendTone',
2424
+ 'visual',
2425
+ 'badge',
2426
+ 'metrics',
2427
+ 'progress',
2428
+ ],
2429
+ sectionHeader: [
2430
+ ...COMMON_ROW_FIELDS,
2431
+ 'sticky',
2432
+ 'valueActionTestID',
2433
+ 'valueSegments',
2434
+ 'titleActionKey',
2435
+ 'titleActionOnHover',
2436
+ 'presentation',
2437
+ 'indexTitle',
2438
+ 'variant',
2439
+ 'title',
2440
+ 'leadingBadge',
2441
+ 'titleLoading',
2442
+ 'subtitle',
2443
+ 'value',
2444
+ 'valueHelp',
2445
+ 'valueActionKey',
2446
+ 'titleIcon',
2447
+ 'valueIcon',
2448
+ 'checkbox',
2449
+ ],
2450
+ action: [
2451
+ ...COMMON_ROW_FIELDS,
2452
+ 'presentation',
2453
+ 'title',
2454
+ 'actionKey',
2455
+ 'tone',
2456
+ 'icon',
2457
+ 'checkbox',
2458
+ 'trailing',
2459
+ ],
2460
+ buttonGroup: [...COMMON_ROW_FIELDS, 'buttons'],
2461
+ system: [
2462
+ ...COMMON_ROW_FIELDS,
2463
+ 'variant',
2464
+ 'presentation',
2465
+ 'loadingStyle',
2466
+ 'message',
2467
+ 'actionKey',
2468
+ 'actionText',
2469
+ 'title',
2470
+ 'borderColor',
2471
+ ],
2472
+ };
2473
+
2474
+ const COMMON_PATCH_FIELDS = [
2475
+ 'height',
2476
+ 'heightRounding',
2477
+ 'opacity',
2478
+ 'pressDisabled',
2479
+ 'revision',
2480
+ 'disabled',
2481
+ 'selected',
2482
+ 'accessibilityLabel',
2483
+ 'separator',
2484
+ 'style',
2485
+ ];
2486
+
2487
+ const PATCH_FIELDS: Readonly<Record<string, readonly string[]>> = {
2488
+ identity: [
2489
+ ...COMMON_PATCH_FIELDS,
2490
+ 'contentHeight',
2491
+ 'longPressActionKey',
2492
+ 'titleMatch',
2493
+ 'titleActionKey',
2494
+ 'titleActionOnHover',
2495
+ 'subtitleSegments',
2496
+ 'leading',
2497
+ 'leadingAction',
2498
+ 'title',
2499
+ 'titleSuffixes',
2500
+ 'titleIcon',
2501
+ 'secondaryTitleIcon',
2502
+ 'titleImage',
2503
+ 'subtitle',
2504
+ 'tertiary',
2505
+ 'tertiaryTone',
2506
+ 'subtitleLines',
2507
+ 'badges',
2508
+ 'trailing',
2509
+ ],
2510
+ rail: [...COMMON_PATCH_FIELDS, 'title', 'visual', 'status', 'badge'],
2511
+ activity: [
2512
+ ...COMMON_PATCH_FIELDS,
2513
+ 'presentation',
2514
+ 'leading',
2515
+ 'title',
2516
+ 'badges',
2517
+ 'status',
2518
+ 'hideLeading',
2519
+ 'secondaryLeading',
2520
+ 'description',
2521
+ 'primaryAmount',
2522
+ 'secondaryAmount',
2523
+ 'amounts',
2524
+ 'fee',
2525
+ 'descriptionActionKey',
2526
+ 'footerActions',
2527
+ ],
2528
+ message: [
2529
+ ...COMMON_PATCH_FIELDS,
2530
+ 'presentation',
2531
+ 'title',
2532
+ 'unread',
2533
+ 'body',
2534
+ 'bodyLines',
2535
+ 'time',
2536
+ 'thumbnail',
2537
+ ],
2538
+ dataRow: [
2539
+ ...COMMON_PATCH_FIELDS,
2540
+ 'leading',
2541
+ 'badges',
2542
+ 'trailing',
2543
+ 'badge',
2544
+ 'columns',
2545
+ 'checkbox',
2546
+ 'index',
2547
+ 'favorite',
2548
+ 'favoriteActive',
2549
+ ],
2550
+ market: [
2551
+ ...COMMON_PATCH_FIELDS,
2552
+ 'longPressActionKey',
2553
+ 'subtitleSegments',
2554
+ 'leading',
2555
+ 'leadingAction',
2556
+ 'title',
2557
+ 'subtitle',
2558
+ 'badges',
2559
+ 'webTable',
2560
+ 'price',
2561
+ 'priceSegments',
2562
+ 'change',
2563
+ 'pressActionKey',
2564
+ 'pressInActionKey',
2565
+ 'diagnostics',
2566
+ ],
2567
+ mediaTile: [
2568
+ ...COMMON_PATCH_FIELDS,
2569
+ 'title',
2570
+ 'subtitle',
2571
+ 'badge',
2572
+ 'media',
2573
+ 'image',
2574
+ 'imageState',
2575
+ 'networkImage',
2576
+ 'closeActionKey',
2577
+ ],
2578
+ metricCard: [
2579
+ ...COMMON_PATCH_FIELDS,
2580
+ 'title',
2581
+ 'subtitle',
2582
+ 'visual',
2583
+ 'badge',
2584
+ 'variant',
2585
+ 'value',
2586
+ 'trend',
2587
+ 'trendTone',
2588
+ 'metrics',
2589
+ 'progress',
2590
+ ],
2591
+ sectionHeader: [
2592
+ ...COMMON_PATCH_FIELDS,
2593
+ 'titleActionKey',
2594
+ 'titleActionOnHover',
2595
+ 'title',
2596
+ 'titleIcon',
2597
+ 'subtitle',
2598
+ 'checkbox',
2599
+ 'variant',
2600
+ 'value',
2601
+ 'leadingBadge',
2602
+ 'titleLoading',
2603
+ 'valueHelp',
2604
+ 'valueActionKey',
2605
+ 'valueIcon',
2606
+ ],
2607
+ action: [
2608
+ ...COMMON_PATCH_FIELDS,
2609
+ 'title',
2610
+ 'trailing',
2611
+ 'checkbox',
2612
+ 'tone',
2613
+ 'icon',
2614
+ ],
2615
+ buttonGroup: [...COMMON_PATCH_FIELDS, 'buttons'],
2616
+ system: ['revision', 'disabled', 'message'],
2617
+ };
2618
+
2253
2619
  function protocolObject(value: unknown, path: string): Record<string, unknown> {
2254
2620
  if (!value || typeof value !== 'object' || Array.isArray(value))
2255
2621
  fail(path, 'must be an object');
@@ -2389,6 +2755,14 @@ function assertProtocolFields(
2389
2755
  fail(`${path}.uri`, 'must be a string');
2390
2756
  Object.entries(value).forEach(([field, item]) => {
2391
2757
  if (item === undefined) return;
2758
+ if (field === 'headers') {
2759
+ const headers = protocolObject(item, `${path}.headers`);
2760
+ for (const [name, headerValue] of Object.entries(headers)) {
2761
+ if (!name.trim() || typeof headerValue !== 'string')
2762
+ fail(`${path}.headers`, 'must contain string headers');
2763
+ }
2764
+ return;
2765
+ }
2392
2766
  const fieldPath = `${path}.${field}`;
2393
2767
  if (
2394
2768
  !path.endsWith('.style') &&
@@ -2418,6 +2792,7 @@ function assertProtocolFields(
2418
2792
  if (
2419
2793
  field === 'key' ||
2420
2794
  field === 'sectionKey' ||
2795
+ field === 'groupId' ||
2421
2796
  field === 'actionKey' ||
2422
2797
  field.endsWith('ActionKey')
2423
2798
  )
@@ -2466,6 +2841,7 @@ function assertRowProtocol(row: RowModel, path: string): void {
2466
2841
  protocolObject(row, path);
2467
2842
  assertRowType(row.type, path);
2468
2843
  protocolKey(row.key, `${path}.key`);
2844
+ assertCoreRow(row, path);
2469
2845
  assertProtocolFields(row, path);
2470
2846
  for (const field of ['leading', 'visual', 'secondaryLeading'] as const) {
2471
2847
  const visual = (row as unknown as Record<string, unknown>)[field];
@@ -2495,7 +2871,7 @@ function assertRowProtocol(row: RowModel, path: string): void {
2495
2871
  if (row.height !== undefined && row.height < 0)
2496
2872
  fail(`${path}.height`, 'must be non-negative');
2497
2873
  if (row.type === 'identity') {
2498
- assertIdentityExtras(row, path);
2874
+ if (isDevelopment()) assertIdentityExtras(row, path);
2499
2875
  if (row.titleSuffixes !== undefined && !Array.isArray(row.titleSuffixes))
2500
2876
  fail(`${path}.titleSuffixes`, 'must be an array');
2501
2877
  row.titleSuffixes?.forEach((suffix, index) => {
@@ -2529,8 +2905,9 @@ function assertRowProtocol(row: RowModel, path: string): void {
2529
2905
  end = match.end;
2530
2906
  });
2531
2907
  }
2532
- if (row.type === 'buttonGroup') assertButtonGroup(row, path);
2533
- if (row.type === 'sectionHeader')
2908
+ if (row.type === 'buttonGroup' && isDevelopment())
2909
+ assertButtonGroup(row, path);
2910
+ if (row.type === 'sectionHeader' && isDevelopment())
2534
2911
  assertTextHelp(row.valueHelp, `${path}.valueHelp`);
2535
2912
  if (row.type === 'mediaTile' && row.media) {
2536
2913
  protocolObject(row.media.source, `${path}.media.source`);
@@ -2558,9 +2935,10 @@ function assertRowProtocol(row: RowModel, path: string): void {
2558
2935
  row.columns.forEach((column, index) => {
2559
2936
  const columnPath = `${path}.columns[${index}]`;
2560
2937
  protocolObject(column, columnPath);
2561
- assertIdentityExtras(column, columnPath);
2938
+ if (isDevelopment()) assertIdentityExtras(column, columnPath);
2562
2939
  });
2563
- assertTrailingAccessories(row.trailing, `${path}.trailing`);
2940
+ if (isDevelopment())
2941
+ assertTrailingAccessories(row.trailing, `${path}.trailing`);
2564
2942
  }
2565
2943
  if (row.type === 'market') {
2566
2944
  protocolObject(row.change, `${path}.change`);
@@ -2696,19 +3074,780 @@ function assertSnapshotProtocol(snapshot: NativeListSnapshot): void {
2696
3074
  }
2697
3075
  }
2698
3076
 
2699
- /** Empty optional image descriptors must take the same path as absent images. */
3077
+ function isRecord(value: unknown): value is Record<string, unknown> {
3078
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
3079
+ }
3080
+
3081
+ function isAllowedImageUri(value: unknown): value is string {
3082
+ return (
3083
+ typeof value === 'string' &&
3084
+ value.trim().length > 0 &&
3085
+ (!/^[a-z][a-z\d+.-]*:/i.test(value) ||
3086
+ /^(https?|file|content|ph|assets-library|asset|res|blob):/i.test(value) ||
3087
+ /^data:image\//i.test(value))
3088
+ );
3089
+ }
3090
+
3091
+ const IMAGE_FIELDS = [
3092
+ 'uri',
3093
+ 'width',
3094
+ 'height',
3095
+ 'headers',
3096
+ 'contentFit',
3097
+ 'cachePolicy',
3098
+ 'autoplay',
3099
+ 'optimizeTos',
3100
+ 'overscan',
3101
+ 'loadingStrategy',
3102
+ 'fallbackUri',
3103
+ 'fallbackUris',
3104
+ 'retryTimes',
3105
+ ];
3106
+ const ICON_FIELDS = [
3107
+ 'name',
3108
+ 'tintColor',
3109
+ 'backgroundColor',
3110
+ 'size',
3111
+ 'accessibilityLabel',
3112
+ 'hoverActionKey',
3113
+ ];
3114
+ const VISUAL_FIELDS = [
3115
+ 'kind',
3116
+ 'name',
3117
+ 'image',
3118
+ 'images',
3119
+ 'networkImage',
3120
+ 'shape',
3121
+ 'tintColor',
3122
+ 'backgroundColor',
3123
+ 'fallbackIcon',
3124
+ 'fallbackText',
3125
+ 'fallbackTextColor',
3126
+ 'overlays',
3127
+ 'borderStyle',
3128
+ 'borderColor',
3129
+ 'cornerIcon',
3130
+ ];
3131
+ const TEXT_STYLE_FIELDS = [
3132
+ 'token',
3133
+ 'fontSize',
3134
+ 'fontWeight',
3135
+ 'color',
3136
+ 'lineHeight',
3137
+ 'lines',
3138
+ 'truncate',
3139
+ 'alignment',
3140
+ 'verticalAlignment',
3141
+ 'offsetY',
3142
+ ];
3143
+ const ACCESSORY_FIELDS: Readonly<Record<string, readonly string[]>> = {
3144
+ value: ['kind', 'text', 'secondary', 'textSegments'],
3145
+ valuePair: [
3146
+ 'kind',
3147
+ 'primary',
3148
+ 'secondary',
3149
+ 'primaryFontWeight',
3150
+ 'primaryHelp',
3151
+ 'primaryTextSegments',
3152
+ 'secondaryTextSegments',
3153
+ 'primaryTone',
3154
+ 'secondaryTone',
3155
+ ],
3156
+ button: [
3157
+ 'kind',
3158
+ 'label',
3159
+ 'actionKey',
3160
+ 'disabled',
3161
+ 'testID',
3162
+ 'accessibilityLabel',
3163
+ 'textColor',
3164
+ 'backgroundColor',
3165
+ 'pressedBackgroundColor',
3166
+ 'hoverBackgroundColor',
3167
+ 'focusRingColor',
3168
+ ],
3169
+ icon: [
3170
+ 'kind',
3171
+ 'name',
3172
+ 'tintColor',
3173
+ 'buttonStyle',
3174
+ 'disabled',
3175
+ 'actionKey',
3176
+ 'testID',
3177
+ 'hoverActionKey',
3178
+ 'accessibilityLabel',
3179
+ ],
3180
+ checkbox: ['kind', 'state', 'disabled', 'loading', 'target', 'actionKey'],
3181
+ radio: ['kind', 'checked', 'disabled', 'actionKey'],
3182
+ switch: ['kind', 'value', 'disabled', 'actionKey'],
3183
+ chevron: ['kind', 'actionKey'],
3184
+ menu: ['kind', 'actionKey'],
3185
+ drag: ['kind', 'actionKey'],
3186
+ spinner: ['kind'],
3187
+ progress: ['kind', 'value'],
3188
+ };
3189
+
3190
+ const REQUIRED_ROW_FIELDS: Readonly<Record<string, readonly string[]>> = {
3191
+ identity: ['title', 'leading'],
3192
+ walletGroup: ['parent', 'children'],
3193
+ rail: ['title', 'visual'],
3194
+ activity: ['title', 'leading'],
3195
+ message: ['title', 'body', 'time'],
3196
+ dataRow: ['columns'],
3197
+ market: ['title', 'leading', 'variant', 'price', 'change'],
3198
+ mediaTile: ['title', 'variant'],
3199
+ metricCard: ['title', 'value'],
3200
+ sectionHeader: ['title', 'sectionKey'],
3201
+ action: ['title', 'actionKey'],
3202
+ buttonGroup: ['buttons'],
3203
+ system: ['variant'],
3204
+ };
3205
+
3206
+ function assertCoreRow(row: RowModel, path: string): void {
3207
+ const object = row as unknown as Record<string, unknown>;
3208
+ for (const key of REQUIRED_ROW_FIELDS[row.type]) {
3209
+ const value = object[key];
3210
+ if (value === undefined || value === null)
3211
+ fail(`${path}.${key}`, 'required field');
3212
+ if (
3213
+ ['title', 'body', 'time', 'price', 'value', 'variant'].includes(key) &&
3214
+ typeof value !== 'string'
3215
+ )
3216
+ fail(`${path}.${key}`, 'must be a string');
3217
+ }
3218
+ if (row.type === 'market') {
3219
+ if (!row.price.trim()) fail(`${path}.price`, 'must be nonblank');
3220
+ if (typeof row.change?.text !== 'string')
3221
+ fail(`${path}.change.text`, 'must be a string');
3222
+ }
3223
+ if (row.type === 'activity') {
3224
+ row.amounts?.forEach((amount, index) => {
3225
+ protocolKey(amount.key, `${path}.amounts[${index}].key`);
3226
+ if (typeof amount.text !== 'string')
3227
+ fail(`${path}.amounts[${index}].text`, 'required financial text');
3228
+ });
3229
+ if (row.fee && typeof row.fee.primary !== 'string')
3230
+ fail(`${path}.fee.primary`, 'required financial text');
3231
+ row.footerActions?.forEach((action, index) => {
3232
+ protocolKey(action.key, `${path}.footerActions[${index}].key`);
3233
+ protocolKey(action.label, `${path}.footerActions[${index}].label`);
3234
+ });
3235
+ }
3236
+ if (row.type === 'action') protocolKey(row.actionKey, `${path}.actionKey`);
3237
+ if (row.type === 'sectionHeader')
3238
+ protocolKey(row.sectionKey, `${path}.sectionKey`);
3239
+ if (
3240
+ row.type === 'system' &&
3241
+ ['retry', 'warning', 'noMatch'].includes(row.variant)
3242
+ ) {
3243
+ if (typeof object.message !== 'string')
3244
+ fail(`${path}.message`, 'must be a string');
3245
+ if (row.variant === 'retry')
3246
+ protocolKey(row.actionKey, `${path}.actionKey`);
3247
+ }
3248
+ if (row.type === 'dataRow') {
3249
+ if (!Array.isArray(row.columns) || row.columns.length === 0)
3250
+ fail(`${path}.columns`, 'required columns');
3251
+ row.columns.forEach((column, index) => {
3252
+ protocolObject(column, `${path}.columns[${index}]`);
3253
+ protocolKey(column.key, `${path}.columns[${index}].key`);
3254
+ if (typeof column.text !== 'string')
3255
+ fail(`${path}.columns[${index}].text`, 'must be a string');
3256
+ });
3257
+ }
3258
+ if (row.type === 'buttonGroup') {
3259
+ if (!Array.isArray(row.buttons) || row.buttons.length === 0)
3260
+ fail(`${path}.buttons`, 'required buttons');
3261
+ const keys = new Set<string>();
3262
+ row.buttons.forEach((button, index) => {
3263
+ const field = `${path}.buttons[${index}]`;
3264
+ protocolObject(button, field);
3265
+ protocolKey(button.key, `${field}.key`);
3266
+ protocolKey(button.actionKey, `${field}.actionKey`);
3267
+ protocolKey(button.label, `${field}.label`);
3268
+ if (keys.has(button.key)) fail(field, 'duplicate button identity');
3269
+ keys.add(button.key);
3270
+ });
3271
+ }
3272
+ for (const field of [
3273
+ 'trailing',
3274
+ 'leadingAction',
3275
+ 'titleIcon',
3276
+ 'valueIcon',
3277
+ ] as const) {
3278
+ const value = object[field];
3279
+ if (value === undefined) continue;
3280
+ // Identity title icons are decorative descriptors, not trailing controls.
3281
+ if (field === 'titleIcon' && row.type !== 'sectionHeader') continue;
3282
+ const accessories = field === 'trailing' ? value : [value];
3283
+ if (!Array.isArray(accessories))
3284
+ fail(`${path}.${field}`, 'must be an array');
3285
+ accessories.forEach((accessory, index) => {
3286
+ const entry = protocolObject(accessory, `${path}.${field}[${index}]`);
3287
+ for (const key of entry.kind === 'value'
3288
+ ? ['text']
3289
+ : entry.kind === 'valuePair'
3290
+ ? ['primary', 'secondary']
3291
+ : []) {
3292
+ if (typeof entry[key] !== 'string')
3293
+ fail(`${path}.${field}[${index}].${key}`, 'required financial text');
3294
+ }
3295
+ if (
3296
+ ['button', 'menu', 'switch'].includes(entry.kind as string) ||
3297
+ 'actionKey' in entry
3298
+ ) {
3299
+ protocolKey(entry.actionKey, `${path}.${field}[${index}].actionKey`);
3300
+ if (entry.kind === 'button')
3301
+ protocolKey(entry.label, `${path}.${field}[${index}].label`);
3302
+ }
3303
+ if (entry.kind === 'icon')
3304
+ protocolKey(entry.name, `${path}.${field}[${index}].name`);
3305
+ });
3306
+ }
3307
+ }
3308
+
3309
+ function pickFields(
3310
+ object: Record<string, unknown>,
3311
+ fields: readonly string[]
3312
+ ): Record<string, unknown> {
3313
+ return Object.fromEntries(
3314
+ Object.entries(object).filter(([key]) => fields.includes(key))
3315
+ );
3316
+ }
3317
+
3318
+ function sanitizeImage(value: unknown): Record<string, unknown> | undefined {
3319
+ if (
3320
+ !isRecord(value) ||
3321
+ !isAllowedImageUri(value.uri) ||
3322
+ typeof value.width !== 'number' ||
3323
+ !Number.isFinite(value.width) ||
3324
+ value.width <= 0 ||
3325
+ typeof value.height !== 'number' ||
3326
+ !Number.isFinite(value.height) ||
3327
+ value.height <= 0
3328
+ )
3329
+ return undefined;
3330
+ const next = pickFields(value, IMAGE_FIELDS);
3331
+ next.width = Math.min(MAX_IMAGE_EDGE, value.width);
3332
+ next.height = Math.min(MAX_IMAGE_EDGE, value.height);
3333
+ if (next.headers !== undefined) {
3334
+ if (!isRecord(next.headers)) delete next.headers;
3335
+ else
3336
+ next.headers = Object.fromEntries(
3337
+ Object.entries(next.headers)
3338
+ .filter(
3339
+ ([key, item]) =>
3340
+ key.trim() &&
3341
+ key.length <= MAX_HEADER_LENGTH &&
3342
+ typeof item === 'string' &&
3343
+ item.length <= MAX_HEADER_LENGTH
3344
+ )
3345
+ .slice(0, MAX_IMAGE_HEADERS)
3346
+ );
3347
+ }
3348
+ if (next.fallbackUris !== undefined) {
3349
+ if (Array.isArray(next.fallbackUris))
3350
+ next.fallbackUris = next.fallbackUris.filter(isAllowedImageUri);
3351
+ else delete next.fallbackUris;
3352
+ }
3353
+ if (next.fallbackUri !== undefined && !isAllowedImageUri(next.fallbackUri))
3354
+ delete next.fallbackUri;
3355
+ if (
3356
+ next.overscan !== undefined &&
3357
+ (typeof next.overscan !== 'number' ||
3358
+ !Number.isFinite(next.overscan) ||
3359
+ next.overscan < 1 ||
3360
+ next.overscan > 4)
3361
+ )
3362
+ delete next.overscan;
3363
+ if (
3364
+ next.retryTimes !== undefined &&
3365
+ (typeof next.retryTimes !== 'number' ||
3366
+ !Number.isFinite(next.retryTimes) ||
3367
+ next.retryTimes < 0)
3368
+ )
3369
+ delete next.retryTimes;
3370
+ const options: Readonly<Record<string, readonly string[]>> = {
3371
+ contentFit: ['cover', 'contain', 'fill', 'center'],
3372
+ cachePolicy: ['memory-disk', 'memory', 'disk', 'none'],
3373
+ loadingStrategy: ['static', 'skeleton', 'none'],
3374
+ };
3375
+ for (const [key, allowed] of Object.entries(options))
3376
+ if (next[key] !== undefined && !allowed.includes(next[key] as string))
3377
+ delete next[key];
3378
+ for (const key of ['autoplay', 'optimizeTos'])
3379
+ if (next[key] !== undefined && typeof next[key] !== 'boolean')
3380
+ delete next[key];
3381
+ return next;
3382
+ }
3383
+
3384
+ const NESTED_DECORATION_FIELDS: Readonly<Record<string, readonly string[]>> = {
3385
+ webTable: ['columns', 'cells', 'testID', 'draggable', 'contextMenuActionKey'],
3386
+ columns: [
3387
+ 'key',
3388
+ 'text',
3389
+ 'textSegments',
3390
+ 'titleIcon',
3391
+ 'secondaryTitleIcon',
3392
+ 'secondaryLeadingText',
3393
+ 'secondaryText',
3394
+ 'secondaryTextSegments',
3395
+ 'weight',
3396
+ 'alignment',
3397
+ 'tone',
3398
+ 'secondaryTone',
3399
+ 'width',
3400
+ 'minWidth',
3401
+ 'flex',
3402
+ 'paddingStart',
3403
+ 'paddingEnd',
3404
+ ],
3405
+ cells: [
3406
+ 'key',
3407
+ 'kind',
3408
+ 'active',
3409
+ 'disabled',
3410
+ 'testID',
3411
+ 'hoverActionKey',
3412
+ 'actionKey',
3413
+ 'accessibilityLabel',
3414
+ 'leading',
3415
+ 'title',
3416
+ 'secondary',
3417
+ 'lineGap',
3418
+ 'secondaryBadges',
3419
+ 'hoverImages',
3420
+ 'hoverSecondary',
3421
+ 'hoverActionLabel',
3422
+ 'badges',
3423
+ 'primary',
3424
+ 'partGap',
3425
+ 'points',
3426
+ 'width',
3427
+ 'height',
3428
+ 'strokeColor',
3429
+ 'fillColor',
3430
+ ],
3431
+ badges: [
3432
+ 'key',
3433
+ 'text',
3434
+ 'tone',
3435
+ 'iconName',
3436
+ 'icon',
3437
+ 'textColor',
3438
+ 'backgroundColor',
3439
+ 'actionKey',
3440
+ 'accessibilityLabel',
3441
+ 'style',
3442
+ ],
3443
+ overlays: [
3444
+ 'position',
3445
+ 'size',
3446
+ 'width',
3447
+ 'height',
3448
+ 'padding',
3449
+ 'offset',
3450
+ 'offsetX',
3451
+ 'offsetY',
3452
+ 'image',
3453
+ 'fallbackIcon',
3454
+ 'name',
3455
+ 'text',
3456
+ 'fontWeight',
3457
+ 'tintColor',
3458
+ 'backgroundColor',
3459
+ ],
3460
+ primaryHelp: ['actionKey', 'presentation', 'underline'],
3461
+ valueHelp: ['actionKey', 'presentation', 'underline'],
3462
+ };
3463
+
3464
+ function sanitizeDecoration(value: unknown, field: string): unknown {
3465
+ if (value === undefined || value === null) return undefined;
3466
+ if (
3467
+ !Array.isArray(value) &&
3468
+ [
3469
+ 'image',
3470
+ 'networkImage',
3471
+ 'thumbnail',
3472
+ 'source',
3473
+ 'images',
3474
+ 'hoverImages',
3475
+ ].includes(field)
3476
+ )
3477
+ return sanitizeImage(value);
3478
+ if (
3479
+ [
3480
+ 'titleImage',
3481
+ 'titleIcon',
3482
+ 'secondaryTitleIcon',
3483
+ 'fallbackIcon',
3484
+ 'cornerIcon',
3485
+ 'media',
3486
+ ].includes(field) &&
3487
+ !isRecord(value)
3488
+ )
3489
+ return undefined;
3490
+ if (field === 'style' && typeof value !== 'string' && !isRecord(value))
3491
+ return undefined;
3492
+ if (
3493
+ ['badges', 'overlays'].includes(field) &&
3494
+ !Array.isArray(value) &&
3495
+ !isRecord(value)
3496
+ )
3497
+ return undefined;
3498
+ if (field === 'style' && typeof value === 'string')
3499
+ return value === 'subscript' ? value : undefined;
3500
+ if (Array.isArray(value)) {
3501
+ const items = value.map((item) => sanitizeDecoration(item, field));
3502
+ return field === 'hoverImages'
3503
+ ? items.map((item) => item ?? null)
3504
+ : items.filter((item) => item !== undefined);
3505
+ }
3506
+ if (!isRecord(value)) return value;
3507
+ let object = value;
3508
+ if (field === 'titleImage') {
3509
+ const image = sanitizeImage(value.image);
3510
+ if (!image) return undefined;
3511
+ const edge = Math.min(
3512
+ 40,
3513
+ Math.max(
3514
+ 1,
3515
+ Math.round(Math.min(image.width as number, image.height as number))
3516
+ )
3517
+ );
3518
+ object = {
3519
+ ...pickFields(value, ['image', 'accessibilityLabel', 'hoverActionKey']),
3520
+ image: { ...image, width: edge, height: edge },
3521
+ };
3522
+ } else if (
3523
+ ['titleIcon', 'secondaryTitleIcon', 'fallbackIcon', 'cornerIcon'].includes(
3524
+ field
3525
+ ) &&
3526
+ value.kind === undefined
3527
+ ) {
3528
+ if (typeof value.name !== 'string' || !value.name.trim()) return undefined;
3529
+ object = pickFields(value, ICON_FIELDS);
3530
+ } else if (['leading', 'visual', 'secondaryLeading'].includes(field)) {
3531
+ object = pickFields(value, VISUAL_FIELDS);
3532
+ } else if (field === 'media') {
3533
+ object = pickFields(value, ['source', 'probeOrder']);
3534
+ }
3535
+ if (
3536
+ field === 'trailing' ||
3537
+ (['leadingAction', 'titleIcon', 'valueIcon'].includes(field) &&
3538
+ object.kind !== undefined)
3539
+ ) {
3540
+ const fields = ACCESSORY_FIELDS[object.kind as string];
3541
+ if (fields) object = pickFields(object, fields);
3542
+ } else if (field === 'buttons')
3543
+ object = pickFields(object, [
3544
+ 'key',
3545
+ 'label',
3546
+ 'actionKey',
3547
+ 'disabled',
3548
+ 'loading',
3549
+ 'testID',
3550
+ 'accessibilityLabel',
3551
+ ]);
3552
+ else if (field === 'container')
3553
+ object = pickFields(object, [
3554
+ 'height',
3555
+ 'backgroundColor',
3556
+ 'opacity',
3557
+ 'cornerRadius',
3558
+ 'borderWidth',
3559
+ 'borderColor',
3560
+ 'contentVerticalAlignment',
3561
+ ]);
3562
+ else if (field === 'imageStyle')
3563
+ object = pickFields(object, [
3564
+ 'width',
3565
+ 'height',
3566
+ 'shape',
3567
+ 'cornerRadius',
3568
+ 'contentFit',
3569
+ ]);
3570
+ else if (field === 'style')
3571
+ object = pickFields(object, [
3572
+ ...TEXT_STYLE_FIELDS,
3573
+ 'container',
3574
+ ...new Set(Object.values(ROW_BOX_STYLE_KEYS_BY_TYPE).flat()),
3575
+ ...new Set(Object.values(TEXT_STYLE_KEYS_BY_ROW_TYPE).flat()),
3576
+ ...new Set(Object.values(EXTRA_STYLE_KEYS_BY_ROW_TYPE).flat()),
3577
+ 'height',
3578
+ ]);
3579
+ else if (
3580
+ [
3581
+ 'title',
3582
+ 'subtitle',
3583
+ 'tertiary',
3584
+ 'body',
3585
+ 'time',
3586
+ 'price',
3587
+ 'change',
3588
+ 'value',
3589
+ 'valueSecondary',
3590
+ 'columnSecondary',
3591
+ 'primaryAmount',
3592
+ 'secondaryAmount',
3593
+ 'description',
3594
+ 'status',
3595
+ 'trend',
3596
+ 'message',
3597
+ 'actionText',
3598
+ ].includes(field) &&
3599
+ !('text' in object)
3600
+ ) {
3601
+ object = pickFields(object, TEXT_STYLE_FIELDS);
3602
+ }
3603
+ if (NESTED_DECORATION_FIELDS[field])
3604
+ object = pickFields(object, NESTED_DECORATION_FIELDS[field]);
3605
+ if (
3606
+ [
3607
+ 'textSegments',
3608
+ 'primaryTextSegments',
3609
+ 'secondaryTextSegments',
3610
+ 'priceSegments',
3611
+ 'segments',
3612
+ ].includes(field)
3613
+ )
3614
+ object = pickFields(object, ['text', 'style']);
3615
+ if (field === 'subtitleSegments')
3616
+ object = pickFields(object, [
3617
+ 'text',
3618
+ 'style',
3619
+ 'textSegments',
3620
+ 'tone',
3621
+ 'separatorBefore',
3622
+ ]);
3623
+ const styleObject =
3624
+ ['style', 'imageStyle', 'container'].includes(field) ||
3625
+ TEXT_STYLE_FIELDS.some((key) => key in object);
3626
+ const next: Record<string, unknown> = {};
3627
+ for (const [key, item] of Object.entries(object)) {
3628
+ if (item === undefined || item === null) continue;
3629
+ if (
3630
+ (/color$/i.test(key) || /background$/i.test(key)) &&
3631
+ (typeof item !== 'string' || !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(item))
3632
+ )
3633
+ continue;
3634
+ if (
3635
+ /TextSegments$/.test(key) ||
3636
+ [
3637
+ 'textSegments',
3638
+ 'priceSegments',
3639
+ 'subtitleSegments',
3640
+ 'segments',
3641
+ ].includes(key)
3642
+ ) {
3643
+ if (
3644
+ !Array.isArray(item) ||
3645
+ item.some(
3646
+ (segment) => !isRecord(segment) || typeof segment.text !== 'string'
3647
+ )
3648
+ )
3649
+ continue;
3650
+ }
3651
+ if (
3652
+ styleObject &&
3653
+ PROTOCOL_NUMBER_FIELDS.has(key) &&
3654
+ typeof item !== 'number'
3655
+ )
3656
+ continue;
3657
+ if (
3658
+ key === 'token' &&
3659
+ (typeof item !== 'string' || !Object.hasOwn(TYPOGRAPHY_TOKENS, item))
3660
+ )
3661
+ continue;
3662
+ if (
3663
+ key === 'fontWeight' &&
3664
+ !['regular', 'medium', 'semibold', 'bold'].includes(item as string)
3665
+ )
3666
+ continue;
3667
+ if (
3668
+ [
3669
+ 'disabled',
3670
+ 'loading',
3671
+ 'checked',
3672
+ 'selected',
3673
+ 'separator',
3674
+ 'hideLeading',
3675
+ 'pressDisabled',
3676
+ 'draggable',
3677
+ 'active',
3678
+ 'autoplay',
3679
+ ].includes(key) &&
3680
+ typeof item !== 'boolean'
3681
+ )
3682
+ continue;
3683
+ if (key === 'primaryFontWeight' && item !== 'medium') continue;
3684
+ if (key === 'buttonStyle' && item !== 'tertiary') continue;
3685
+ if (key === 'hoverActionKey' && (typeof item !== 'string' || !item.trim()))
3686
+ continue;
3687
+ if (key === 'accessibilityLabel' && typeof item !== 'string') continue;
3688
+ if (
3689
+ (field === 'style' || PROTOCOL_NUMBER_FIELDS.has(key)) &&
3690
+ typeof item !== 'object' &&
3691
+ typeof item === 'number' &&
3692
+ (!Number.isFinite(item) ||
3693
+ (item < 0 && !['offsetX', 'offsetY'].includes(key)))
3694
+ )
3695
+ continue;
3696
+ if (
3697
+ field === 'style' &&
3698
+ PROTOCOL_NUMBER_FIELDS.has(key) &&
3699
+ typeof item !== 'number'
3700
+ )
3701
+ continue;
3702
+ if (
3703
+ key === 'leadingContentMaxWidthRatio' &&
3704
+ (typeof item !== 'number' || item <= 0 || item > 1)
3705
+ )
3706
+ continue;
3707
+ const normalized = sanitizeDecoration(
3708
+ item,
3709
+ field === 'style' && key === 'image' ? 'imageStyle' : key
3710
+ );
3711
+ if (normalized !== undefined) next[key] = normalized;
3712
+ }
3713
+ if (field === 'titleImage') {
3714
+ // The nested image was already normalized with title-specific geometry.
3715
+ if (!next.image) return undefined;
3716
+ }
3717
+ if (field === 'media' && (!next.source || !Array.isArray(next.probeOrder)))
3718
+ return undefined;
3719
+ if (['leading', 'visual', 'secondaryLeading'].includes(field)) {
3720
+ if (next.kind === 'image' && !next.image)
3721
+ return {
3722
+ kind: 'token',
3723
+ ...pickFields(next, [
3724
+ 'fallbackIcon',
3725
+ 'fallbackText',
3726
+ 'fallbackTextColor',
3727
+ ]),
3728
+ };
3729
+ if (
3730
+ next.kind === 'stackedImages' &&
3731
+ (!Array.isArray(next.images) || next.images.length < 2)
3732
+ )
3733
+ return { kind: 'token' };
3734
+ if (
3735
+ next.kind === 'icon' &&
3736
+ (typeof next.name !== 'string' || !next.name.trim())
3737
+ )
3738
+ return { kind: 'token' };
3739
+ if (
3740
+ ![
3741
+ 'token',
3742
+ 'account',
3743
+ 'wallet',
3744
+ 'network',
3745
+ 'image',
3746
+ 'icon',
3747
+ 'stackedImages',
3748
+ ].includes(next.kind as string)
3749
+ )
3750
+ return { kind: 'token' };
3751
+ }
3752
+ if (
3753
+ field === 'overlays' &&
3754
+ (!['topLeft', 'bottomRight'].includes(next.position as string) ||
3755
+ (!next.image && !next.name && !next.text))
3756
+ )
3757
+ return undefined;
3758
+ if (
3759
+ field === 'trailing' &&
3760
+ next.kind === 'icon' &&
3761
+ !('actionKey' in value) &&
3762
+ (typeof next.name !== 'string' || !next.name.trim())
3763
+ )
3764
+ return undefined;
3765
+ if (
3766
+ field === 'trailing' &&
3767
+ ![
3768
+ 'value',
3769
+ 'valuePair',
3770
+ 'button',
3771
+ 'checkbox',
3772
+ 'radio',
3773
+ 'switch',
3774
+ 'chevron',
3775
+ 'menu',
3776
+ 'drag',
3777
+ 'icon',
3778
+ 'spinner',
3779
+ 'progress',
3780
+ ].includes(next.kind as string) &&
3781
+ !('actionKey' in value)
3782
+ )
3783
+ return undefined;
3784
+ if (
3785
+ field === 'badges' &&
3786
+ (typeof next.key !== 'string' ||
3787
+ (!next.text && !next.iconName && !next.icon))
3788
+ )
3789
+ return undefined;
3790
+ if (next.kind === 'sparkline') {
3791
+ const points = Array.isArray(value.points)
3792
+ ? value.points
3793
+ .filter(
3794
+ (point) => typeof point === 'number' && Number.isFinite(point)
3795
+ )
3796
+ .slice(0, 256)
3797
+ : [];
3798
+ // Preserve the table's column identity even when its optional drawing fails.
3799
+ return points.length < 2
3800
+ ? { key: next.key, kind: 'text', primary: [] }
3801
+ : { ...next, points };
3802
+ }
3803
+ return next;
3804
+ }
3805
+
3806
+ function sanitizeRowForProduction(row: RowModel): RowModel {
3807
+ const object = protocolObject(row, 'row');
3808
+ const fields = ROW_FIELDS[row.type];
3809
+ if (!fields) return row;
3810
+ const next = pickFields(object, fields);
3811
+ for (const [field, value] of Object.entries(next)) {
3812
+ if (
3813
+ (/color$/i.test(field) || /background$/i.test(field)) &&
3814
+ (typeof value !== 'string' ||
3815
+ !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value))
3816
+ ) {
3817
+ delete next[field];
3818
+ continue;
3819
+ }
3820
+ if (field === 'parent' && isRecord(value))
3821
+ next[field] = sanitizeRowForProduction(value as unknown as RowModel);
3822
+ else if (field === 'children' && Array.isArray(value))
3823
+ next[field] = value.map((child) =>
3824
+ sanitizeRowForProduction(child as RowModel)
3825
+ );
3826
+ else {
3827
+ const normalized = sanitizeDecoration(value, field);
3828
+ if (normalized === undefined) delete next[field];
3829
+ else next[field] = normalized;
3830
+ }
3831
+ }
3832
+ if (isRecord(next.style))
3833
+ next.style = pickFields(next.style, [
3834
+ 'container',
3835
+ ...ROW_BOX_STYLE_KEYS_BY_TYPE[row.type],
3836
+ ...(TEXT_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []),
3837
+ ...(EXTRA_STYLE_KEYS_BY_ROW_TYPE[row.type] ?? []),
3838
+ ]);
3839
+ return sameJsonValue(row, next) ? row : (next as unknown as RowModel);
3840
+ }
3841
+
3842
+ /** Empty images and optional nulls share one meaning before strict assertions. */
2700
3843
  function normalizeEmptyImages(value: unknown, field = ''): unknown {
2701
- if (!value || typeof value !== 'object') return value;
3844
+ if (value === null || value === undefined || typeof value !== 'object')
3845
+ return value;
2702
3846
  if (Array.isArray(value)) {
2703
- const next = value.map((item) => normalizeEmptyImages(item));
2704
- const filtered =
2705
- field === 'images'
2706
- ? next.filter((item) => item !== undefined)
2707
- : next.map((item) => (item === undefined ? null : item));
2708
- return filtered.length === value.length &&
2709
- filtered.every((item, index) => item === value[index])
3847
+ const next = value.map((item) => normalizeEmptyImages(item, field));
3848
+ return next.every((item, index) => item === value[index])
2710
3849
  ? value
2711
- : filtered;
3850
+ : next.filter((item) => item !== undefined);
2712
3851
  }
2713
3852
  const object = value as Record<string, unknown>;
2714
3853
  if (
@@ -2719,30 +3858,146 @@ function normalizeEmptyImages(value: unknown, field = ''): unknown {
2719
3858
  )
2720
3859
  return undefined;
2721
3860
  let changed = false;
2722
- const next = { ...object };
2723
- Object.entries(object).forEach(([key, item]) => {
2724
- const normalized = normalizeEmptyImages(item, key);
2725
- if (normalized !== item) {
2726
- // Omitted JSON patch fields must also be absent from the JS merge.
2727
- if (field === 'changes' && normalized === undefined) delete next[key];
2728
- else next[key] = normalized;
3861
+ const next: Record<string, unknown> = {};
3862
+ for (const [key, item] of Object.entries(object)) {
3863
+ const optional =
3864
+ object.type &&
3865
+ ![
3866
+ 'key',
3867
+ 'type',
3868
+ ...(REQUIRED_ROW_FIELDS[object.type as string] ?? []),
3869
+ ].includes(key);
3870
+ if (
3871
+ item === undefined ||
3872
+ (item === null && optional) ||
3873
+ (['priceSegments', 'subtitleSegments'].includes(key) &&
3874
+ Array.isArray(item) &&
3875
+ item.length === 0)
3876
+ ) {
2729
3877
  changed = true;
3878
+ continue;
2730
3879
  }
2731
- });
2732
- if (field === 'media' && next.source === undefined) return undefined;
3880
+ const normalized =
3881
+ field === 'headers' ? item : normalizeEmptyImages(item, key);
3882
+ if (normalized !== undefined) next[key] = normalized;
3883
+ if (normalized !== item) changed = true;
3884
+ }
3885
+ if (
3886
+ (field === 'media' &&
3887
+ object.source !== undefined &&
3888
+ next.source === undefined) ||
3889
+ (field === 'titleImage' &&
3890
+ object.image !== undefined &&
3891
+ next.image === undefined)
3892
+ )
3893
+ return undefined;
3894
+ if (
3895
+ ['leading', 'visual', 'secondaryLeading'].includes(field) &&
3896
+ next.kind === 'image' &&
3897
+ !next.image
3898
+ )
3899
+ return { kind: 'token' };
3900
+ if (field === 'overlays' && object.image !== undefined && !next.image)
3901
+ return undefined;
2733
3902
  return changed ? next : value;
2734
3903
  }
2735
3904
 
3905
+ function normalizeSnapshotForProduction(
3906
+ snapshot: NativeListSnapshot
3907
+ ): NativeListSnapshot {
3908
+ if (!Array.isArray(snapshot.rows)) return snapshot;
3909
+ let rows = snapshot.rows.map(sanitizeRowForProduction);
3910
+ rows = rows.map((row, index) => {
3911
+ if (!row.groupId) return row;
3912
+ const previous = rows[index - 1]?.groupId === row.groupId;
3913
+ const following = rows[index + 1]?.groupId === row.groupId;
3914
+ return {
3915
+ ...row,
3916
+ groupPosition: previous
3917
+ ? following
3918
+ ? 'middle'
3919
+ : 'last'
3920
+ : following
3921
+ ? 'first'
3922
+ : 'single',
3923
+ };
3924
+ });
3925
+ const next = {
3926
+ ...snapshot,
3927
+ rows,
3928
+ ...(snapshot.emptyState
3929
+ ? {
3930
+ emptyState: sanitizeRowForProduction(
3931
+ snapshot.emptyState
3932
+ ) as NativeListSnapshot['emptyState'],
3933
+ }
3934
+ : {}),
3935
+ ...(snapshot.fixedFooter
3936
+ ? {
3937
+ fixedFooter: sanitizeRowForProduction(
3938
+ snapshot.fixedFooter
3939
+ ) as NativeListSnapshot['fixedFooter'],
3940
+ }
3941
+ : {}),
3942
+ };
3943
+ if (snapshot.theme)
3944
+ next.theme = sanitizeDecoration(
3945
+ snapshot.theme,
3946
+ 'theme'
3947
+ ) as NativeListSnapshot['theme'];
3948
+ if (snapshot.listStyle)
3949
+ next.listStyle = sanitizeDecoration(
3950
+ snapshot.listStyle,
3951
+ 'style'
3952
+ ) as NativeListSnapshot['listStyle'];
3953
+ if (snapshot.selection && Array.isArray(snapshot.selection.selectedKeys)) {
3954
+ const selectable = new Set(
3955
+ rows
3956
+ .filter(
3957
+ (row) =>
3958
+ !row.disabled &&
3959
+ ![
3960
+ 'sectionHeader',
3961
+ 'action',
3962
+ 'system',
3963
+ 'buttonGroup',
3964
+ 'market',
3965
+ 'walletGroup',
3966
+ ].includes(row.type)
3967
+ )
3968
+ .map((row) => row.key)
3969
+ );
3970
+ const selectedKeys = [
3971
+ ...new Set(
3972
+ snapshot.selection.selectedKeys.filter((key) => selectable.has(key))
3973
+ ),
3974
+ ];
3975
+ next.selection = {
3976
+ ...snapshot.selection,
3977
+ selectedKeys:
3978
+ snapshot.selection.mode === 'none'
3979
+ ? []
3980
+ : snapshot.selection.mode === 'single'
3981
+ ? selectedKeys.slice(0, 1)
3982
+ : selectedKeys,
3983
+ };
3984
+ }
3985
+ return sameJsonValue(snapshot, next) ? snapshot : next;
3986
+ }
3987
+
2736
3988
  export function validateSnapshot(
2737
3989
  snapshot: NativeListSnapshot
2738
3990
  ): NativeListSnapshot {
2739
- assertPlainSerializable(snapshot, 'snapshot', new Set<object>());
2740
- if (isDevelopment()) assertSnapshotContract(snapshot);
2741
- const normalized = isDevelopment()
2742
- ? snapshot
2743
- : (normalizeEmptyImages(snapshot) as NativeListSnapshot);
2744
- assertSnapshotProtocol(normalized);
2745
- return normalizeSnapshotStyles(normalized);
3991
+ assertPlainSerializable(snapshot, 'snapshot', new Set<object>(), false);
3992
+ protocolObject(snapshot, 'snapshot');
3993
+ const normalized = normalizeEmptyImages(snapshot) as NativeListSnapshot;
3994
+ const canonical = isDevelopment()
3995
+ ? normalized
3996
+ : normalizeSnapshotForProduction(normalized);
3997
+ assertPlainSerializable(canonical, 'snapshot', new Set<object>());
3998
+ if (isDevelopment()) assertSnapshotContract(canonical);
3999
+ assertSnapshotProtocol(canonical);
4000
+ return normalizeSnapshotStyles(canonical);
2746
4001
  }
2747
4002
 
2748
4003
  const ownedNativeSnapshots = new WeakMap<
@@ -2838,10 +4093,23 @@ function normalizeSnapshotStyles(
2838
4093
  ) {
2839
4094
  return snapshot;
2840
4095
  }
2841
- return { ...snapshot, rows, emptyState, fixedFooter };
4096
+ return {
4097
+ ...snapshot,
4098
+ rows,
4099
+ ...(emptyState !== undefined ? { emptyState } : {}),
4100
+ ...(fixedFooter !== undefined ? { fixedFooter } : {}),
4101
+ };
2842
4102
  }
2843
4103
 
2844
- function assertPatchChanges(patch: RowPatch, index: number): void {
4104
+ type PatchValues<Patch = RowPatch> = Patch extends RowPatch
4105
+ ? Omit<Patch, 'changes'> & {
4106
+ changes: {
4107
+ [Key in keyof Patch['changes']]: Exclude<Patch['changes'][Key], null>;
4108
+ };
4109
+ }
4110
+ : never;
4111
+
4112
+ function assertPatchChanges(patch: PatchValues, index: number): void {
2845
4113
  const path = `patches[${index}].changes`;
2846
4114
  const patchedStyle = (patch.changes as { style?: unknown }).style;
2847
4115
  if (patchedStyle !== undefined) {
@@ -3135,36 +4403,150 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
3135
4403
  }
3136
4404
  }
3137
4405
 
4406
+ function normalizePatch(patch: RowPatch, index: number): RowPatch | undefined {
4407
+ const path = `patches[${index}]`;
4408
+ protocolObject(patch, path);
4409
+ protocolKey(patch.key, `${path}.key`);
4410
+ if (isDevelopment()) assertKey(patch.key, `${path}.key`);
4411
+ assertRowType(patch.type, path);
4412
+ const raw = protocolObject(patch.changes, `${path}.changes`);
4413
+ assertPlainSerializable(raw, `${path}.changes`, new Set<object>(), false);
4414
+ const fields = PATCH_FIELDS[patch.type];
4415
+ const changes: Record<string, unknown> = {};
4416
+ for (const [field, value] of Object.entries(raw)) {
4417
+ if (value === undefined) continue;
4418
+ if (!fields?.includes(field)) {
4419
+ if (isDevelopment())
4420
+ fail(`${path}.changes.${field}`, 'unsupported patch field');
4421
+ continue;
4422
+ }
4423
+ if (value === null) {
4424
+ if (
4425
+ REQUIRED_ROW_FIELDS[patch.type].includes(field) ||
4426
+ (patch.type === 'system' && field === 'message')
4427
+ ) {
4428
+ // The concrete system variant is checked after merging with its baseline.
4429
+ if (patch.type !== 'system')
4430
+ fail(`${path}.changes.${field}`, 'required field cannot be cleared');
4431
+ }
4432
+ changes[field] = null;
4433
+ continue;
4434
+ }
4435
+ if (
4436
+ ['priceSegments', 'subtitleSegments'].includes(field) &&
4437
+ Array.isArray(value) &&
4438
+ value.length === 0
4439
+ ) {
4440
+ changes[field] = null;
4441
+ continue;
4442
+ }
4443
+ const normalized = normalizeEmptyImages(value, field);
4444
+ if (normalized === undefined) {
4445
+ if (!REQUIRED_ROW_FIELDS[patch.type].includes(field))
4446
+ changes[field] = null;
4447
+ continue;
4448
+ }
4449
+ const canonical = isDevelopment()
4450
+ ? normalized
4451
+ : sanitizeDecoration(normalized, field);
4452
+ if (canonical !== undefined) changes[field] = canonical;
4453
+ else if (field === 'titleImage') changes[field] = null;
4454
+ }
4455
+ if (
4456
+ patch.type === 'market' &&
4457
+ Array.isArray(changes.priceSegments) &&
4458
+ changes.priceSegments.length > 0 &&
4459
+ (typeof changes.price !== 'string' || !changes.price.trim())
4460
+ )
4461
+ fail(`${path}.changes.price`, 'is required when priceSegments is updated');
4462
+ if (!isDevelopment()) {
4463
+ const sanitized = sanitizeRowForProduction({
4464
+ type: patch.type,
4465
+ ...changes,
4466
+ } as unknown as RowModel) as unknown as Record<string, unknown>;
4467
+ for (const [field, value] of Object.entries(changes)) {
4468
+ if (value === null) continue;
4469
+ if (sanitized[field] === undefined) delete changes[field];
4470
+ else changes[field] = sanitized[field];
4471
+ }
4472
+ }
4473
+ const values = Object.fromEntries(
4474
+ Object.entries(changes).filter(([, value]) => value !== null)
4475
+ );
4476
+ assertPlainSerializable(values, `${path}.changes`, new Set<object>());
4477
+ assertProtocolFields(values, `${path}.changes`);
4478
+ if (isDevelopment())
4479
+ assertPatchChanges({ ...patch, changes: values } as PatchValues, index);
4480
+ const normalizedPatch = {
4481
+ type: patch.type,
4482
+ key: patch.key,
4483
+ changes,
4484
+ } as RowPatch;
4485
+ return Object.keys(changes).length > 0
4486
+ ? normalizePatchStyles([normalizedPatch])[0]
4487
+ : undefined;
4488
+ }
4489
+
4490
+ function reportRejectedPatch(index: number): void {
4491
+ console.warn(`NativeList rejected invalid patch unit at patches[${index}]`);
4492
+ }
4493
+
3138
4494
  export function validatePatches(
3139
4495
  patches: readonly RowPatch[]
3140
4496
  ): readonly RowPatch[] {
3141
- assertPlainSerializable(patches, 'patches', new Set<object>());
3142
4497
  if (!Array.isArray(patches)) fail('patches', 'must be an array');
3143
- const keys = new Set<string>();
3144
- for (const [index, patch] of patches.entries()) {
3145
- protocolObject(patch, `patches[${index}]`);
3146
- protocolKey(patch.key, `patches[${index}].key`);
3147
- if (keys.has(patch.key))
3148
- fail(`patches[${index}].key`, `duplicate key "${patch.key}"`);
3149
- keys.add(patch.key);
3150
- protocolObject(patch.changes, `patches[${index}].changes`);
3151
- assertRowType(patch.type, `patches[${index}]`);
3152
- assertProtocolFields(patch.changes, `patches[${index}].changes`);
3153
- if (patch.type === 'identity')
3154
- assertIdentityExtras(patch.changes, `patches[${index}].changes`);
3155
- if (!isDevelopment()) continue;
3156
- assertKey(patch.key, `patches[${index}].key`);
3157
- if (Object.keys(patch.changes).length === 0) {
3158
- fail(`patches[${index}].changes`, 'must contain at least one field');
4498
+ const units = new Map<string, { patch: RowPatch; index: number }>();
4499
+ const rejectedKeys = new Set<string>();
4500
+ const reject = (key: string, index: number, error: unknown) => {
4501
+ if (isDevelopment()) throw error;
4502
+ rejectedKeys.add(key);
4503
+ reportRejectedPatch(index);
4504
+ };
4505
+ patches.forEach((input, index) => {
4506
+ const key =
4507
+ isRecord(input) && typeof input.key === 'string' ? input.key : '';
4508
+ try {
4509
+ protocolObject(input, `patches[${index}]`);
4510
+ protocolKey(input.key, `patches[${index}].key`);
4511
+ assertRowType(input.type, `patches[${index}]`);
4512
+ const changes = capturePlainData(
4513
+ protocolObject(input.changes, `patches[${index}].changes`)
4514
+ );
4515
+ assertPlainSerializable(
4516
+ changes,
4517
+ `patches[${index}].changes`,
4518
+ new Set<object>(),
4519
+ false
4520
+ );
4521
+ const previous = units.get(key);
4522
+ if (previous && previous.patch.type !== input.type)
4523
+ fail(`patches[${index}].type`, 'conflicting row types');
4524
+ const defined = Object.fromEntries(
4525
+ Object.entries(changes).filter(([, value]) => value !== undefined)
4526
+ );
4527
+ units.set(key, {
4528
+ index,
4529
+ patch: {
4530
+ key,
4531
+ type: input.type,
4532
+ changes: { ...previous?.patch.changes, ...defined },
4533
+ } as RowPatch,
4534
+ });
4535
+ } catch (error) {
4536
+ reject(key, index, error);
4537
+ }
4538
+ });
4539
+ const accepted: RowPatch[] = [];
4540
+ for (const [key, unit] of units) {
4541
+ if (rejectedKeys.has(key)) continue;
4542
+ try {
4543
+ const patch = normalizePatch(unit.patch, unit.index);
4544
+ if (patch) accepted.push(patch);
4545
+ } catch (error) {
4546
+ reject(key, unit.index, error);
3159
4547
  }
3160
- assertPatchChanges(patch, index);
3161
4548
  }
3162
- const normalized = isDevelopment()
3163
- ? patches
3164
- : (normalizeEmptyImages(patches) as readonly RowPatch[]);
3165
- return normalizePatchStyles(
3166
- normalized.filter((patch) => Object.keys(patch.changes).length > 0)
3167
- );
4549
+ return accepted;
3168
4550
  }
3169
4551
 
3170
4552
  /** Patches reach the native side without passing through a snapshot. */
@@ -3296,50 +4678,65 @@ export function planNativeListPatches(
3296
4678
  return patches.length === 0 ? EMPTY_ROW_PATCHES : patches;
3297
4679
  }
3298
4680
 
3299
- export function applyRowPatches(
3300
- snapshot: NativeListSnapshot,
3301
- patches: readonly RowPatch[]
3302
- ): NativeListSnapshot {
3303
- const normalizedPatches = validatePatches(patches);
3304
- const patchByKey = new Map(
3305
- normalizedPatches.map((patch) => [patch.key, patch])
3306
- );
3307
- const knownKeys = new Set(snapshot.rows.map((row) => row.key));
3308
- normalizedPatches.forEach((patch) => {
3309
- if (!knownKeys.has(patch.key))
3310
- fail('patches', `unknown row key "${patch.key}"`);
3311
- });
3312
- const rows = snapshot.rows.map((row): RowModel => {
3313
- const patch = patchByKey.get(row.key);
3314
- if (!patch) return row;
3315
- if (patch.type !== row.type) {
3316
- fail('patches', `row "${row.key}" is ${row.type}, not ${patch.type}`);
3317
- }
4681
+ function mergeAndValidateSingleRow(
4682
+ previous: RowModel,
4683
+ patch: RowPatch,
4684
+ index: number
4685
+ ): { row: RowModel; patch: RowPatch } {
4686
+ if (previous.key !== patch.key)
4687
+ fail(`patches[${index}].key`, 'does not match row identity');
4688
+ if (previous.type !== patch.type)
4689
+ fail(`patches[${index}].type`, 'does not match row type');
4690
+ const changes = { ...patch.changes } as Record<string, unknown>;
4691
+ if (previous.type === 'market') {
3318
4692
  if (
3319
- row.type === 'market' &&
3320
- row.webTable &&
3321
- patch.type === 'market' &&
3322
- (patch.changes.price !== undefined ||
3323
- patch.changes.priceSegments !== undefined ||
3324
- patch.changes.change !== undefined) &&
3325
- patch.changes.webTable === undefined
4693
+ typeof changes.price === 'string' &&
4694
+ !('priceSegments' in changes) &&
4695
+ previous.priceSegments !== undefined
4696
+ )
4697
+ changes.priceSegments = null;
4698
+ if (
4699
+ (previous.webTable !== undefined || 'webTable' in changes) &&
4700
+ ['price', 'priceSegments', 'change'].some((field) => field in changes) &&
4701
+ changes.webTable === undefined
3326
4702
  )
3327
4703
  fail(
3328
- 'patches',
3329
- 'table quote changes must include the visible table cells'
4704
+ `patches[${index}].changes.webTable`,
4705
+ 'table quote changes require visible table cells'
3330
4706
  );
3331
- return {
3332
- ...row,
3333
- ...patch.changes,
3334
- key: row.key,
3335
- type: row.type,
3336
- } as RowModel;
3337
- });
3338
- return validateSnapshot({ ...snapshot, rows });
4707
+ }
4708
+ if (previous.type === 'identity' || previous.type === 'market') {
4709
+ if (
4710
+ typeof changes.subtitle === 'string' &&
4711
+ !('subtitleSegments' in changes) &&
4712
+ previous.subtitleSegments !== undefined
4713
+ )
4714
+ changes.subtitleSegments = null;
4715
+ if (
4716
+ Array.isArray(changes.subtitleSegments) &&
4717
+ changes.subtitleSegments.length > 0 &&
4718
+ !('subtitle' in changes) &&
4719
+ previous.subtitle !== undefined
4720
+ )
4721
+ changes.subtitle = null;
4722
+ }
4723
+ const merged = { ...previous } as unknown as Record<string, unknown>;
4724
+ for (const [field, value] of Object.entries(changes)) {
4725
+ if (value === null) delete merged[field];
4726
+ else if (value !== undefined) merged[field] = value;
4727
+ }
4728
+ const next = normalizeRowStyles(merged as unknown as RowModel);
4729
+ assertPlainSerializable(next, `rows[${index}]`, new Set<object>());
4730
+ if (isDevelopment()) assertRow(next, index);
4731
+ assertRowProtocol(next, `rows[${index}]`);
4732
+ return {
4733
+ row: next,
4734
+ patch: { type: patch.type, key: patch.key, changes } as RowPatch,
4735
+ };
3339
4736
  }
3340
4737
 
3341
- /** Native wrapper only: preserve full public validation for unsupported patches. */
3342
- export function applyOwnedNativePatches(
4738
+ /** One canonical batch owns both accepted rows and the native wire payload. */
4739
+ export function applyValidatedRowPatches(
3343
4740
  snapshot: NativeListSnapshot,
3344
4741
  patches: readonly RowPatch[]
3345
4742
  ): {
@@ -3347,74 +4744,127 @@ export function applyOwnedNativePatches(
3347
4744
  snapshot: NativeListSnapshot;
3348
4745
  json: string;
3349
4746
  } {
3350
- const acceptedPatches = deepFreeze(
3351
- validatePatches(capturePlainData(patches))
3352
- );
3353
- const rowIndexes = ownedNativeSnapshots.get(snapshot);
3354
- const canPatchRows =
3355
- rowIndexes !== undefined &&
3356
- acceptedPatches.every((patch) => {
3357
- const index = rowIndexes.get(patch.key);
3358
- if (index === undefined || snapshot.rows[index].type !== patch.type)
3359
- return false;
3360
- const allowed =
3361
- patch.type === 'identity'
3362
- ? ['subtitle', 'subtitleSegments', 'trailing']
3363
- : patch.type === 'dataRow'
3364
- ? ['columns', 'trailing']
3365
- : undefined;
3366
- return (
3367
- allowed !== undefined &&
3368
- Object.entries(patch.changes).every(
3369
- ([key, value]) =>
3370
- allowed.includes(key) &&
3371
- (value !== undefined ||
3372
- (snapshot.rows[index] as unknown as Record<string, unknown>)[
3373
- key
3374
- ] === undefined)
3375
- )
4747
+ if (!Array.isArray(patches)) fail('patches', 'must be an array');
4748
+ const owned = ownedNativeSnapshots.has(snapshot);
4749
+ const rowIndexes =
4750
+ ownedNativeSnapshots.get(snapshot) ??
4751
+ validatedRowIndexes.get(snapshot) ??
4752
+ new Map(snapshot.rows.map((row, index) => [row.key, index]));
4753
+ validatedRowIndexes.set(snapshot, rowIndexes);
4754
+ const units = new Map<
4755
+ string,
4756
+ { index: number; changes: Record<string, unknown>; type: RowPatch['type'] }
4757
+ >();
4758
+ const rejected = new Set<string>();
4759
+ const rejectedGroups = new Set<string>();
4760
+ const reject = (key: string, index: number, error: unknown) => {
4761
+ if (isDevelopment()) throw error;
4762
+ rejected.add(key);
4763
+ const rowIndex = rowIndexes.get(key);
4764
+ const group =
4765
+ rowIndex === undefined ? undefined : snapshot.rows[rowIndex].groupId;
4766
+ if (group) rejectedGroups.add(group);
4767
+ reportRejectedPatch(index);
4768
+ };
4769
+ patches.forEach((input, index) => {
4770
+ const key =
4771
+ isRecord(input) && typeof input.key === 'string' ? input.key : '';
4772
+ try {
4773
+ protocolObject(input, `patches[${index}]`);
4774
+ protocolKey(input.key, `patches[${index}].key`);
4775
+ const rowIndex = rowIndexes.get(input.key);
4776
+ if (rowIndex === undefined)
4777
+ fail(`patches[${index}].key`, 'unknown row identity');
4778
+ if (snapshot.rows[rowIndex].type !== input.type)
4779
+ fail(`patches[${index}].type`, 'does not match row type');
4780
+ const changes = protocolObject(
4781
+ input.changes,
4782
+ `patches[${index}].changes`
3376
4783
  );
3377
- });
3378
- if (!canPatchRows) {
3379
- const baseline = rowIndexes ? snapshot : capturePlainData(snapshot);
3380
- return {
3381
- patches: acceptedPatches,
3382
- snapshot: ownValidatedNativeSnapshot(
3383
- applyRowPatches(baseline, acceptedPatches)
3384
- ),
3385
- json: JSON.stringify(acceptedPatches),
3386
- };
4784
+ const captured = capturePlainData(changes);
4785
+ assertPlainSerializable(
4786
+ captured,
4787
+ `patches[${index}].changes`,
4788
+ new Set<object>(),
4789
+ false
4790
+ );
4791
+ const previous = units.get(input.key);
4792
+ // Undefined is omitted at every merge, so it cannot overwrite an earlier change.
4793
+ const defined = Object.fromEntries(
4794
+ Object.entries(captured).filter(([, value]) => value !== undefined)
4795
+ );
4796
+ units.set(input.key, {
4797
+ index,
4798
+ type: input.type,
4799
+ changes: { ...previous?.changes, ...defined },
4800
+ });
4801
+ } catch (error) {
4802
+ reject(key, index, error);
4803
+ }
4804
+ });
4805
+ const pending = new Map<string, { row: RowModel; patch: RowPatch }>();
4806
+ for (const [key, unit] of units) {
4807
+ if (rejected.has(key)) continue;
4808
+ try {
4809
+ const patch = normalizePatch(
4810
+ { key, type: unit.type, changes: unit.changes } as RowPatch,
4811
+ unit.index
4812
+ );
4813
+ if (!patch) continue;
4814
+ const merged = mergeAndValidateSingleRow(
4815
+ snapshot.rows[rowIndexes.get(key)!],
4816
+ patch,
4817
+ rowIndexes.get(key)!
4818
+ );
4819
+ if (merged.row.disabled && snapshot.selection?.selectedKeys.includes(key))
4820
+ fail(`patches[${unit.index}]`, 'cannot disable a selected row');
4821
+ pending.set(key, merged);
4822
+ } catch (error) {
4823
+ reject(key, unit.index, error);
4824
+ }
4825
+ }
4826
+ const accepted: RowPatch[] = [];
4827
+ let rows: RowModel[] | undefined;
4828
+ for (const [key, value] of pending) {
4829
+ const index = rowIndexes.get(key)!;
4830
+ const group = snapshot.rows[index].groupId;
4831
+ if (rejected.has(key) || (group && rejectedGroups.has(group))) continue;
4832
+ if (!rows) rows = snapshot.rows.slice();
4833
+ rows[index] = owned ? deepFreeze(value.row) : value.row;
4834
+ accepted.push(owned ? deepFreeze(value.patch) : value.patch);
3387
4835
  }
4836
+ if (!rows) return { patches: EMPTY_ROW_PATCHES, snapshot, json: '[]' };
4837
+ const next = owned
4838
+ ? Object.freeze({ ...snapshot, rows: Object.freeze(rows) })
4839
+ : { ...snapshot, rows };
4840
+ if (owned) ownedNativeSnapshots.set(next, rowIndexes);
4841
+ else validatedRowIndexes.set(next, rowIndexes);
4842
+ return { patches: accepted, snapshot: next, json: JSON.stringify(accepted) };
4843
+ }
3388
4844
 
3389
- const nextRows = snapshot.rows.slice();
3390
- for (const patch of acceptedPatches) {
3391
- const index = rowIndexes.get(patch.key)!;
3392
- const previous = nextRows[index];
3393
- const merged = {
3394
- ...previous,
3395
- ...patch.changes,
3396
- key: previous.key,
3397
- type: previous.type,
3398
- } as RowModel;
3399
- const path = `rows[${index}]`;
3400
- assertPlainSerializable(merged, path, new Set<object>());
3401
- if (isDevelopment()) assertRow(merged, index);
3402
- const normalized = isDevelopment()
3403
- ? merged
3404
- : (normalizeEmptyImages(merged) as RowModel);
3405
- assertRowProtocol(normalized, path);
3406
- nextRows[index] = deepFreeze(normalizeRowStyles(normalized));
3407
- }
3408
- const nextSnapshot = Object.freeze({
3409
- ...snapshot,
3410
- rows: Object.freeze(nextRows),
3411
- });
3412
- ownedNativeSnapshots.set(nextSnapshot, rowIndexes);
3413
- return {
3414
- patches: acceptedPatches,
3415
- snapshot: nextSnapshot,
3416
- json: JSON.stringify(acceptedPatches),
3417
- };
4845
+ const validatedRowIndexes = new WeakMap<
4846
+ NativeListSnapshot,
4847
+ ReadonlyMap<string, number>
4848
+ >();
4849
+
4850
+ export function applyOwnedNativePatches(
4851
+ snapshot: NativeListSnapshot,
4852
+ patches: readonly RowPatch[]
4853
+ ) {
4854
+ const owned = ownedNativeSnapshots.has(snapshot)
4855
+ ? snapshot
4856
+ : ownValidatedNativeSnapshot(validateSnapshot(capturePlainData(snapshot)));
4857
+ return applyValidatedRowPatches(owned, patches);
4858
+ }
4859
+
4860
+ export function applyRowPatches(
4861
+ snapshot: NativeListSnapshot,
4862
+ patches: readonly RowPatch[]
4863
+ ): NativeListSnapshot {
4864
+ const baseline = ownedNativeSnapshots.has(snapshot)
4865
+ ? snapshot
4866
+ : validateSnapshot(snapshot);
4867
+ return applyValidatedRowPatches(baseline, patches).snapshot;
3418
4868
  }
3419
4869
 
3420
4870
  export function serializeSnapshot(snapshot: NativeListSnapshot): string {