ngx-json-render 0.8.2 → 0.9.0
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/README.md +66 -1
- package/fesm2022/ngx-json-render.mjs +1202 -1125
- package/fesm2022/ngx-json-render.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-json-render.d.ts +39 -4
|
@@ -23,6 +23,11 @@ class JsonRenderRootContext {
|
|
|
23
23
|
* they apply whatever `validate` is set to.
|
|
24
24
|
*/
|
|
25
25
|
limits = EMPTY;
|
|
26
|
+
/**
|
|
27
|
+
* The catalog the spec was generated for. While loading, an element whose
|
|
28
|
+
* props do not pass its component's schema waits for them.
|
|
29
|
+
*/
|
|
30
|
+
catalog = EMPTY;
|
|
26
31
|
/** External store (controlled mode). */
|
|
27
32
|
store = EMPTY;
|
|
28
33
|
/** Initial state (uncontrolled mode); falls back to `spec.state`. */
|
|
@@ -1106,1211 +1111,1271 @@ function injectDevtoolsActive() {
|
|
|
1106
1111
|
}
|
|
1107
1112
|
|
|
1108
1113
|
/**
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1114
|
+
* A hostile spec can fail the catalog's schema in as many places as it has
|
|
1115
|
+
* properties. Report enough to act on and stop; the spec is refused either
|
|
1116
|
+
* way, and a console full of them helps nobody.
|
|
1117
|
+
*/
|
|
1118
|
+
const MAX_REPORTED_PROP_ISSUES = 20;
|
|
1119
|
+
/**
|
|
1120
|
+
* Whether this element's `repeat` descends into the item it is already inside,
|
|
1121
|
+
* rather than re-reading a fixed path in state.
|
|
1111
1122
|
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1123
|
+
* A relative statePath — `{"$item": "children"}` — resolves against the
|
|
1124
|
+
* enclosing item, so each pass reads one level further into the data and the
|
|
1125
|
+
* recursion ends where the data does. A plain string is absolute: every pass
|
|
1126
|
+
* reads the same array, and a spec that re-enters itself through one never
|
|
1127
|
+
* terminates.
|
|
1128
|
+
*/
|
|
1129
|
+
function descendsIntoItems(element) {
|
|
1130
|
+
const repeat = element.repeat;
|
|
1131
|
+
return repeat !== undefined && typeof repeat.statePath !== 'string';
|
|
1132
|
+
}
|
|
1133
|
+
/** Every key an element renders below itself: `children` plus every slot. */
|
|
1134
|
+
function childKeysOf(element) {
|
|
1135
|
+
const slots = element.slots;
|
|
1136
|
+
if (!slots)
|
|
1137
|
+
return element.children ?? [];
|
|
1138
|
+
const fromSlots = Object.values(slots).flat();
|
|
1139
|
+
return element.children ? [...element.children, ...fromSlots] : fromSlots;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Walk the element graph once for depth and cycles.
|
|
1117
1143
|
*
|
|
1118
|
-
*
|
|
1119
|
-
* this
|
|
1144
|
+
* Iterative on purpose. A recursive walk would blow the stack on exactly the
|
|
1145
|
+
* specs this exists to catch, which would make the guard a second way to
|
|
1146
|
+
* crash rather than the fix for the first.
|
|
1120
1147
|
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
* - a read of the whole state (`''` or `'/'`), and a condition it can't parse.
|
|
1148
|
+
* Depth is measured from `spec.root`, because that is what renders. Cycles
|
|
1149
|
+
* are swept for across every element, including the ones no parent reaches:
|
|
1150
|
+
* an unreachable cycle is still a defect, and one edit away from a reachable
|
|
1151
|
+
* one.
|
|
1126
1152
|
*
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1153
|
+
* Not every element that reaches itself is a defect, though. Drawing a tree —
|
|
1154
|
+
* a comment thread, a file browser, a nested menu — means an element that
|
|
1155
|
+
* repeats over a relative path and renders itself for each item, and that ends
|
|
1156
|
+
* when the data does. A back edge is only reported when nothing along it
|
|
1157
|
+
* descends into the data, which is the case that cannot end.
|
|
1158
|
+
*
|
|
1159
|
+
* `depthBelow` is memoised per element, so a shared subtree costs one visit
|
|
1160
|
+
* instead of one per path into it — without that, a spec whose elements each
|
|
1161
|
+
* name the next two is exponential to check. The memo makes the reported
|
|
1162
|
+
* depth a lower bound once a cycle has been cut, since which edge closes the
|
|
1163
|
+
* cycle depends on the order the walk reached it. The cycle is reported as an
|
|
1164
|
+
* error of its own, which is the finding worth acting on.
|
|
1129
1165
|
*/
|
|
1130
|
-
function
|
|
1131
|
-
const
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
1166
|
+
function analyseSpecGraph(spec) {
|
|
1167
|
+
const elements = spec.elements ?? {};
|
|
1168
|
+
const cycles = new Set();
|
|
1169
|
+
const depthBelow = new Map();
|
|
1170
|
+
const onPath = new Set();
|
|
1171
|
+
const walkFrom = (start) => {
|
|
1172
|
+
const first = elements[start];
|
|
1173
|
+
if (!first || depthBelow.has(start))
|
|
1174
|
+
return;
|
|
1175
|
+
const stack = [
|
|
1176
|
+
{ key: start, kids: childKeysOf(first), next: 0, deepest: 0 },
|
|
1177
|
+
];
|
|
1178
|
+
onPath.add(start);
|
|
1179
|
+
while (stack.length > 0) {
|
|
1180
|
+
const frame = stack[stack.length - 1];
|
|
1181
|
+
if (frame.next < frame.kids.length) {
|
|
1182
|
+
const childKey = frame.kids[frame.next++];
|
|
1183
|
+
const memo = depthBelow.get(childKey);
|
|
1184
|
+
if (memo !== undefined) {
|
|
1185
|
+
frame.deepest = Math.max(frame.deepest, memo);
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
// A child that closes the cycle is not followed — that is what keeps
|
|
1189
|
+
// this walk finite — and not counted towards depth either.
|
|
1190
|
+
if (onPath.has(childKey)) {
|
|
1191
|
+
const from = stack.findIndex((frame) => frame.key === childKey);
|
|
1192
|
+
const descends = stack
|
|
1193
|
+
.slice(from)
|
|
1194
|
+
.some((frame) => descendsIntoItems(elements[frame.key]));
|
|
1195
|
+
if (!descends)
|
|
1196
|
+
cycles.add(childKey);
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
const child = elements[childKey];
|
|
1200
|
+
// A child the spec never defined is validateSpec's finding, not ours.
|
|
1201
|
+
if (!child)
|
|
1202
|
+
continue;
|
|
1203
|
+
onPath.add(childKey);
|
|
1204
|
+
stack.push({
|
|
1205
|
+
key: childKey,
|
|
1206
|
+
kids: childKeysOf(child),
|
|
1207
|
+
next: 0,
|
|
1208
|
+
deepest: 0,
|
|
1209
|
+
});
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
stack.pop();
|
|
1213
|
+
onPath.delete(frame.key);
|
|
1214
|
+
const depth = frame.deepest + 1;
|
|
1215
|
+
depthBelow.set(frame.key, depth);
|
|
1216
|
+
const parent = stack[stack.length - 1];
|
|
1217
|
+
if (parent)
|
|
1218
|
+
parent.deepest = Math.max(parent.deepest, depth);
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
if (spec.root)
|
|
1222
|
+
walkFrom(spec.root);
|
|
1223
|
+
const depth = (spec.root ? depthBelow.get(spec.root) : undefined) ?? 0;
|
|
1224
|
+
for (const key of Object.keys(elements))
|
|
1225
|
+
walkFrom(key);
|
|
1226
|
+
return { depth, cycles: [...cycles] };
|
|
1135
1227
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1228
|
+
/** Report a cycle, naming the element that is its own descendant. */
|
|
1229
|
+
function cycleIssue(key) {
|
|
1230
|
+
return {
|
|
1231
|
+
severity: 'error',
|
|
1232
|
+
code: 'cycle',
|
|
1233
|
+
elementKey: key,
|
|
1234
|
+
message: `Element "${key}" renders itself against the same data, which never terminates. The cycle is broken where it closes — the repeated element does not render a second time — but the spec cannot be drawn as written. A tree that recurses needs a repeat with a relative statePath, like {"$item": "children"}.`,
|
|
1235
|
+
};
|
|
1138
1236
|
}
|
|
1139
|
-
/**
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1237
|
+
/** Report a breached element-count cap. */
|
|
1238
|
+
function elementCountIssue(count, limit) {
|
|
1239
|
+
return {
|
|
1240
|
+
severity: 'error',
|
|
1241
|
+
code: 'too_many_elements',
|
|
1242
|
+
message: `Spec has ${count} elements, over the limit of ${limit}. Nothing is rendered: renderLimits.maxElements is a cap on the whole spec.`,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
/** Report a breached depth cap. */
|
|
1246
|
+
function depthIssue(depth, limit) {
|
|
1247
|
+
return {
|
|
1248
|
+
severity: 'error',
|
|
1249
|
+
code: 'too_deep',
|
|
1250
|
+
message: `Spec nests ${depth} levels deep, over the limit of ${limit}. Elements below level ${limit} do not render.`,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Check a spec against a catalog: the component types it names, then the
|
|
1255
|
+
* catalog's spec schema, then each element's props against its own
|
|
1256
|
+
* component's schema.
|
|
1257
|
+
*
|
|
1258
|
+
* The props need that pass of their own. Core builds one schema for the whole
|
|
1259
|
+
* spec, and it gives an element's props a component's schema only when the
|
|
1260
|
+
* catalog has exactly one component; with more, every element's props are an
|
|
1261
|
+
* open record, whatever its `type`. So `catalog.validate` holds an element to
|
|
1262
|
+
* its shape — `children`, a `type` from the catalog — and not to its props.
|
|
1263
|
+
*
|
|
1264
|
+
* Whatever that schema did say about the props of an element checked here is
|
|
1265
|
+
* dropped: with a one-component catalog it would be reported twice, and it
|
|
1266
|
+
* judges expressions this pass knows to leave alone.
|
|
1267
|
+
*
|
|
1268
|
+
* The schema passes are skipped while a type is unknown. That is an error on
|
|
1269
|
+
* its own, and the spec schema would only restate it as a `type` outside the
|
|
1270
|
+
* ones it accepts.
|
|
1271
|
+
*/
|
|
1272
|
+
function catalogIssues(spec, catalog) {
|
|
1273
|
+
const elements = Object.entries(spec.elements ?? {});
|
|
1274
|
+
const known = new Set(catalog.componentNames);
|
|
1275
|
+
const unknown = [];
|
|
1276
|
+
for (const [key, element] of elements) {
|
|
1277
|
+
if (known.has(element.type))
|
|
1278
|
+
continue;
|
|
1279
|
+
unknown.push({
|
|
1280
|
+
severity: 'error',
|
|
1281
|
+
code: 'unknown_component',
|
|
1282
|
+
elementKey: key,
|
|
1283
|
+
message: `Element "${key}" has type "${element.type}", which this catalog does not define. Nothing renders for it.`,
|
|
1284
|
+
});
|
|
1148
1285
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
return this.item(value['$item']);
|
|
1157
|
-
if ('$index' in value && value['$index'] === true)
|
|
1158
|
-
return true;
|
|
1159
|
-
// A two-way binding reads the path it writes back to.
|
|
1160
|
-
if (typeof value['$bindState'] === 'string') {
|
|
1161
|
-
return this.state(value['$bindState']);
|
|
1162
|
-
}
|
|
1163
|
-
if (typeof value['$bindItem'] === 'string') {
|
|
1164
|
-
return this.boundItem(value['$bindItem']);
|
|
1165
|
-
}
|
|
1166
|
-
if ('$cond' in value && '$then' in value && '$else' in value) {
|
|
1167
|
-
return (this.condition(value['$cond']) &&
|
|
1168
|
-
this.value(value['$then']) &&
|
|
1169
|
-
this.value(value['$else']));
|
|
1170
|
-
}
|
|
1171
|
-
if (typeof value['$computed'] === 'string') {
|
|
1172
|
-
// The function is handed its resolved arguments and nothing else.
|
|
1173
|
-
const args = value['args'];
|
|
1174
|
-
return (!args || Object.values(Object(args)).every((arg) => this.value(arg)));
|
|
1175
|
-
}
|
|
1176
|
-
if (typeof value['$template'] === 'string') {
|
|
1177
|
-
return this.template(value['$template']);
|
|
1178
|
-
}
|
|
1179
|
-
if (Array.isArray(value))
|
|
1180
|
-
return value.every((item) => this.value(item));
|
|
1181
|
-
if (this.directives) {
|
|
1182
|
-
for (const name of this.directives.keys()) {
|
|
1183
|
-
if (name in value)
|
|
1184
|
-
return false;
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
1188
|
-
if (key.startsWith('$') || !this.value(entry))
|
|
1189
|
-
return false;
|
|
1190
|
-
}
|
|
1191
|
-
return true;
|
|
1286
|
+
if (unknown.length > 0)
|
|
1287
|
+
return unknown;
|
|
1288
|
+
const schemas = new Map();
|
|
1289
|
+
for (const [key, element] of elements) {
|
|
1290
|
+
const schema = propsSchemaOf(catalog.data, element.type);
|
|
1291
|
+
if (schema)
|
|
1292
|
+
schemas.set(key, schema);
|
|
1192
1293
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
const children = condition[group];
|
|
1205
|
-
return (Array.isArray(children) &&
|
|
1206
|
-
children.every((child) => this.condition(child)));
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
return this.singleCondition(condition);
|
|
1210
|
-
}
|
|
1211
|
-
singleCondition(condition) {
|
|
1212
|
-
if (!isObject(condition))
|
|
1213
|
-
return false;
|
|
1214
|
-
let understood;
|
|
1215
|
-
if ('$index' in condition) {
|
|
1216
|
-
understood = true;
|
|
1217
|
-
}
|
|
1218
|
-
else if ('$item' in condition) {
|
|
1219
|
-
const item = condition['$item'];
|
|
1220
|
-
understood = typeof item === 'string' && this.item(item);
|
|
1221
|
-
}
|
|
1222
|
-
else {
|
|
1223
|
-
// Core reads `$state` here without checking it: anything but a string
|
|
1224
|
-
// path reads the whole state.
|
|
1225
|
-
const path = condition['$state'];
|
|
1226
|
-
understood = typeof path === 'string' && this.state(path);
|
|
1227
|
-
}
|
|
1228
|
-
// Core looks only at the first operator present. Reading all of them is
|
|
1229
|
-
// a superset of that.
|
|
1230
|
-
for (const operator of COMPARISONS) {
|
|
1231
|
-
const operand = condition[operator];
|
|
1232
|
-
if (isObject(operand) && typeof operand['$state'] === 'string') {
|
|
1233
|
-
understood = this.state(operand['$state']) && understood;
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
return understood;
|
|
1294
|
+
const found = specSchemaIssues(spec, catalog).filter(({ path }) => !(path[0] === 'elements' &&
|
|
1295
|
+
typeof path[1] === 'string' &&
|
|
1296
|
+
schemas.has(path[1]) &&
|
|
1297
|
+
path[2] === 'props'));
|
|
1298
|
+
for (const [key, element] of elements) {
|
|
1299
|
+
const room = MAX_REPORTED_PROP_ISSUES - found.length;
|
|
1300
|
+
if (room <= 0)
|
|
1301
|
+
break;
|
|
1302
|
+
const schema = schemas.get(key);
|
|
1303
|
+
if (schema)
|
|
1304
|
+
found.push(...propsIssues(key, element, schema, room));
|
|
1237
1305
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1306
|
+
return found.slice(0, MAX_REPORTED_PROP_ISSUES).map(toSpecCheckIssue);
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Whether an element's props have arrived: they pass its component's schema,
|
|
1310
|
+
* leaving out what the schema says about expressions, the same way
|
|
1311
|
+
* {@link catalogIssues} judges them. The renderer holds an element back on
|
|
1312
|
+
* this while the spec streams.
|
|
1313
|
+
*
|
|
1314
|
+
* The props are taken as written in the spec, not as resolved. What a stream
|
|
1315
|
+
* leaves out is spec, and an expression that is there has arrived, whatever
|
|
1316
|
+
* it resolves to; checking the spec also keeps state writes from re-running
|
|
1317
|
+
* the schema.
|
|
1318
|
+
*
|
|
1319
|
+
* True when the catalog declares no schema that can be called for the type:
|
|
1320
|
+
* there is nothing to wait for. False when the schema throws, or fails
|
|
1321
|
+
* without saying where — the element mounts once loading ends either way.
|
|
1322
|
+
*/
|
|
1323
|
+
function propsArrived(element, catalog) {
|
|
1324
|
+
const schema = propsSchemaOf(catalog.data, element.type);
|
|
1325
|
+
if (!schema)
|
|
1251
1326
|
return true;
|
|
1327
|
+
const props = element.props ?? {};
|
|
1328
|
+
let result;
|
|
1329
|
+
try {
|
|
1330
|
+
result = schema.safeParse(props);
|
|
1252
1331
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1332
|
+
catch {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
if (result.success)
|
|
1257
1336
|
return true;
|
|
1337
|
+
const issues = readIssues(result.error);
|
|
1338
|
+
return (issues.length > 0 &&
|
|
1339
|
+
issues.every(({ path }) => reachesExpression(props, path)));
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* The props schema a catalog declares for a component type, if it declares
|
|
1343
|
+
* one that can be called.
|
|
1344
|
+
*/
|
|
1345
|
+
function propsSchemaOf(data, type) {
|
|
1346
|
+
const components = data
|
|
1347
|
+
?.components;
|
|
1348
|
+
if (typeof components !== 'object' ||
|
|
1349
|
+
components === null ||
|
|
1350
|
+
!Object.hasOwn(components, type)) {
|
|
1351
|
+
return undefined;
|
|
1258
1352
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1353
|
+
const props = components[type]?.props;
|
|
1354
|
+
return typeof props
|
|
1355
|
+
?.safeParse === 'function'
|
|
1356
|
+
? props
|
|
1357
|
+
: undefined;
|
|
1358
|
+
}
|
|
1359
|
+
/** What the catalog's spec schema reported, with paths rooted at the spec. */
|
|
1360
|
+
function specSchemaIssues(spec, catalog) {
|
|
1361
|
+
try {
|
|
1362
|
+
const result = catalog.validate(spec);
|
|
1363
|
+
return result.success ? [] : readIssues(result.error);
|
|
1269
1364
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
* reaches the element on its own, but only as the same object when an
|
|
1273
|
-
* external store edits it in place, so the path is what notices the edit.
|
|
1274
|
-
*/
|
|
1275
|
-
item(itemPath) {
|
|
1276
|
-
if (this.repeatBasePath === undefined)
|
|
1277
|
-
return true;
|
|
1278
|
-
const path = resolveRepeatStatePath({ $item: itemPath }, this.repeatBasePath);
|
|
1279
|
-
return path !== undefined && this.state(path);
|
|
1365
|
+
catch (error) {
|
|
1366
|
+
return [{ path: [], message: threwMessage('the catalog schema', error) }];
|
|
1280
1367
|
}
|
|
1281
1368
|
}
|
|
1282
|
-
|
|
1283
|
-
/**
|
|
1284
|
-
* The render context of the element currently being rendered.
|
|
1285
|
-
* Provided by the renderer for every catalog component instance.
|
|
1286
|
-
*/
|
|
1287
|
-
const RENDER_CONTEXT = new InjectionToken('ngx-json-render RENDER_CONTEXT');
|
|
1288
|
-
/**
|
|
1289
|
-
* The spec key of the element currently being rendered. Provided alongside
|
|
1290
|
-
* {@link RENDER_CONTEXT} for every catalog component instance.
|
|
1291
|
-
*/
|
|
1292
|
-
const ELEMENT_KEY = new InjectionToken('ngx-json-render ELEMENT_KEY');
|
|
1293
|
-
/**
|
|
1294
|
-
* The current repeat scope. Present only for elements rendered inside a
|
|
1295
|
-
* `repeat` block.
|
|
1296
|
-
*/
|
|
1297
|
-
const REPEAT_SCOPE = new InjectionToken('ngx-json-render REPEAT_SCOPE');
|
|
1298
|
-
/**
|
|
1299
|
-
* This element's position in the render tree. Provided by `<jr-element>` for
|
|
1300
|
-
* everything it renders below itself, so each element can see its own depth
|
|
1301
|
-
* and its own ancestry.
|
|
1302
|
-
*
|
|
1303
|
-
* @internal Not part of the public API: it exists so the renderer can refuse
|
|
1304
|
-
* to draw an element that is its own ancestor, and to enforce
|
|
1305
|
-
* `renderLimits.maxDepth`.
|
|
1306
|
-
*/
|
|
1307
|
-
const RENDER_PATH = new InjectionToken('ngx-json-render RENDER_PATH');
|
|
1308
|
-
/**
|
|
1309
|
-
* Whether an element that skipped a state write checks that it was right to:
|
|
1310
|
-
* that resolving it against the whole state would not give anything else.
|
|
1311
|
-
* On in dev mode, off in production.
|
|
1312
|
-
*
|
|
1313
|
-
* @internal Not part of the public API. The check calls `$computed`
|
|
1314
|
-
* functions again, so tests that count those calls turn it off.
|
|
1315
|
-
*/
|
|
1316
|
-
const CHECK_SKIPPED_WRITES = new InjectionToken('ngx-json-render CHECK_SKIPPED_WRITES', { providedIn: 'root', factory: () => isDevMode() });
|
|
1317
1369
|
/**
|
|
1318
|
-
*
|
|
1370
|
+
* What an element's component schema says about its props, leaving out what
|
|
1371
|
+
* it says about expressions, with paths rooted at the spec.
|
|
1319
1372
|
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
* template: `<button (click)="ctx.emit('press')">{{ ctx.props().label }}</button>`,
|
|
1325
|
-
* })
|
|
1326
|
-
* export class ButtonComponent {
|
|
1327
|
-
* readonly ctx = injectRenderContext<{ label: string }>();
|
|
1328
|
-
* }
|
|
1329
|
-
* ```
|
|
1373
|
+
* A schema that throws counts as a rejection, not a crash. Real ones do:
|
|
1374
|
+
* `ValidationConfigSchema`, which every Material input's `validation` prop
|
|
1375
|
+
* uses, recurses through `enabled` and overflows the stack a thousand
|
|
1376
|
+
* `$and`s deep.
|
|
1330
1377
|
*/
|
|
1331
|
-
function
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1378
|
+
function propsIssues(key, element, schema, room) {
|
|
1379
|
+
const at = ['elements', key, 'props'];
|
|
1380
|
+
let result;
|
|
1381
|
+
try {
|
|
1382
|
+
result = schema.safeParse(element.props);
|
|
1335
1383
|
}
|
|
1336
|
-
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
return [
|
|
1386
|
+
{
|
|
1387
|
+
path: at,
|
|
1388
|
+
message: threwMessage(`the "${element.type}" schema`, error),
|
|
1389
|
+
},
|
|
1390
|
+
];
|
|
1391
|
+
}
|
|
1392
|
+
if (result.success)
|
|
1393
|
+
return [];
|
|
1394
|
+
const issues = [];
|
|
1395
|
+
for (const { path, message } of readIssues(result.error)) {
|
|
1396
|
+
if (issues.length >= room)
|
|
1397
|
+
break;
|
|
1398
|
+
if (reachesExpression(element.props, path))
|
|
1399
|
+
continue;
|
|
1400
|
+
issues.push({ path: [...at, ...path], message });
|
|
1401
|
+
}
|
|
1402
|
+
return issues;
|
|
1337
1403
|
}
|
|
1338
1404
|
/**
|
|
1339
|
-
*
|
|
1405
|
+
* Whether a props issue is about an expression: it sits on one, inside one,
|
|
1406
|
+
* or on a value holding one.
|
|
1340
1407
|
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1408
|
+
* `{"$state": "/title"}` where the schema wants a string is not a defect.
|
|
1409
|
+
* Core resolves every such value before the component sees it, so the schema
|
|
1410
|
+
* would be judging something that never renders. Any `$`-prefixed key counts
|
|
1411
|
+
* — directive names must start with `$`, and so would an expression added to
|
|
1412
|
+
* core later. A value merely holding one is left alone too, since a union or
|
|
1413
|
+
* a refinement over it ran on the unresolved value.
|
|
1345
1414
|
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
* const key = injectElementKey();
|
|
1349
|
-
* const index = computed(() => parent.element().children?.indexOf(key()) ?? -1);
|
|
1350
|
-
* ```
|
|
1415
|
+
* The props object itself is not an expression: core resolves the values
|
|
1416
|
+
* inside it, never the object.
|
|
1351
1417
|
*/
|
|
1352
|
-
function
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1418
|
+
function reachesExpression(props, path) {
|
|
1419
|
+
let value = props;
|
|
1420
|
+
for (const segment of path) {
|
|
1421
|
+
if (typeof value !== 'object' || value === null)
|
|
1422
|
+
return false;
|
|
1423
|
+
value = value[segment];
|
|
1424
|
+
if (isExpression(value))
|
|
1425
|
+
return true;
|
|
1356
1426
|
}
|
|
1357
|
-
return
|
|
1427
|
+
return holdsExpression(value);
|
|
1358
1428
|
}
|
|
1359
|
-
/**
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1429
|
+
/** A value core resolves at render time — or will, once it knows the key. */
|
|
1430
|
+
function isExpression(value) {
|
|
1431
|
+
return (typeof value === 'object' &&
|
|
1432
|
+
value !== null &&
|
|
1433
|
+
!Array.isArray(value) &&
|
|
1434
|
+
Object.keys(value).some((key) => key.startsWith('$')));
|
|
1365
1435
|
}
|
|
1366
|
-
|
|
1367
|
-
const warnedSlots = new Set();
|
|
1368
1436
|
/**
|
|
1369
|
-
*
|
|
1370
|
-
* confirmation dialog is a normal outcome, not an error, so it stays silent.
|
|
1371
|
-
*/
|
|
1372
|
-
function reportActionError(error) {
|
|
1373
|
-
if (isActionCancelled(error))
|
|
1374
|
-
return;
|
|
1375
|
-
console.error(error);
|
|
1376
|
-
}
|
|
1377
|
-
/**
|
|
1378
|
-
* Whether `inner` reads strictly deeper into state than `outer` — the state
|
|
1379
|
-
* path of one repeat item nested inside another's.
|
|
1380
|
-
*
|
|
1381
|
-
* `null` is the world outside any repeat, so stepping into one is always
|
|
1382
|
-
* progress. Below that it is a prefix test on the resolved path, and the
|
|
1383
|
-
* trailing separator is what keeps `/tree/10` from counting as a step inside
|
|
1384
|
-
* `/tree/1`.
|
|
1385
|
-
*/
|
|
1386
|
-
function descendsInto(outer, inner) {
|
|
1387
|
-
if (inner === null)
|
|
1388
|
-
return false;
|
|
1389
|
-
if (outer === null)
|
|
1390
|
-
return true;
|
|
1391
|
-
return inner.startsWith(`${outer}/`);
|
|
1392
|
-
}
|
|
1393
|
-
/**
|
|
1394
|
-
* Whether this element renders itself again without getting anywhere — the
|
|
1395
|
-
* one arrangement that cannot terminate.
|
|
1396
|
-
*
|
|
1397
|
-
* An element reaching itself is not by itself a defect. It is how a tree gets
|
|
1398
|
-
* drawn — a comment thread, a file browser, a nested menu — where each pass
|
|
1399
|
-
* repeats over a path relative to the item it is already inside and so reads
|
|
1400
|
-
* one level further into the data. That ends when the data does.
|
|
1437
|
+
* Whether anything nested inside `value` is an expression.
|
|
1401
1438
|
*
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
1406
|
-
* between the passes at all. Only the first can run out.
|
|
1439
|
+
* Iterative, and it remembers what it has seen, for the same reason as
|
|
1440
|
+
* {@link analyseSpecGraph}: props are as attacker-shaped as the rest of the
|
|
1441
|
+
* spec, and a check that overflowed or looped on them would be a second way
|
|
1442
|
+
* to crash.
|
|
1407
1443
|
*/
|
|
1408
|
-
function
|
|
1409
|
-
|
|
1410
|
-
|
|
1444
|
+
function holdsExpression(value) {
|
|
1445
|
+
const pending = [value];
|
|
1446
|
+
const seen = new Set();
|
|
1447
|
+
while (pending.length > 0) {
|
|
1448
|
+
const current = pending.pop();
|
|
1449
|
+
if (typeof current !== 'object' || current === null)
|
|
1411
1450
|
continue;
|
|
1412
|
-
|
|
1451
|
+
if (seen.has(current))
|
|
1452
|
+
continue;
|
|
1453
|
+
seen.add(current);
|
|
1454
|
+
for (const child of Object.values(current)) {
|
|
1455
|
+
if (isExpression(child))
|
|
1456
|
+
return true;
|
|
1457
|
+
pending.push(child);
|
|
1458
|
+
}
|
|
1413
1459
|
}
|
|
1414
1460
|
return false;
|
|
1415
1461
|
}
|
|
1416
1462
|
/**
|
|
1417
|
-
*
|
|
1463
|
+
* Read what a schema reported.
|
|
1418
1464
|
*
|
|
1419
|
-
*
|
|
1420
|
-
*
|
|
1421
|
-
* it has
|
|
1422
|
-
*
|
|
1423
|
-
* primitives can be trusted to compare equal, and any object value counts as
|
|
1424
|
-
* changed.
|
|
1465
|
+
* Read defensively rather than typed against the schema library: the shape
|
|
1466
|
+
* below (`issues`, each with `path` and `message`) is what every version of
|
|
1467
|
+
* it has produced, and a renderer that threw while explaining a bad spec
|
|
1468
|
+
* would be the worse failure.
|
|
1425
1469
|
*/
|
|
1426
|
-
function
|
|
1427
|
-
const
|
|
1428
|
-
if (
|
|
1429
|
-
return
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
const
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1470
|
+
function readIssues(error) {
|
|
1471
|
+
const raw = error?.issues;
|
|
1472
|
+
if (!Array.isArray(raw))
|
|
1473
|
+
return [];
|
|
1474
|
+
return raw.map((entry) => {
|
|
1475
|
+
const item = entry;
|
|
1476
|
+
const path = item?.path;
|
|
1477
|
+
const message = item?.message;
|
|
1478
|
+
return {
|
|
1479
|
+
path: Array.isArray(path) ? path : [],
|
|
1480
|
+
message: typeof message === 'string' ? message : 'is not valid',
|
|
1481
|
+
};
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
function threwMessage(schema, error) {
|
|
1485
|
+
const reason = error instanceof Error ? ` (${error.name}: ${error.message})` : '';
|
|
1486
|
+
return `${schema} threw instead of answering${reason}, so this counts as rejected`;
|
|
1487
|
+
}
|
|
1488
|
+
function toSpecCheckIssue({ path, message }) {
|
|
1489
|
+
// Paths are rooted at the spec, so `elements.<key>.props.<prop>` names the
|
|
1490
|
+
// element in its second segment.
|
|
1491
|
+
const elementKey = path[0] === 'elements' && typeof path[1] === 'string' ? path[1] : undefined;
|
|
1492
|
+
const where = path.length > 0 ? path.map(String).join('.') : 'spec';
|
|
1493
|
+
return {
|
|
1494
|
+
severity: 'error',
|
|
1495
|
+
code: 'invalid_props',
|
|
1496
|
+
...(elementKey === undefined ? {} : { elementKey }),
|
|
1497
|
+
message: `${where}: ${message}`,
|
|
1498
|
+
};
|
|
1441
1499
|
}
|
|
1500
|
+
|
|
1442
1501
|
/**
|
|
1443
|
-
*
|
|
1502
|
+
* The state paths an element's expressions can read, or `null` when that
|
|
1503
|
+
* can't be known from the spec.
|
|
1444
1504
|
*
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1505
|
+
* Worked out from the spec alone, before anything resolves, so that an element
|
|
1506
|
+
* can depend on those paths rather than on the whole state. The answer may
|
|
1507
|
+
* name more paths than a given resolution reads — both branches of a `$cond`,
|
|
1508
|
+
* say — but never fewer: a path left out would be a write the element misses,
|
|
1509
|
+
* and it would keep showing the old value with nothing in the console.
|
|
1510
|
+
*
|
|
1511
|
+
* `null` is the safe answer, and it is given whenever the spec holds something
|
|
1512
|
+
* this reader does not model:
|
|
1513
|
+
*
|
|
1514
|
+
* - a registered directive, whose `resolve` receives the whole context and may
|
|
1515
|
+
* read any path at all;
|
|
1516
|
+
* - a `$`-prefixed key it doesn't recognise, which is what an expression added
|
|
1517
|
+
* to core after this was written would look like;
|
|
1518
|
+
* - a read of the whole state (`''` or `'/'`), and a condition it can't parse.
|
|
1519
|
+
*
|
|
1520
|
+
* The walk mirrors core's `resolvePropValue` and `evaluateVisibility`, in the
|
|
1521
|
+
* same order, because the order decides which expression an object is.
|
|
1450
1522
|
*/
|
|
1451
|
-
function
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b)
|
|
1457
|
-
return false;
|
|
1458
|
-
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b))
|
|
1459
|
-
return false;
|
|
1460
|
-
if (a instanceof Date)
|
|
1461
|
-
return a.getTime() === b.getTime();
|
|
1462
|
-
const plain = Array.isArray(a) || Object.getPrototypeOf(a) === Object.prototype;
|
|
1463
|
-
if (!plain || seen.has(a))
|
|
1464
|
-
return true;
|
|
1465
|
-
seen.add(a);
|
|
1466
|
-
const keys = Object.keys(a);
|
|
1467
|
-
if (keys.length !== Object.keys(b).length)
|
|
1468
|
-
return false;
|
|
1469
|
-
return keys.every((key) => Object.hasOwn(b, key) &&
|
|
1470
|
-
sameContent(a[key], b[key], seen));
|
|
1523
|
+
function collectStateReads(element, directives, repeatBasePath) {
|
|
1524
|
+
const reader = new StateReader(directives, repeatBasePath);
|
|
1525
|
+
const understood = reader.condition(element.visible) &&
|
|
1526
|
+
Object.values(element.props ?? {}).every((value) => reader.value(value));
|
|
1527
|
+
return understood ? reader.paths : null;
|
|
1471
1528
|
}
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
* same path, a container above it, or a path inside it.
|
|
1475
|
-
*/
|
|
1476
|
-
function touches(written, bound) {
|
|
1477
|
-
return (written === bound ||
|
|
1478
|
-
bound.startsWith(`${written}/`) ||
|
|
1479
|
-
written.startsWith(`${bound}/`));
|
|
1529
|
+
function isObject(value) {
|
|
1530
|
+
return typeof value === 'object' && value !== null;
|
|
1480
1531
|
}
|
|
1481
|
-
/**
|
|
1482
|
-
const
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1532
|
+
/** Comparison operators a single condition may carry, as core reads them. */
|
|
1533
|
+
const COMPARISONS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'];
|
|
1534
|
+
class StateReader {
|
|
1535
|
+
directives;
|
|
1536
|
+
repeatBasePath;
|
|
1537
|
+
paths = new Set();
|
|
1538
|
+
constructor(directives, repeatBasePath) {
|
|
1539
|
+
this.directives = directives;
|
|
1540
|
+
this.repeatBasePath = repeatBasePath;
|
|
1541
|
+
}
|
|
1542
|
+
/** A prop value. Returns false when it can read state this can't name. */
|
|
1543
|
+
value(value) {
|
|
1544
|
+
if (!isObject(value))
|
|
1545
|
+
return true;
|
|
1546
|
+
if (typeof value['$state'] === 'string')
|
|
1547
|
+
return this.state(value['$state']);
|
|
1548
|
+
if (typeof value['$item'] === 'string')
|
|
1549
|
+
return this.item(value['$item']);
|
|
1550
|
+
if ('$index' in value && value['$index'] === true)
|
|
1551
|
+
return true;
|
|
1552
|
+
// A two-way binding reads the path it writes back to.
|
|
1553
|
+
if (typeof value['$bindState'] === 'string') {
|
|
1554
|
+
return this.state(value['$bindState']);
|
|
1555
|
+
}
|
|
1556
|
+
if (typeof value['$bindItem'] === 'string') {
|
|
1557
|
+
return this.boundItem(value['$bindItem']);
|
|
1558
|
+
}
|
|
1559
|
+
if ('$cond' in value && '$then' in value && '$else' in value) {
|
|
1560
|
+
return (this.condition(value['$cond']) &&
|
|
1561
|
+
this.value(value['$then']) &&
|
|
1562
|
+
this.value(value['$else']));
|
|
1563
|
+
}
|
|
1564
|
+
if (typeof value['$computed'] === 'string') {
|
|
1565
|
+
// The function is handed its resolved arguments and nothing else.
|
|
1566
|
+
const args = value['args'];
|
|
1567
|
+
return (!args || Object.values(Object(args)).every((arg) => this.value(arg)));
|
|
1568
|
+
}
|
|
1569
|
+
if (typeof value['$template'] === 'string') {
|
|
1570
|
+
return this.template(value['$template']);
|
|
1571
|
+
}
|
|
1572
|
+
if (Array.isArray(value))
|
|
1573
|
+
return value.every((item) => this.value(item));
|
|
1574
|
+
if (this.directives) {
|
|
1575
|
+
for (const name of this.directives.keys()) {
|
|
1576
|
+
if (name in value)
|
|
1577
|
+
return false;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1581
|
+
if (key.startsWith('$') || !this.value(entry))
|
|
1582
|
+
return false;
|
|
1583
|
+
}
|
|
1486
1584
|
return true;
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
if (
|
|
1585
|
+
}
|
|
1586
|
+
/** A `visible` condition, or the condition of a `$cond`. */
|
|
1587
|
+
condition(condition) {
|
|
1588
|
+
if (condition === undefined || typeof condition === 'boolean')
|
|
1589
|
+
return true;
|
|
1590
|
+
if (Array.isArray(condition)) {
|
|
1591
|
+
return condition.every((single) => this.singleCondition(single));
|
|
1592
|
+
}
|
|
1593
|
+
if (!isObject(condition))
|
|
1491
1594
|
return false;
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1595
|
+
for (const group of ['$and', '$or']) {
|
|
1596
|
+
if (group in condition) {
|
|
1597
|
+
const children = condition[group];
|
|
1598
|
+
return (Array.isArray(children) &&
|
|
1599
|
+
children.every((child) => this.condition(child)));
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return this.singleCondition(condition);
|
|
1499
1603
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
* fetches it again, `buildSpecFromParts` replaying a message — and then an
|
|
1524
|
-
* element whose content is unchanged would resolve, and every container
|
|
1525
|
-
* with a fresh `children` array re-render, for nothing.
|
|
1526
|
-
*/
|
|
1527
|
-
rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], { ...(ngDevMode ? { debugName: "rawElement" } : /* istanbul ignore next */ {}), equal: (a, b) => sameJson(a, b) });
|
|
1528
|
-
/**
|
|
1529
|
-
* The state paths this element's expressions can read, or null when the
|
|
1530
|
-
* spec doesn't say — see {@link collectStateReads} for when that is.
|
|
1531
|
-
*/
|
|
1532
|
-
stateReads = computed(() => {
|
|
1533
|
-
const el = this.rawElement();
|
|
1534
|
-
if (!el)
|
|
1535
|
-
return EMPTY_READS;
|
|
1536
|
-
return collectStateReads(el, this.root.directiveRegistry(), this.repeatScope?.basePath());
|
|
1537
|
-
}, { ...(ngDevMode ? { debugName: "stateReads" } : /* istanbul ignore next */ {}), equal: sameReads });
|
|
1538
|
-
/**
|
|
1539
|
-
* One signal per path this element reads, each holding the value at that
|
|
1540
|
-
* path. They are rebuilt only when the paths change, and a cell that stays
|
|
1541
|
-
* keeps its last value, so a path that didn't change is not a change.
|
|
1542
|
-
*
|
|
1543
|
-
* A cell compares like a resolved prop does: primitives by value, objects
|
|
1544
|
-
* by reference with the internal store (which copies every path it writes)
|
|
1545
|
-
* and never equal with an external `store`, which may write into its
|
|
1546
|
-
* snapshot in place.
|
|
1547
|
-
*/
|
|
1548
|
-
stateCells = (() => {
|
|
1549
|
-
let cells = new Map();
|
|
1550
|
-
return computed(() => {
|
|
1551
|
-
const reads = this.stateReads();
|
|
1552
|
-
if (reads === null)
|
|
1553
|
-
return null;
|
|
1554
|
-
const next = new Map();
|
|
1555
|
-
for (const path of reads) {
|
|
1556
|
-
next.set(path, cells.get(path) ??
|
|
1557
|
-
computed(() => getByPath(this.state.state(), path), {
|
|
1558
|
-
equal: (a, b) => Object.is(a, b) &&
|
|
1559
|
-
(typeof a !== 'object' ||
|
|
1560
|
-
a === null ||
|
|
1561
|
-
!untracked(this.root.store)),
|
|
1562
|
-
}));
|
|
1604
|
+
singleCondition(condition) {
|
|
1605
|
+
if (!isObject(condition))
|
|
1606
|
+
return false;
|
|
1607
|
+
let understood;
|
|
1608
|
+
if ('$index' in condition) {
|
|
1609
|
+
understood = true;
|
|
1610
|
+
}
|
|
1611
|
+
else if ('$item' in condition) {
|
|
1612
|
+
const item = condition['$item'];
|
|
1613
|
+
understood = typeof item === 'string' && this.item(item);
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
// Core reads `$state` here without checking it: anything but a string
|
|
1617
|
+
// path reads the whole state.
|
|
1618
|
+
const path = condition['$state'];
|
|
1619
|
+
understood = typeof path === 'string' && this.state(path);
|
|
1620
|
+
}
|
|
1621
|
+
// Core looks only at the first operator present. Reading all of them is
|
|
1622
|
+
// a superset of that.
|
|
1623
|
+
for (const operator of COMPARISONS) {
|
|
1624
|
+
const operand = condition[operator];
|
|
1625
|
+
if (isObject(operand) && typeof operand['$state'] === 'string') {
|
|
1626
|
+
understood = this.state(operand['$state']) && understood;
|
|
1563
1627
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
* unknown follows every write, as all elements did before.
|
|
1578
|
-
*/
|
|
1579
|
-
readableState = computed(() => {
|
|
1580
|
-
this.snapshotsTaken++;
|
|
1581
|
-
const cells = this.stateCells();
|
|
1582
|
-
if (cells === null)
|
|
1583
|
-
return this.state.state();
|
|
1584
|
-
for (const cell of cells.values())
|
|
1585
|
-
cell();
|
|
1586
|
-
return untracked(this.state.state);
|
|
1587
|
-
}, { ...(ngDevMode ? { debugName: "readableState" } : /* istanbul ignore next */ {}), equal: () => false });
|
|
1588
|
-
/** Prop/visibility resolution context (state + repeat scope + extensions). */
|
|
1589
|
-
resolutionCtx = computed(() => {
|
|
1590
|
-
const scope = this.repeatScope;
|
|
1591
|
-
return {
|
|
1592
|
-
stateModel: this.readableState(),
|
|
1593
|
-
...(scope
|
|
1594
|
-
? {
|
|
1595
|
-
repeatItem: scope.item(),
|
|
1596
|
-
repeatIndex: scope.index(),
|
|
1597
|
-
repeatBasePath: scope.basePath(),
|
|
1598
|
-
}
|
|
1599
|
-
: {}),
|
|
1600
|
-
functions: this.root.functions() ?? {},
|
|
1601
|
-
directives: this.root.directiveRegistry(),
|
|
1602
|
-
};
|
|
1603
|
-
}, ...(ngDevMode ? [{ debugName: "resolutionCtx" }] : /* istanbul ignore next */ []));
|
|
1604
|
-
visible = computed(() => {
|
|
1605
|
-
const el = this.rawElement();
|
|
1606
|
-
if (!el || el.visible === undefined)
|
|
1607
|
-
return true;
|
|
1608
|
-
return evaluateVisibility(el.visible, this.resolutionCtx());
|
|
1609
|
-
}, ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
|
|
1610
|
-
/**
|
|
1611
|
-
* How many state writes have touched a path this element is two-way bound
|
|
1612
|
-
* to. See {@link resolution} for why those writes count on their own.
|
|
1613
|
-
*/
|
|
1614
|
-
boundWrites = signal(0, ...(ngDevMode ? [{ debugName: "boundWrites" }] : /* istanbul ignore next */ []));
|
|
1615
|
-
/**
|
|
1616
|
-
* The element with all prop expressions resolved, and the bound writes it
|
|
1617
|
-
* was resolved after.
|
|
1618
|
-
*
|
|
1619
|
-
* Resolving builds a new props object every time, so a result that holds
|
|
1620
|
-
* the same values as the last one counts as unchanged and keeps the old
|
|
1621
|
-
* object; otherwise one keystroke would re-run every catalog template that
|
|
1622
|
-
* happened to resolve.
|
|
1623
|
-
*
|
|
1624
|
-
* A write to a path the element is bound to always counts, though. Its
|
|
1625
|
-
* component writes the user's input into the DOM before it reaches state,
|
|
1626
|
-
* so the DOM can differ from a prop whose value never did: typing and a
|
|
1627
|
-
* `clearStatePath` in the same turn take the prop from '' through 'milk'
|
|
1628
|
-
* back to '' with nobody reading 'milk'. The README tells catalog authors
|
|
1629
|
-
* to sync such inputs with an effect on props, and that effect only gets to
|
|
1630
|
-
* put the DOM right if it runs. Writes anywhere else compare by value, like
|
|
1631
|
-
* any element's.
|
|
1632
|
-
*/
|
|
1633
|
-
resolution = computed(() => {
|
|
1634
|
-
const boundWrites = this.boundWrites();
|
|
1635
|
-
const el = this.rawElement();
|
|
1636
|
-
if (!el)
|
|
1637
|
-
return { element: undefined, boundWrites };
|
|
1638
|
-
return {
|
|
1639
|
-
element: {
|
|
1640
|
-
...el,
|
|
1641
|
-
props: resolveElementProps(el.props ?? {}, this.resolutionCtx()),
|
|
1642
|
-
},
|
|
1643
|
-
boundWrites,
|
|
1644
|
-
};
|
|
1645
|
-
}, { ...(ngDevMode ? { debugName: "resolution" } : /* istanbul ignore next */ {}), equal: (a, b) => {
|
|
1646
|
-
if (a.boundWrites !== b.boundWrites)
|
|
1647
|
-
return false;
|
|
1648
|
-
if (a.element === b.element)
|
|
1649
|
-
return true;
|
|
1650
|
-
if (!a.element || !b.element)
|
|
1628
|
+
}
|
|
1629
|
+
return understood;
|
|
1630
|
+
}
|
|
1631
|
+
template(template) {
|
|
1632
|
+
for (const [, placeholder] of template.matchAll(/\$\{([^}]+)\}/g)) {
|
|
1633
|
+
if (placeholder.startsWith('/')) {
|
|
1634
|
+
if (!this.state(placeholder))
|
|
1635
|
+
return false;
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
// Core tries the repeat item first and falls back to a state path of
|
|
1639
|
+
// the same name when the item has nothing there.
|
|
1640
|
+
if (!this.item(placeholder) || !this.state(`/${placeholder}`)) {
|
|
1651
1641
|
return false;
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
const el = this.rawElement();
|
|
1663
|
-
return el
|
|
1664
|
-
? resolveBindings(el.props ?? {}, this.resolutionCtx())
|
|
1665
|
-
: undefined;
|
|
1666
|
-
}, { ...(ngDevMode ? { debugName: "bindings" } : /* istanbul ignore next */ {}), equal: (a, b) => a === b || (!!a && !!b && sameValues(a, b, true)) });
|
|
1667
|
-
entry = computed(() => {
|
|
1668
|
-
const el = this.rawElement();
|
|
1669
|
-
return el ? this.root.resolveEntry(el.type) : undefined;
|
|
1670
|
-
}, ...(ngDevMode ? [{ debugName: "entry" }] : /* istanbul ignore next */ []));
|
|
1671
|
-
component = computed(() => this.entry()?.component ?? null, ...(ngDevMode ? [{ debugName: "component" }] : /* istanbul ignore next */ []));
|
|
1672
|
-
devtoolsKey = computed(() => this.devtoolsActive() ? this.elementKey() : null, ...(ngDevMode ? [{ debugName: "devtoolsKey" }] : /* istanbul ignore next */ []));
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return true;
|
|
1645
|
+
}
|
|
1646
|
+
state(path) {
|
|
1647
|
+
if (path === '' || path === '/')
|
|
1648
|
+
return false;
|
|
1649
|
+
this.paths.add(path);
|
|
1650
|
+
return true;
|
|
1651
|
+
}
|
|
1673
1652
|
/**
|
|
1674
|
-
*
|
|
1675
|
-
*
|
|
1676
|
-
* without the renderer holding a registry of who is drawing what.
|
|
1653
|
+
* A `$bindItem`, joined the way core joins it — which is not quite how it
|
|
1654
|
+
* joins an `$item` read. Outside a repeat it resolves to nothing.
|
|
1677
1655
|
*/
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
};
|
|
1686
|
-
}, ...(ngDevMode ? [{ debugName: "path" }] : /* istanbul ignore next */ []));
|
|
1656
|
+
boundItem(itemPath) {
|
|
1657
|
+
if (this.repeatBasePath === undefined)
|
|
1658
|
+
return true;
|
|
1659
|
+
return this.state(itemPath === ''
|
|
1660
|
+
? this.repeatBasePath
|
|
1661
|
+
: `${this.repeatBasePath}/${itemPath}`);
|
|
1662
|
+
}
|
|
1687
1663
|
/**
|
|
1688
|
-
*
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
* element that renders itself without reading any deeper renders forever,
|
|
1692
|
-
* and a spec that never stops rendering takes the tab with it; refusing to
|
|
1693
|
-
* draw the repetition is the only outcome that leaves anything on screen.
|
|
1694
|
-
* It costs one walk up a chain of parents, bounded by `maxDepth` where one
|
|
1695
|
-
* is set and by the spec's own nesting where it is not.
|
|
1664
|
+
* A read of the repeat item, as the state path it sits at. The item value
|
|
1665
|
+
* reaches the element on its own, but only as the same object when an
|
|
1666
|
+
* external store edits it in place, so the path is what notices the edit.
|
|
1696
1667
|
*/
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1668
|
+
item(itemPath) {
|
|
1669
|
+
if (this.repeatBasePath === undefined)
|
|
1670
|
+
return true;
|
|
1671
|
+
const path = resolveRepeatStatePath({ $item: itemPath }, this.repeatBasePath);
|
|
1672
|
+
return path !== undefined && this.state(path);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* The render context of the element currently being rendered.
|
|
1678
|
+
* Provided by the renderer for every catalog component instance.
|
|
1679
|
+
*/
|
|
1680
|
+
const RENDER_CONTEXT = new InjectionToken('ngx-json-render RENDER_CONTEXT');
|
|
1681
|
+
/**
|
|
1682
|
+
* The spec key of the element currently being rendered. Provided alongside
|
|
1683
|
+
* {@link RENDER_CONTEXT} for every catalog component instance.
|
|
1684
|
+
*/
|
|
1685
|
+
const ELEMENT_KEY = new InjectionToken('ngx-json-render ELEMENT_KEY');
|
|
1686
|
+
/**
|
|
1687
|
+
* The current repeat scope. Present only for elements rendered inside a
|
|
1688
|
+
* `repeat` block.
|
|
1689
|
+
*/
|
|
1690
|
+
const REPEAT_SCOPE = new InjectionToken('ngx-json-render REPEAT_SCOPE');
|
|
1691
|
+
/**
|
|
1692
|
+
* This element's position in the render tree. Provided by `<jr-element>` for
|
|
1693
|
+
* everything it renders below itself, so each element can see its own depth
|
|
1694
|
+
* and its own ancestry.
|
|
1695
|
+
*
|
|
1696
|
+
* @internal Not part of the public API: it exists so the renderer can refuse
|
|
1697
|
+
* to draw an element that is its own ancestor, and to enforce
|
|
1698
|
+
* `renderLimits.maxDepth`.
|
|
1699
|
+
*/
|
|
1700
|
+
const RENDER_PATH = new InjectionToken('ngx-json-render RENDER_PATH');
|
|
1701
|
+
/**
|
|
1702
|
+
* Whether an element that skipped a state write checks that it was right to:
|
|
1703
|
+
* that resolving it against the whole state would not give anything else.
|
|
1704
|
+
* On in dev mode, off in production.
|
|
1705
|
+
*
|
|
1706
|
+
* @internal Not part of the public API. The check calls `$computed`
|
|
1707
|
+
* functions again, so tests that count those calls turn it off.
|
|
1708
|
+
*/
|
|
1709
|
+
const CHECK_SKIPPED_WRITES = new InjectionToken('ngx-json-render CHECK_SKIPPED_WRITES', { providedIn: 'root', factory: () => isDevMode() });
|
|
1710
|
+
/**
|
|
1711
|
+
* Inject the render context inside a catalog component.
|
|
1712
|
+
*
|
|
1713
|
+
* @example
|
|
1714
|
+
* ```ts
|
|
1715
|
+
* @Component({
|
|
1716
|
+
* selector: 'app-button',
|
|
1717
|
+
* template: `<button (click)="ctx.emit('press')">{{ ctx.props().label }}</button>`,
|
|
1718
|
+
* })
|
|
1719
|
+
* export class ButtonComponent {
|
|
1720
|
+
* readonly ctx = injectRenderContext<{ label: string }>();
|
|
1721
|
+
* }
|
|
1722
|
+
* ```
|
|
1723
|
+
*/
|
|
1724
|
+
function injectRenderContext() {
|
|
1725
|
+
const ctx = inject(RENDER_CONTEXT, { optional: true });
|
|
1726
|
+
if (!ctx) {
|
|
1727
|
+
throw new Error('injectRenderContext() must be used inside a component rendered by <json-render>');
|
|
1728
|
+
}
|
|
1729
|
+
return ctx;
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Inject the spec key of the element this component renders.
|
|
1733
|
+
*
|
|
1734
|
+
* Catalog components that collect their children — a tab group discovering
|
|
1735
|
+
* its tabs, for instance — need it to order registrations by the spec's
|
|
1736
|
+
* `children` array rather than by the order the children happened to
|
|
1737
|
+
* announce themselves in.
|
|
1738
|
+
*
|
|
1739
|
+
* @example
|
|
1740
|
+
* ```ts
|
|
1741
|
+
* const key = injectElementKey();
|
|
1742
|
+
* const index = computed(() => parent.element().children?.indexOf(key()) ?? -1);
|
|
1743
|
+
* ```
|
|
1744
|
+
*/
|
|
1745
|
+
function injectElementKey() {
|
|
1746
|
+
const key = inject(ELEMENT_KEY, { optional: true });
|
|
1747
|
+
if (!key) {
|
|
1748
|
+
throw new Error('injectElementKey() must be used inside a component rendered by <json-render>');
|
|
1749
|
+
}
|
|
1750
|
+
return key;
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* Inject the current repeat scope, or `null` when the component is not
|
|
1754
|
+
* rendered inside a `repeat` block.
|
|
1755
|
+
*/
|
|
1756
|
+
function injectRepeatScope() {
|
|
1757
|
+
return inject(REPEAT_SCOPE, { optional: true });
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
const warnedSlots = new Set();
|
|
1761
|
+
/**
|
|
1762
|
+
* Report a failure from a fire-and-forget action dispatch. Cancelling a
|
|
1763
|
+
* confirmation dialog is a normal outcome, not an error, so it stays silent.
|
|
1764
|
+
*/
|
|
1765
|
+
function reportActionError(error) {
|
|
1766
|
+
if (isActionCancelled(error))
|
|
1767
|
+
return;
|
|
1768
|
+
console.error(error);
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Whether `inner` reads strictly deeper into state than `outer` — the state
|
|
1772
|
+
* path of one repeat item nested inside another's.
|
|
1773
|
+
*
|
|
1774
|
+
* `null` is the world outside any repeat, so stepping into one is always
|
|
1775
|
+
* progress. Below that it is a prefix test on the resolved path, and the
|
|
1776
|
+
* trailing separator is what keeps `/tree/10` from counting as a step inside
|
|
1777
|
+
* `/tree/1`.
|
|
1778
|
+
*/
|
|
1779
|
+
function descendsInto(outer, inner) {
|
|
1780
|
+
if (inner === null)
|
|
1781
|
+
return false;
|
|
1782
|
+
if (outer === null)
|
|
1783
|
+
return true;
|
|
1784
|
+
return inner.startsWith(`${outer}/`);
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Whether this element renders itself again without getting anywhere — the
|
|
1788
|
+
* one arrangement that cannot terminate.
|
|
1789
|
+
*
|
|
1790
|
+
* An element reaching itself is not by itself a defect. It is how a tree gets
|
|
1791
|
+
* drawn — a comment thread, a file browser, a nested menu — where each pass
|
|
1792
|
+
* repeats over a path relative to the item it is already inside and so reads
|
|
1793
|
+
* one level further into the data. That ends when the data does.
|
|
1794
|
+
*
|
|
1795
|
+
* What separates the two is whether the repeat descended. Compare against the
|
|
1796
|
+
* nearest occurrence above: `/tree/0` then `/tree/0/children/1` is a tree
|
|
1797
|
+
* walking down, while `/items/0` then `/items/1` is a spec repeating over a
|
|
1798
|
+
* fixed array inside itself, and `/items/0` twice is one with no repeat
|
|
1799
|
+
* between the passes at all. Only the first can run out.
|
|
1800
|
+
*/
|
|
1801
|
+
function repeatsItself(path) {
|
|
1802
|
+
for (let step = path.parent; step !== null; step = step.parent) {
|
|
1803
|
+
if (step.key !== path.key)
|
|
1804
|
+
continue;
|
|
1805
|
+
return !descendsInto(step.scopePath, path.scopePath);
|
|
1806
|
+
}
|
|
1807
|
+
return false;
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Whether two objects hold the same values under the same keys.
|
|
1811
|
+
*
|
|
1812
|
+
* `objectsByIdentity` decides what the same object or array reference means.
|
|
1813
|
+
* The internal store copies every path it writes and never touches a snapshot
|
|
1814
|
+
* it has handed out, so there the same reference is the same content. An
|
|
1815
|
+
* external store may write into its snapshot in place, so there only
|
|
1816
|
+
* primitives can be trusted to compare equal, and any object value counts as
|
|
1817
|
+
* changed.
|
|
1818
|
+
*/
|
|
1819
|
+
function sameValues(a, b, objectsByIdentity) {
|
|
1820
|
+
const keys = Object.keys(a);
|
|
1821
|
+
if (keys.length !== Object.keys(b).length)
|
|
1822
|
+
return false;
|
|
1823
|
+
for (const key of keys) {
|
|
1824
|
+
if (!Object.hasOwn(b, key))
|
|
1825
|
+
return false;
|
|
1826
|
+
const value = a[key];
|
|
1827
|
+
if (!Object.is(value, b[key]))
|
|
1828
|
+
return false;
|
|
1829
|
+
if (!objectsByIdentity && typeof value === 'object' && value !== null) {
|
|
1830
|
+
return false;
|
|
1769
1831
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1832
|
+
}
|
|
1833
|
+
return true;
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Whether two resolved values hold the same content, however they are built.
|
|
1837
|
+
*
|
|
1838
|
+
* For the dev-mode check on skipped writes, which compares a fresh resolution
|
|
1839
|
+
* with an old one: a `$computed` function returns a new object every time,
|
|
1840
|
+
* so identity says nothing there. What it can't see into — functions, a `Map`,
|
|
1841
|
+
* a class instance — it counts as the same, because a check that warns about
|
|
1842
|
+
* a correct element is worse than one that misses a case.
|
|
1843
|
+
*/
|
|
1844
|
+
function sameContent(a, b, seen = new Set()) {
|
|
1845
|
+
if (Object.is(a, b))
|
|
1846
|
+
return true;
|
|
1847
|
+
if (typeof a === 'function' && typeof b === 'function')
|
|
1848
|
+
return true;
|
|
1849
|
+
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b)
|
|
1850
|
+
return false;
|
|
1851
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b))
|
|
1852
|
+
return false;
|
|
1853
|
+
if (a instanceof Date)
|
|
1854
|
+
return a.getTime() === b.getTime();
|
|
1855
|
+
const plain = Array.isArray(a) || Object.getPrototypeOf(a) === Object.prototype;
|
|
1856
|
+
if (!plain || seen.has(a))
|
|
1857
|
+
return true;
|
|
1858
|
+
seen.add(a);
|
|
1859
|
+
const keys = Object.keys(a);
|
|
1860
|
+
if (keys.length !== Object.keys(b).length)
|
|
1861
|
+
return false;
|
|
1862
|
+
return keys.every((key) => Object.hasOwn(b, key) &&
|
|
1863
|
+
sameContent(a[key], b[key], seen));
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Whether a state write at `written` can change the value at `bound`: the
|
|
1867
|
+
* same path, a container above it, or a path inside it.
|
|
1868
|
+
*/
|
|
1869
|
+
function touches(written, bound) {
|
|
1870
|
+
return (written === bound ||
|
|
1871
|
+
bound.startsWith(`${written}/`) ||
|
|
1872
|
+
written.startsWith(`${bound}/`));
|
|
1873
|
+
}
|
|
1874
|
+
/** Element keys already warned about by the check on skipped writes. */
|
|
1875
|
+
const warnedSkippedWrites = new Set();
|
|
1876
|
+
const EMPTY_READS = new Set();
|
|
1877
|
+
function sameReads(a, b) {
|
|
1878
|
+
if (a === b)
|
|
1879
|
+
return true;
|
|
1880
|
+
if (!a || !b || a.size !== b.size)
|
|
1881
|
+
return false;
|
|
1882
|
+
for (const path of a)
|
|
1883
|
+
if (!b.has(path))
|
|
1884
|
+
return false;
|
|
1885
|
+
return true;
|
|
1886
|
+
}
|
|
1887
|
+
/** The chain from the root down to `path`, as a line a human can follow. */
|
|
1888
|
+
function describePath(path) {
|
|
1889
|
+
const keys = [];
|
|
1890
|
+
for (let step = path; step !== null; step = step.parent) {
|
|
1891
|
+
keys.push(step.key);
|
|
1892
|
+
}
|
|
1893
|
+
return keys.reverse().join(' \u2192 ');
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Renders a single spec element: evaluates visibility, resolves prop
|
|
1897
|
+
* expressions against the state model, wires event/watch bindings, and
|
|
1898
|
+
* instantiates the catalog component with a {@link RenderContext} injector.
|
|
1899
|
+
*
|
|
1900
|
+
* @internal Used by `<json-render>` and `<jr-children>`.
|
|
1901
|
+
*/
|
|
1902
|
+
class JrElement {
|
|
1903
|
+
elementKey = input.required(...(ngDevMode ? [{ debugName: "elementKey" }] : /* istanbul ignore next */ []));
|
|
1904
|
+
root = inject(JsonRenderRootContext);
|
|
1905
|
+
state = inject(JsonRenderStateService);
|
|
1906
|
+
actions = inject(JsonRenderActionsService);
|
|
1907
|
+
repeatScope = inject(REPEAT_SCOPE, { optional: true });
|
|
1908
|
+
parentPath = inject(RENDER_PATH, { optional: true });
|
|
1909
|
+
devtoolsActive = injectDevtoolsActive();
|
|
1910
|
+
/**
|
|
1911
|
+
* The raw (unresolved) element from the spec.
|
|
1912
|
+
*
|
|
1913
|
+
* Compared by content, not identity. A streamed patch shares every element
|
|
1914
|
+
* it did not touch, and the identity check settles those at once; but a
|
|
1915
|
+
* spec can also arrive as all-new objects — a whole-spec part, a host that
|
|
1916
|
+
* fetches it again, `buildSpecFromParts` replaying a message — and then an
|
|
1917
|
+
* element whose content is unchanged would resolve, and every container
|
|
1918
|
+
* with a fresh `children` array re-render, for nothing.
|
|
1919
|
+
*/
|
|
1920
|
+
rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], { ...(ngDevMode ? { debugName: "rawElement" } : /* istanbul ignore next */ {}), equal: (a, b) => sameJson(a, b) });
|
|
1921
|
+
/**
|
|
1922
|
+
* The state paths this element's expressions can read, or null when the
|
|
1923
|
+
* spec doesn't say — see {@link collectStateReads} for when that is.
|
|
1924
|
+
*/
|
|
1925
|
+
stateReads = computed(() => {
|
|
1926
|
+
const el = this.rawElement();
|
|
1927
|
+
if (!el)
|
|
1928
|
+
return EMPTY_READS;
|
|
1929
|
+
return collectStateReads(el, this.root.directiveRegistry(), this.repeatScope?.basePath());
|
|
1930
|
+
}, { ...(ngDevMode ? { debugName: "stateReads" } : /* istanbul ignore next */ {}), equal: sameReads });
|
|
1931
|
+
/**
|
|
1932
|
+
* One signal per path this element reads, each holding the value at that
|
|
1933
|
+
* path. They are rebuilt only when the paths change, and a cell that stays
|
|
1934
|
+
* keeps its last value, so a path that didn't change is not a change.
|
|
1935
|
+
*
|
|
1936
|
+
* A cell compares like a resolved prop does: primitives by value, objects
|
|
1937
|
+
* by reference with the internal store (which copies every path it writes)
|
|
1938
|
+
* and never equal with an external `store`, which may write into its
|
|
1939
|
+
* snapshot in place.
|
|
1940
|
+
*/
|
|
1941
|
+
stateCells = (() => {
|
|
1942
|
+
let cells = new Map();
|
|
1943
|
+
return computed(() => {
|
|
1944
|
+
const reads = this.stateReads();
|
|
1945
|
+
if (reads === null)
|
|
1946
|
+
return null;
|
|
1947
|
+
const next = new Map();
|
|
1948
|
+
for (const path of reads) {
|
|
1949
|
+
next.set(path, cells.get(path) ??
|
|
1950
|
+
computed(() => getByPath(this.state.state(), path), {
|
|
1951
|
+
equal: (a, b) => Object.is(a, b) &&
|
|
1952
|
+
(typeof a !== 'object' ||
|
|
1953
|
+
a === null ||
|
|
1954
|
+
!untracked(this.root.store)),
|
|
1955
|
+
}));
|
|
1813
1956
|
}
|
|
1957
|
+
cells = next;
|
|
1958
|
+
return next;
|
|
1814
1959
|
});
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1960
|
+
})();
|
|
1961
|
+
/**
|
|
1962
|
+
* How many times {@link readableState} has taken a snapshot. The dev-mode
|
|
1963
|
+
* check compares it between state writes to tell which ones were skipped.
|
|
1964
|
+
*/
|
|
1965
|
+
snapshotsTaken = 0;
|
|
1966
|
+
/**
|
|
1967
|
+
* The state to resolve against: the whole snapshot, but only as fresh as
|
|
1968
|
+
* the paths this element reads. A write elsewhere doesn't reach it, so the
|
|
1969
|
+
* element doesn't resolve anything for it. An element whose reads are
|
|
1970
|
+
* unknown follows every write, as all elements did before.
|
|
1971
|
+
*/
|
|
1972
|
+
readableState = computed(() => {
|
|
1973
|
+
this.snapshotsTaken++;
|
|
1974
|
+
const cells = this.stateCells();
|
|
1975
|
+
if (cells === null)
|
|
1976
|
+
return this.state.state();
|
|
1977
|
+
for (const cell of cells.values())
|
|
1978
|
+
cell();
|
|
1979
|
+
return untracked(this.state.state);
|
|
1980
|
+
}, { ...(ngDevMode ? { debugName: "readableState" } : /* istanbul ignore next */ {}), equal: () => false });
|
|
1981
|
+
/** Prop/visibility resolution context (state + repeat scope + extensions). */
|
|
1982
|
+
resolutionCtx = computed(() => {
|
|
1983
|
+
const scope = this.repeatScope;
|
|
1984
|
+
return {
|
|
1985
|
+
stateModel: this.readableState(),
|
|
1986
|
+
...(scope
|
|
1987
|
+
? {
|
|
1988
|
+
repeatItem: scope.item(),
|
|
1989
|
+
repeatIndex: scope.index(),
|
|
1990
|
+
repeatBasePath: scope.basePath(),
|
|
1991
|
+
}
|
|
1992
|
+
: {}),
|
|
1993
|
+
functions: this.root.functions() ?? {},
|
|
1994
|
+
directives: this.root.directiveRegistry(),
|
|
1995
|
+
};
|
|
1996
|
+
}, ...(ngDevMode ? [{ debugName: "resolutionCtx" }] : /* istanbul ignore next */ []));
|
|
1997
|
+
visible = computed(() => {
|
|
1998
|
+
const el = this.rawElement();
|
|
1999
|
+
if (!el || el.visible === undefined)
|
|
2000
|
+
return true;
|
|
2001
|
+
return evaluateVisibility(el.visible, this.resolutionCtx());
|
|
2002
|
+
}, ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
|
|
2003
|
+
/**
|
|
2004
|
+
* How many state writes have touched a path this element is two-way bound
|
|
2005
|
+
* to. See {@link resolution} for why those writes count on their own.
|
|
2006
|
+
*/
|
|
2007
|
+
boundWrites = signal(0, ...(ngDevMode ? [{ debugName: "boundWrites" }] : /* istanbul ignore next */ []));
|
|
1846
2008
|
/**
|
|
1847
|
-
*
|
|
1848
|
-
*
|
|
2009
|
+
* The element with all prop expressions resolved, and the bound writes it
|
|
2010
|
+
* was resolved after.
|
|
1849
2011
|
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
2012
|
+
* Resolving builds a new props object every time, so a result that holds
|
|
2013
|
+
* the same values as the last one counts as unchanged and keeps the old
|
|
2014
|
+
* object; otherwise one keystroke would re-run every catalog template that
|
|
2015
|
+
* happened to resolve.
|
|
2016
|
+
*
|
|
2017
|
+
* A write to a path the element is bound to always counts, though. Its
|
|
2018
|
+
* component writes the user's input into the DOM before it reaches state,
|
|
2019
|
+
* so the DOM can differ from a prop whose value never did: typing and a
|
|
2020
|
+
* `clearStatePath` in the same turn take the prop from '' through 'milk'
|
|
2021
|
+
* back to '' with nobody reading 'milk'. The README tells catalog authors
|
|
2022
|
+
* to sync such inputs with an effect on props, and that effect only gets to
|
|
2023
|
+
* put the DOM right if it runs. Writes anywhere else compare by value, like
|
|
2024
|
+
* any element's.
|
|
1852
2025
|
*/
|
|
1853
|
-
|
|
1854
|
-
const
|
|
2026
|
+
resolution = computed(() => {
|
|
2027
|
+
const boundWrites = this.boundWrites();
|
|
1855
2028
|
const el = this.rawElement();
|
|
1856
|
-
if (!el
|
|
1857
|
-
return;
|
|
1858
|
-
const ctx = this.liveResolutionCtx();
|
|
1859
|
-
let props;
|
|
1860
|
-
let visible;
|
|
1861
|
-
try {
|
|
1862
|
-
props = resolveElementProps(el.props ?? {}, ctx);
|
|
1863
|
-
visible = el.visible === undefined || evaluateVisibility(el.visible, ctx);
|
|
1864
|
-
}
|
|
1865
|
-
catch {
|
|
1866
|
-
return;
|
|
1867
|
-
}
|
|
1868
|
-
const differs = Object.keys({ ...props, ...shown.props }).filter((prop) => !sameContent(props[prop], shown.props[prop]));
|
|
1869
|
-
if (visible !== shownVisible)
|
|
1870
|
-
differs.push('visible');
|
|
1871
|
-
if (differs.length === 0)
|
|
1872
|
-
return;
|
|
1873
|
-
warnedSkippedWrites.add(key);
|
|
1874
|
-
const paths = reads.size > 0 ? [...reads].join(', ') : 'no state paths';
|
|
1875
|
-
console.warn(`[ngx-json-render] "${key}" did not resolve again after a state write, but resolving it now changes ${differs.join(', ')}. It resolves only when a state path it reads changes, and it reads ${paths}. Either a $computed function in it depends on something besides its args, or the renderer missed a read — please report the spec at https://github.com/shteynu/ngx-json-render/issues. This check runs in dev mode only.`);
|
|
1876
|
-
}
|
|
1877
|
-
/**
|
|
1878
|
-
* Resolution context with a live state snapshot, so `$state` references in
|
|
1879
|
-
* later actions of a chain see mutations from earlier ones.
|
|
1880
|
-
*/
|
|
1881
|
-
liveResolutionCtx() {
|
|
2029
|
+
if (!el)
|
|
2030
|
+
return { element: undefined, boundWrites };
|
|
1882
2031
|
return {
|
|
1883
|
-
|
|
1884
|
-
|
|
2032
|
+
element: {
|
|
2033
|
+
...el,
|
|
2034
|
+
props: resolveElementProps(el.props ?? {}, this.resolutionCtx()),
|
|
2035
|
+
},
|
|
2036
|
+
boundWrites,
|
|
1885
2037
|
};
|
|
1886
|
-
}
|
|
2038
|
+
}, { ...(ngDevMode ? { debugName: "resolution" } : /* istanbul ignore next */ {}), equal: (a, b) => {
|
|
2039
|
+
if (a.boundWrites !== b.boundWrites)
|
|
2040
|
+
return false;
|
|
2041
|
+
if (a.element === b.element)
|
|
2042
|
+
return true;
|
|
2043
|
+
if (!a.element || !b.element)
|
|
2044
|
+
return false;
|
|
2045
|
+
const { props: aProps, ...aRest } = a.element;
|
|
2046
|
+
const { props: bProps, ...bRest } = b.element;
|
|
2047
|
+
// Everything but props is the spec element itself, never state.
|
|
2048
|
+
return (sameValues(aRest, bRest, true) &&
|
|
2049
|
+
sameValues(aProps, bProps, !untracked(this.root.store)));
|
|
2050
|
+
} });
|
|
2051
|
+
/** The element with all prop expressions resolved. */
|
|
2052
|
+
resolvedElement = computed(() => this.resolution().element, ...(ngDevMode ? [{ debugName: "resolvedElement" }] : /* istanbul ignore next */ []));
|
|
2053
|
+
/** Two-way binding paths ($bindState / $bindItem) by prop name. */
|
|
2054
|
+
bindings = computed(() => {
|
|
2055
|
+
const el = this.rawElement();
|
|
2056
|
+
return el
|
|
2057
|
+
? resolveBindings(el.props ?? {}, this.resolutionCtx())
|
|
2058
|
+
: undefined;
|
|
2059
|
+
}, { ...(ngDevMode ? { debugName: "bindings" } : /* istanbul ignore next */ {}), equal: (a, b) => a === b || (!!a && !!b && sameValues(a, b, true)) });
|
|
2060
|
+
entry = computed(() => {
|
|
2061
|
+
const el = this.rawElement();
|
|
2062
|
+
return el ? this.root.resolveEntry(el.type) : undefined;
|
|
2063
|
+
}, ...(ngDevMode ? [{ debugName: "entry" }] : /* istanbul ignore next */ []));
|
|
2064
|
+
/** Set once the real component has been shown; see {@link held}. */
|
|
2065
|
+
mounted = false;
|
|
1887
2066
|
/**
|
|
1888
|
-
*
|
|
1889
|
-
*
|
|
1890
|
-
*
|
|
2067
|
+
* Whether the component waits for its props: the spec is still loading, a
|
|
2068
|
+
* catalog was given, and the props written so far do not pass the
|
|
2069
|
+
* component's schema (see {@link propsArrived}).
|
|
2070
|
+
*
|
|
2071
|
+
* One-way. Once the component has been shown it is never swapped back for
|
|
2072
|
+
* the fallback, even if a later patch takes a prop away again: a component
|
|
2073
|
+
* flickering out mid-stream is worse than one showing a gap. Once latched
|
|
2074
|
+
* this reads no signals, so a mounted element pays nothing for the gate on
|
|
2075
|
+
* later patches — and neither does any element when nothing is loading.
|
|
1891
2076
|
*/
|
|
1892
|
-
|
|
1893
|
-
this.
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
const
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2077
|
+
held = computed(() => {
|
|
2078
|
+
if (this.mounted)
|
|
2079
|
+
return false;
|
|
2080
|
+
const el = this.rawElement();
|
|
2081
|
+
const catalog = this.root.catalog();
|
|
2082
|
+
const held = !!el && !!catalog && this.root.loading() && !propsArrived(el, catalog);
|
|
2083
|
+
if (!held && this.entry() && this.visible() && !this.refusal()) {
|
|
2084
|
+
this.mounted = true;
|
|
2085
|
+
}
|
|
2086
|
+
return held;
|
|
2087
|
+
}, ...(ngDevMode ? [{ debugName: "held" }] : /* istanbul ignore next */ []));
|
|
2088
|
+
/** What the outlet shows: the component, or its fallback while held. */
|
|
2089
|
+
component = computed(() => this.held()
|
|
2090
|
+
? (this.entry()?.fallback ?? null)
|
|
2091
|
+
: (this.entry()?.component ?? null), ...(ngDevMode ? [{ debugName: "component" }] : /* istanbul ignore next */ []));
|
|
2092
|
+
devtoolsKey = computed(() => this.devtoolsActive() ? this.elementKey() : null, ...(ngDevMode ? [{ debugName: "devtoolsKey" }] : /* istanbul ignore next */ []));
|
|
1900
2093
|
/**
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
* the
|
|
2094
|
+
* Where this element sits in the tree. Passed down to everything it renders
|
|
2095
|
+
* below itself, which is what lets each element see its own ancestry
|
|
2096
|
+
* without the renderer holding a registry of who is drawing what.
|
|
1904
2097
|
*/
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
if (!b.params) {
|
|
1908
|
-
await this.actions.execute(b);
|
|
1909
|
-
continue;
|
|
1910
|
-
}
|
|
1911
|
-
const liveCtx = this.liveResolutionCtx();
|
|
1912
|
-
const resolved = {};
|
|
1913
|
-
for (const [key, val] of Object.entries(b.params)) {
|
|
1914
|
-
resolved[key] = resolveActionParam(val, liveCtx);
|
|
1915
|
-
}
|
|
1916
|
-
await this.actions.execute({ ...b, params: resolved });
|
|
1917
|
-
}
|
|
1918
|
-
}
|
|
1919
|
-
eventHandle(eventName) {
|
|
1920
|
-
const el = this.rawElement();
|
|
1921
|
-
const binding = el?.on?.[eventName];
|
|
1922
|
-
if (!binding) {
|
|
1923
|
-
return { emit: () => { }, shouldPreventDefault: false, bound: false };
|
|
1924
|
-
}
|
|
1925
|
-
const actionBindings = Array.isArray(binding) ? binding : [binding];
|
|
2098
|
+
path = computed(() => {
|
|
2099
|
+
const parent = this.parentPath?.() ?? null;
|
|
1926
2100
|
return {
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
2101
|
+
key: this.elementKey(),
|
|
2102
|
+
depth: (parent?.depth ?? 0) + 1,
|
|
2103
|
+
scopePath: this.repeatScope?.basePath() ?? null,
|
|
2104
|
+
parent,
|
|
1930
2105
|
};
|
|
1931
|
-
}
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
while (stack.length > 0) {
|
|
2027
|
-
const frame = stack[stack.length - 1];
|
|
2028
|
-
if (frame.next < frame.kids.length) {
|
|
2029
|
-
const childKey = frame.kids[frame.next++];
|
|
2030
|
-
const memo = depthBelow.get(childKey);
|
|
2031
|
-
if (memo !== undefined) {
|
|
2032
|
-
frame.deepest = Math.max(frame.deepest, memo);
|
|
2033
|
-
continue;
|
|
2106
|
+
}, ...(ngDevMode ? [{ debugName: "path" }] : /* istanbul ignore next */ []));
|
|
2107
|
+
/**
|
|
2108
|
+
* Why this element is not drawn, or null when it is.
|
|
2109
|
+
*
|
|
2110
|
+
* The cycle half is unconditional — not something `validate` turns on. An
|
|
2111
|
+
* element that renders itself without reading any deeper renders forever,
|
|
2112
|
+
* and a spec that never stops rendering takes the tab with it; refusing to
|
|
2113
|
+
* draw the repetition is the only outcome that leaves anything on screen.
|
|
2114
|
+
* It costs one walk up a chain of parents, bounded by `maxDepth` where one
|
|
2115
|
+
* is set and by the spec's own nesting where it is not.
|
|
2116
|
+
*/
|
|
2117
|
+
refusal = computed(() => {
|
|
2118
|
+
const path = this.path();
|
|
2119
|
+
if (repeatsItself(path))
|
|
2120
|
+
return 'cycle';
|
|
2121
|
+
const maxDepth = this.root.limits()?.maxDepth;
|
|
2122
|
+
if (maxDepth !== undefined && path.depth > maxDepth)
|
|
2123
|
+
return 'depth';
|
|
2124
|
+
return null;
|
|
2125
|
+
}, ...(ngDevMode ? [{ debugName: "refusal" }] : /* istanbul ignore next */ []));
|
|
2126
|
+
renderCtx = {
|
|
2127
|
+
element: this.resolvedElement,
|
|
2128
|
+
props: computed(() => this.resolvedElement()?.props ?? {}),
|
|
2129
|
+
emit: (event) => this.fireEvent(event),
|
|
2130
|
+
on: (event) => this.eventHandle(event),
|
|
2131
|
+
bindings: this.bindings,
|
|
2132
|
+
loading: computed(() => this.root.loading()),
|
|
2133
|
+
setBound: (prop, value) => {
|
|
2134
|
+
const path = untracked(this.bindings)?.[prop];
|
|
2135
|
+
if (path) {
|
|
2136
|
+
this.state.set(path, value);
|
|
2137
|
+
}
|
|
2138
|
+
else if (isDevMode()) {
|
|
2139
|
+
console.warn(`[ngx-json-render] setBound("${prop}"): prop has no $bindState/$bindItem binding`);
|
|
2140
|
+
}
|
|
2141
|
+
},
|
|
2142
|
+
};
|
|
2143
|
+
// Typed as Injector for the peer range: see `confirmInjector` in
|
|
2144
|
+
// renderer.component.ts.
|
|
2145
|
+
outletInjector = Injector.create({
|
|
2146
|
+
providers: [
|
|
2147
|
+
{ provide: RENDER_CONTEXT, useValue: this.renderCtx },
|
|
2148
|
+
{ provide: ELEMENT_KEY, useValue: this.elementKey },
|
|
2149
|
+
{ provide: RENDER_PATH, useValue: this.path },
|
|
2150
|
+
],
|
|
2151
|
+
parent: inject(Injector),
|
|
2152
|
+
});
|
|
2153
|
+
constructor() {
|
|
2154
|
+
// Warn (once) when this element is refused. Both reasons are silent
|
|
2155
|
+
// failures on screen — something the spec asked for is missing — so
|
|
2156
|
+
// neither should be silent in the console.
|
|
2157
|
+
let warnedRefusal = false;
|
|
2158
|
+
effect(() => {
|
|
2159
|
+
const reason = this.refusal();
|
|
2160
|
+
if (!reason || warnedRefusal)
|
|
2161
|
+
return;
|
|
2162
|
+
warnedRefusal = true;
|
|
2163
|
+
const path = untracked(this.path);
|
|
2164
|
+
if (reason === 'cycle') {
|
|
2165
|
+
console.warn(`[ngx-json-render] Cycle in the spec: ${describePath(path)}. "${path.key}" renders itself again without reading any deeper into state${path.scopePath === null ? '' : ` (still at "${path.scopePath}")`}, so it never ends. It is not rendered again; everything above it still is. A tree that recurses needs a repeat with a relative statePath, like {"$item": "children"}.`);
|
|
2166
|
+
}
|
|
2167
|
+
else {
|
|
2168
|
+
console.warn(`[ngx-json-render] renderLimits.maxDepth (${untracked(this.root.limits)?.maxDepth}) reached at ${describePath(path)}. "${path.key}" and anything below it do not render.`);
|
|
2169
|
+
}
|
|
2170
|
+
});
|
|
2171
|
+
// Dev mode: after a state write this element skipped, check that it was
|
|
2172
|
+
// right to. Resolving against the whole state has to give what the element
|
|
2173
|
+
// already shows; anything else is a read the spec analysis didn't name.
|
|
2174
|
+
if (inject(CHECK_SKIPPED_WRITES)) {
|
|
2175
|
+
let snapshotsSeen = -1;
|
|
2176
|
+
effect(() => {
|
|
2177
|
+
this.state.state();
|
|
2178
|
+
const shown = this.resolvedElement();
|
|
2179
|
+
const shownVisible = this.visible();
|
|
2180
|
+
const reads = this.stateReads();
|
|
2181
|
+
untracked(() => {
|
|
2182
|
+
const skipped = this.snapshotsTaken === snapshotsSeen;
|
|
2183
|
+
snapshotsSeen = this.snapshotsTaken;
|
|
2184
|
+
if (skipped && shown && reads !== null) {
|
|
2185
|
+
this.checkSkippedWrite(shown, shownVisible, reads);
|
|
2186
|
+
}
|
|
2187
|
+
});
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
// Count the writes that touch a path this element is bound to. Only an
|
|
2191
|
+
// element with bindings listens, and it reads them when a write arrives,
|
|
2192
|
+
// so a binding that moves (a repeat item that shifts) is followed without
|
|
2193
|
+
// subscribing again.
|
|
2194
|
+
effect((onCleanup) => {
|
|
2195
|
+
if (!this.bindings())
|
|
2196
|
+
return;
|
|
2197
|
+
const unsubscribe = this.state.subscribeChanges((changes) => {
|
|
2198
|
+
const paths = Object.values(untracked(this.bindings) ?? {});
|
|
2199
|
+
if (changes.some((change) => paths.some((path) => touches(change.path, path)))) {
|
|
2200
|
+
this.boundWrites.update((count) => count + 1);
|
|
2034
2201
|
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2202
|
+
});
|
|
2203
|
+
onCleanup(unsubscribe);
|
|
2204
|
+
});
|
|
2205
|
+
// Warn (once per type) about unknown component types.
|
|
2206
|
+
const warnedTypes = new Set();
|
|
2207
|
+
effect(() => {
|
|
2208
|
+
const el = this.rawElement();
|
|
2209
|
+
if (!el || this.entry() || warnedTypes.has(el.type))
|
|
2210
|
+
return;
|
|
2211
|
+
warnedTypes.add(el.type);
|
|
2212
|
+
console.warn(`No renderer for component type: ${el.type}`);
|
|
2213
|
+
});
|
|
2214
|
+
// Warn about unknown / default slots when the registry carries metadata.
|
|
2215
|
+
effect(() => {
|
|
2216
|
+
const el = this.rawElement();
|
|
2217
|
+
const meta = this.entry();
|
|
2218
|
+
if (!el?.slots || !meta?.slots)
|
|
2219
|
+
return;
|
|
2220
|
+
const available = new Set(meta.slots);
|
|
2221
|
+
for (const slotName of Object.keys(el.slots)) {
|
|
2222
|
+
const warnKey = `${el.type}:${slotName}`;
|
|
2223
|
+
if (warnedSlots.has(warnKey))
|
|
2044
2224
|
continue;
|
|
2225
|
+
if (slotName === 'default') {
|
|
2226
|
+
warnedSlots.add(warnKey);
|
|
2227
|
+
console.warn(`[json-render] Component "${el.type}" uses slots.default. Use "children" for default slot content.`);
|
|
2228
|
+
}
|
|
2229
|
+
else if (!available.has(slotName)) {
|
|
2230
|
+
warnedSlots.add(warnKey);
|
|
2231
|
+
console.warn(`[json-render] Unknown slot "${slotName}" on component "${el.type}". Available slots: ${meta.slots.join(', ')}`);
|
|
2045
2232
|
}
|
|
2046
|
-
const child = elements[childKey];
|
|
2047
|
-
// A child the spec never defined is validateSpec's finding, not ours.
|
|
2048
|
-
if (!child)
|
|
2049
|
-
continue;
|
|
2050
|
-
onPath.add(childKey);
|
|
2051
|
-
stack.push({
|
|
2052
|
-
key: childKey,
|
|
2053
|
-
kids: childKeysOf(child),
|
|
2054
|
-
next: 0,
|
|
2055
|
-
deepest: 0,
|
|
2056
|
-
});
|
|
2057
|
-
continue;
|
|
2058
2233
|
}
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
message: `Spec has ${count} elements, over the limit of ${limit}. Nothing is rendered: renderLimits.maxElements is a cap on the whole spec.`,
|
|
2090
|
-
};
|
|
2091
|
-
}
|
|
2092
|
-
/** Report a breached depth cap. */
|
|
2093
|
-
function depthIssue(depth, limit) {
|
|
2094
|
-
return {
|
|
2095
|
-
severity: 'error',
|
|
2096
|
-
code: 'too_deep',
|
|
2097
|
-
message: `Spec nests ${depth} levels deep, over the limit of ${limit}. Elements below level ${limit} do not render.`,
|
|
2098
|
-
};
|
|
2099
|
-
}
|
|
2100
|
-
/**
|
|
2101
|
-
* Check a spec against a catalog: the component types it names, then the
|
|
2102
|
-
* catalog's spec schema, then each element's props against its own
|
|
2103
|
-
* component's schema.
|
|
2104
|
-
*
|
|
2105
|
-
* The props need that pass of their own. Core builds one schema for the whole
|
|
2106
|
-
* spec, and it gives an element's props a component's schema only when the
|
|
2107
|
-
* catalog has exactly one component; with more, every element's props are an
|
|
2108
|
-
* open record, whatever its `type`. So `catalog.validate` holds an element to
|
|
2109
|
-
* its shape — `children`, a `type` from the catalog — and not to its props.
|
|
2110
|
-
*
|
|
2111
|
-
* Whatever that schema did say about the props of an element checked here is
|
|
2112
|
-
* dropped: with a one-component catalog it would be reported twice, and it
|
|
2113
|
-
* judges expressions this pass knows to leave alone.
|
|
2114
|
-
*
|
|
2115
|
-
* The schema passes are skipped while a type is unknown. That is an error on
|
|
2116
|
-
* its own, and the spec schema would only restate it as a `type` outside the
|
|
2117
|
-
* ones it accepts.
|
|
2118
|
-
*/
|
|
2119
|
-
function catalogIssues(spec, catalog) {
|
|
2120
|
-
const elements = Object.entries(spec.elements ?? {});
|
|
2121
|
-
const known = new Set(catalog.componentNames);
|
|
2122
|
-
const unknown = [];
|
|
2123
|
-
for (const [key, element] of elements) {
|
|
2124
|
-
if (known.has(element.type))
|
|
2125
|
-
continue;
|
|
2126
|
-
unknown.push({
|
|
2127
|
-
severity: 'error',
|
|
2128
|
-
code: 'unknown_component',
|
|
2129
|
-
elementKey: key,
|
|
2130
|
-
message: `Element "${key}" has type "${element.type}", which this catalog does not define. Nothing renders for it.`,
|
|
2234
|
+
});
|
|
2235
|
+
// Watch effect: fire actions when watched state paths change.
|
|
2236
|
+
effect((onCleanup) => {
|
|
2237
|
+
// A refused element does not act, not just draw nothing. `watch` is the
|
|
2238
|
+
// one thing an element does without being on screen, so leaving it wired
|
|
2239
|
+
// would let the element past `maxDepth` — or the one that closes a cycle
|
|
2240
|
+
// — keep dispatching actions from behind a cap that was supposed to have
|
|
2241
|
+
// stopped it. Reading the signal here also unwires it if the refusal
|
|
2242
|
+
// arrives later, when the limits change.
|
|
2243
|
+
if (this.refusal())
|
|
2244
|
+
return;
|
|
2245
|
+
const watchConfig = this.rawElement()?.watch;
|
|
2246
|
+
if (!watchConfig)
|
|
2247
|
+
return;
|
|
2248
|
+
const paths = Object.keys(watchConfig);
|
|
2249
|
+
if (paths.length === 0)
|
|
2250
|
+
return;
|
|
2251
|
+
const unsubscribe = this.state.subscribeChanges((changes) => {
|
|
2252
|
+
const changedPaths = new Set(changes.map((change) => change.path));
|
|
2253
|
+
void (async () => {
|
|
2254
|
+
for (const path of paths) {
|
|
2255
|
+
if (!changedPaths.has(path))
|
|
2256
|
+
continue;
|
|
2257
|
+
const binding = watchConfig[path];
|
|
2258
|
+
if (binding)
|
|
2259
|
+
await this.dispatch(binding);
|
|
2260
|
+
}
|
|
2261
|
+
})().catch(reportActionError);
|
|
2262
|
+
});
|
|
2263
|
+
onCleanup(unsubscribe);
|
|
2131
2264
|
});
|
|
2132
2265
|
}
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
}
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
components === null ||
|
|
2164
|
-
!Object.hasOwn(components, type)) {
|
|
2165
|
-
return undefined;
|
|
2166
|
-
}
|
|
2167
|
-
const props = components[type]?.props;
|
|
2168
|
-
return typeof props
|
|
2169
|
-
?.safeParse === 'function'
|
|
2170
|
-
? props
|
|
2171
|
-
: undefined;
|
|
2172
|
-
}
|
|
2173
|
-
/** What the catalog's spec schema reported, with paths rooted at the spec. */
|
|
2174
|
-
function specSchemaIssues(spec, catalog) {
|
|
2175
|
-
try {
|
|
2176
|
-
const result = catalog.validate(spec);
|
|
2177
|
-
return result.success ? [] : readIssues(result.error);
|
|
2178
|
-
}
|
|
2179
|
-
catch (error) {
|
|
2180
|
-
return [{ path: [], message: threwMessage('the catalog schema', error) }];
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
/**
|
|
2184
|
-
* What an element's component schema says about its props, leaving out what
|
|
2185
|
-
* it says about expressions, with paths rooted at the spec.
|
|
2186
|
-
*
|
|
2187
|
-
* A schema that throws counts as a rejection, not a crash. Real ones do:
|
|
2188
|
-
* `ValidationConfigSchema`, which every Material input's `validation` prop
|
|
2189
|
-
* uses, recurses through `enabled` and overflows the stack a thousand
|
|
2190
|
-
* `$and`s deep.
|
|
2191
|
-
*/
|
|
2192
|
-
function propsIssues(key, element, schema, room) {
|
|
2193
|
-
const at = ['elements', key, 'props'];
|
|
2194
|
-
let result;
|
|
2195
|
-
try {
|
|
2196
|
-
result = schema.safeParse(element.props);
|
|
2266
|
+
/**
|
|
2267
|
+
* Resolve this element against the whole current state and warn, once per
|
|
2268
|
+
* element key, when that differs from what it shows after skipping a write.
|
|
2269
|
+
*
|
|
2270
|
+
* It only reports, and leaves the element as it is: correcting it here would
|
|
2271
|
+
* make dev mode render what production doesn't.
|
|
2272
|
+
*/
|
|
2273
|
+
checkSkippedWrite(shown, shownVisible, reads) {
|
|
2274
|
+
const key = this.elementKey();
|
|
2275
|
+
const el = this.rawElement();
|
|
2276
|
+
if (!el || warnedSkippedWrites.has(key))
|
|
2277
|
+
return;
|
|
2278
|
+
const ctx = this.liveResolutionCtx();
|
|
2279
|
+
let props;
|
|
2280
|
+
let visible;
|
|
2281
|
+
try {
|
|
2282
|
+
props = resolveElementProps(el.props ?? {}, ctx);
|
|
2283
|
+
visible = el.visible === undefined || evaluateVisibility(el.visible, ctx);
|
|
2284
|
+
}
|
|
2285
|
+
catch {
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
const differs = Object.keys({ ...props, ...shown.props }).filter((prop) => !sameContent(props[prop], shown.props[prop]));
|
|
2289
|
+
if (visible !== shownVisible)
|
|
2290
|
+
differs.push('visible');
|
|
2291
|
+
if (differs.length === 0)
|
|
2292
|
+
return;
|
|
2293
|
+
warnedSkippedWrites.add(key);
|
|
2294
|
+
const paths = reads.size > 0 ? [...reads].join(', ') : 'no state paths';
|
|
2295
|
+
console.warn(`[ngx-json-render] "${key}" did not resolve again after a state write, but resolving it now changes ${differs.join(', ')}. It resolves only when a state path it reads changes, and it reads ${paths}. Either a $computed function in it depends on something besides its args, or the renderer missed a read — please report the spec at https://github.com/shteynu/ngx-json-render/issues. This check runs in dev mode only.`);
|
|
2197
2296
|
}
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2297
|
+
/**
|
|
2298
|
+
* Resolution context with a live state snapshot, so `$state` references in
|
|
2299
|
+
* later actions of a chain see mutations from earlier ones.
|
|
2300
|
+
*/
|
|
2301
|
+
liveResolutionCtx() {
|
|
2302
|
+
return {
|
|
2303
|
+
...untracked(this.resolutionCtx),
|
|
2304
|
+
stateModel: this.state.getSnapshot(),
|
|
2305
|
+
};
|
|
2205
2306
|
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
continue;
|
|
2214
|
-
issues.push({ path: [...at, ...path], message });
|
|
2307
|
+
/**
|
|
2308
|
+
* Fire-and-forget entry point for event emission: dispatching is async, but
|
|
2309
|
+
* a template listener has nowhere to await it, so the failure must be
|
|
2310
|
+
* handled here rather than escaping as an unhandled rejection.
|
|
2311
|
+
*/
|
|
2312
|
+
fireEvent(eventName) {
|
|
2313
|
+
this.emitEvent(eventName).catch(reportActionError);
|
|
2215
2314
|
}
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
* or on a value holding one.
|
|
2221
|
-
*
|
|
2222
|
-
* `{"$state": "/title"}` where the schema wants a string is not a defect.
|
|
2223
|
-
* Core resolves every such value before the component sees it, so the schema
|
|
2224
|
-
* would be judging something that never renders. Any `$`-prefixed key counts
|
|
2225
|
-
* — directive names must start with `$`, and so would an expression added to
|
|
2226
|
-
* core later. A value merely holding one is left alone too, since a union or
|
|
2227
|
-
* a refinement over it ran on the unresolved value.
|
|
2228
|
-
*
|
|
2229
|
-
* The props object itself is not an expression: core resolves the values
|
|
2230
|
-
* inside it, never the object.
|
|
2231
|
-
*/
|
|
2232
|
-
function reachesExpression(props, path) {
|
|
2233
|
-
let value = props;
|
|
2234
|
-
for (const segment of path) {
|
|
2235
|
-
if (typeof value !== 'object' || value === null)
|
|
2236
|
-
return false;
|
|
2237
|
-
value = value[segment];
|
|
2238
|
-
if (isExpression(value))
|
|
2239
|
-
return true;
|
|
2315
|
+
async emitEvent(eventName) {
|
|
2316
|
+
const binding = untracked(this.rawElement)?.on?.[eventName];
|
|
2317
|
+
if (binding)
|
|
2318
|
+
await this.dispatch(binding);
|
|
2240
2319
|
}
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
function holdsExpression(value) {
|
|
2259
|
-
const pending = [value];
|
|
2260
|
-
const seen = new Set();
|
|
2261
|
-
while (pending.length > 0) {
|
|
2262
|
-
const current = pending.pop();
|
|
2263
|
-
if (typeof current !== 'object' || current === null)
|
|
2264
|
-
continue;
|
|
2265
|
-
if (seen.has(current))
|
|
2266
|
-
continue;
|
|
2267
|
-
seen.add(current);
|
|
2268
|
-
for (const child of Object.values(current)) {
|
|
2269
|
-
if (isExpression(child))
|
|
2270
|
-
return true;
|
|
2271
|
-
pending.push(child);
|
|
2320
|
+
/**
|
|
2321
|
+
* Run an `on` or `watch` binding's actions in order. Params are resolved
|
|
2322
|
+
* per action, just before it runs, so a `$state` read in a later action of
|
|
2323
|
+
* the chain sees what an earlier one wrote.
|
|
2324
|
+
*/
|
|
2325
|
+
async dispatch(binding) {
|
|
2326
|
+
for (const b of Array.isArray(binding) ? binding : [binding]) {
|
|
2327
|
+
if (!b.params) {
|
|
2328
|
+
await this.actions.execute(b);
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
const liveCtx = this.liveResolutionCtx();
|
|
2332
|
+
const resolved = {};
|
|
2333
|
+
for (const [key, val] of Object.entries(b.params)) {
|
|
2334
|
+
resolved[key] = resolveActionParam(val, liveCtx);
|
|
2335
|
+
}
|
|
2336
|
+
await this.actions.execute({ ...b, params: resolved });
|
|
2272
2337
|
}
|
|
2273
2338
|
}
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
* it has produced, and a renderer that threw while explaining a bad spec
|
|
2282
|
-
* would be the worse failure.
|
|
2283
|
-
*/
|
|
2284
|
-
function readIssues(error) {
|
|
2285
|
-
const raw = error?.issues;
|
|
2286
|
-
if (!Array.isArray(raw))
|
|
2287
|
-
return [];
|
|
2288
|
-
return raw.map((entry) => {
|
|
2289
|
-
const item = entry;
|
|
2290
|
-
const path = item?.path;
|
|
2291
|
-
const message = item?.message;
|
|
2339
|
+
eventHandle(eventName) {
|
|
2340
|
+
const el = this.rawElement();
|
|
2341
|
+
const binding = el?.on?.[eventName];
|
|
2342
|
+
if (!binding) {
|
|
2343
|
+
return { emit: () => { }, shouldPreventDefault: false, bound: false };
|
|
2344
|
+
}
|
|
2345
|
+
const actionBindings = Array.isArray(binding) ? binding : [binding];
|
|
2292
2346
|
return {
|
|
2293
|
-
|
|
2294
|
-
|
|
2347
|
+
emit: () => this.fireEvent(eventName),
|
|
2348
|
+
shouldPreventDefault: actionBindings.some((b) => b.preventDefault),
|
|
2349
|
+
bound: true,
|
|
2295
2350
|
};
|
|
2296
|
-
}
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
code: 'invalid_props',
|
|
2310
|
-
...(elementKey === undefined ? {} : { elementKey }),
|
|
2311
|
-
message: `${where}: ${message}`,
|
|
2312
|
-
};
|
|
2351
|
+
}
|
|
2352
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrElement, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2353
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JrElement, isStandalone: true, selector: "jr-element", inputs: { elementKey: { classPropertyName: "elementKey", publicName: "elementKey", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
|
|
2354
|
+
@if (!refusal() && rawElement() && visible() && component()) {
|
|
2355
|
+
@if (devtoolsKey(); as dk) {
|
|
2356
|
+
<span [attr.data-jr-key]="dk" style="display: contents">
|
|
2357
|
+
<ng-container *ngComponentOutlet="component(); injector: outletInjector" />
|
|
2358
|
+
</span>
|
|
2359
|
+
} @else {
|
|
2360
|
+
<ng-container *ngComponentOutlet="component(); injector: outletInjector" />
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
`, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2313
2364
|
}
|
|
2365
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrElement, decorators: [{
|
|
2366
|
+
type: Component,
|
|
2367
|
+
args: [{ selector: 'jr-element', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgComponentOutlet], template: `
|
|
2368
|
+
@if (!refusal() && rawElement() && visible() && component()) {
|
|
2369
|
+
@if (devtoolsKey(); as dk) {
|
|
2370
|
+
<span [attr.data-jr-key]="dk" style="display: contents">
|
|
2371
|
+
<ng-container *ngComponentOutlet="component(); injector: outletInjector" />
|
|
2372
|
+
</span>
|
|
2373
|
+
} @else {
|
|
2374
|
+
<ng-container *ngComponentOutlet="component(); injector: outletInjector" />
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
`, styles: [":host{display:contents}\n"] }]
|
|
2378
|
+
}], ctorParameters: () => [], propDecorators: { elementKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "elementKey", required: true }] }] } });
|
|
2314
2379
|
|
|
2315
2380
|
const CLEAN = {
|
|
2316
2381
|
spec: null,
|
|
@@ -2501,8 +2566,14 @@ class JsonRenderer {
|
|
|
2501
2566
|
* `$bindState`, `$template`, a directive — has no value until render time,
|
|
2502
2567
|
* so it is not checked.
|
|
2503
2568
|
*
|
|
2504
|
-
*
|
|
2505
|
-
* skipped while `loading`.
|
|
2569
|
+
* The check is read only when `validate` is on, and — like the rest of
|
|
2570
|
+
* it — skipped while `loading`.
|
|
2571
|
+
*
|
|
2572
|
+
* While `loading`, the catalog also gates mounting, whatever `validate` is:
|
|
2573
|
+
* an element whose props do not yet pass its component's schema shows its
|
|
2574
|
+
* registry entry's `fallback`, or nothing, instead of a component with half
|
|
2575
|
+
* its props. Once an element has mounted it stays mounted, and when
|
|
2576
|
+
* `loading` ends every element mounts.
|
|
2506
2577
|
*/
|
|
2507
2578
|
catalog = input(null, ...(ngDevMode ? [{ debugName: "catalog" }] : /* istanbul ignore next */ []));
|
|
2508
2579
|
/**
|
|
@@ -2548,6 +2619,7 @@ class JsonRenderer {
|
|
|
2548
2619
|
root.loading = this.loading;
|
|
2549
2620
|
root.fallback = this.fallback;
|
|
2550
2621
|
root.limits = this.renderLimits;
|
|
2622
|
+
root.catalog = this.catalog;
|
|
2551
2623
|
root.store = this.store;
|
|
2552
2624
|
root.initialState = computed(() => this.state() ?? this.spec()?.state ?? {}, ...(ngDevMode ? [{ debugName: "initialState" }] : /* istanbul ignore next */ []));
|
|
2553
2625
|
root.handlers = this.handlers;
|
|
@@ -2878,8 +2950,13 @@ function defineRegistry(catalog, options) {
|
|
|
2878
2950
|
// Build the component registry, attaching slot metadata from the catalog.
|
|
2879
2951
|
const registry = {};
|
|
2880
2952
|
if (options.components) {
|
|
2881
|
-
for (const [name,
|
|
2882
|
-
const entry =
|
|
2953
|
+
for (const [name, value] of Object.entries(options.components)) {
|
|
2954
|
+
const entry = typeof value === 'function'
|
|
2955
|
+
? { component: value }
|
|
2956
|
+
: {
|
|
2957
|
+
component: value.component,
|
|
2958
|
+
...(value.fallback ? { fallback: value.fallback } : {}),
|
|
2959
|
+
};
|
|
2883
2960
|
const slots = catalogComponents?.[name]?.slots;
|
|
2884
2961
|
if (slots)
|
|
2885
2962
|
entry.slots = slots;
|