@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/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +33 -2
- package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +3 -1
- package/docs/ROW_TEMPLATES.md +4 -0
- package/docs/SPEC.md +121 -35
- package/ios/NativeListIdentityRenderer.swift +50 -3
- package/ios/RNCNativeListView.swift +6 -1
- package/ios/tests/NativeListLifecycleTests.swift +60 -0
- package/lib/module/NativeList.web.js +9 -12
- package/lib/module/models.js +2 -0
- package/lib/module/validation.js +627 -114
- package/lib/module/web/NativeListWebEngine.js +3 -9
- package/lib/module/web/templates/IdentityRowRenderer.js +58 -4
- package/lib/typescript/src/models.d.ts +25 -14
- package/lib/typescript/src/validation.d.ts +7 -2
- package/package.json +3 -3
- package/src/NativeList.web.tsx +10 -14
- package/src/models.ts +144 -153
- package/src/validation.ts +1617 -167
- package/src/web/NativeListWebEngine.ts +6 -10
- package/src/web/templates/IdentityRowRenderer.ts +70 -4
package/lib/module/validation.js
CHANGED
|
@@ -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]) =>
|
|
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
|
|
1655
|
+
/** Empty images and optional nulls share one meaning before strict assertions. */
|
|
1334
1656
|
function normalizeEmptyImages(value, field = '') {
|
|
1335
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1361
|
-
const normalized =
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
-
|
|
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
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
const
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1790
|
-
export function
|
|
1791
|
-
|
|
1792
|
-
const
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
const
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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(
|
|
1827
|
-
})
|
|
1828
|
-
|
|
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:
|
|
1831
|
-
snapshot:
|
|
1832
|
-
json: JSON.stringify(
|
|
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
|
}
|