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.
@@ -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
- * The state paths an element's expressions can read, or `null` when that
1110
- * can't be known from the spec.
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
- * Worked out from the spec alone, before anything resolves, so that an element
1113
- * can depend on those paths rather than on the whole state. The answer may
1114
- * name more paths than a given resolution reads — both branches of a `$cond`,
1115
- * say — but never fewer: a path left out would be a write the element misses,
1116
- * and it would keep showing the old value with nothing in the console.
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
- * `null` is the safe answer, and it is given whenever the spec holds something
1119
- * this reader does not model:
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
- * - a registered directive, whose `resolve` receives the whole context and may
1122
- * read any path at all;
1123
- * - a `$`-prefixed key it doesn't recognise, which is what an expression added
1124
- * to core after this was written would look like;
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
- * The walk mirrors core's `resolvePropValue` and `evaluateVisibility`, in the
1128
- * same order, because the order decides which expression an object is.
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 collectStateReads(element, directives, repeatBasePath) {
1131
- const reader = new StateReader(directives, repeatBasePath);
1132
- const understood = reader.condition(element.visible) &&
1133
- Object.values(element.props ?? {}).every((value) => reader.value(value));
1134
- return understood ? reader.paths : null;
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
- function isObject(value) {
1137
- return typeof value === 'object' && value !== null;
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
- /** Comparison operators a single condition may carry, as core reads them. */
1140
- const COMPARISONS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'];
1141
- class StateReader {
1142
- directives;
1143
- repeatBasePath;
1144
- paths = new Set();
1145
- constructor(directives, repeatBasePath) {
1146
- this.directives = directives;
1147
- this.repeatBasePath = repeatBasePath;
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
- /** A prop value. Returns false when it can read state this can't name. */
1150
- value(value) {
1151
- if (!isObject(value))
1152
- return true;
1153
- if (typeof value['$state'] === 'string')
1154
- return this.state(value['$state']);
1155
- if (typeof value['$item'] === 'string')
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
- /** A `visible` condition, or the condition of a `$cond`. */
1194
- condition(condition) {
1195
- if (condition === undefined || typeof condition === 'boolean')
1196
- return true;
1197
- if (Array.isArray(condition)) {
1198
- return condition.every((single) => this.singleCondition(single));
1199
- }
1200
- if (!isObject(condition))
1201
- return false;
1202
- for (const group of ['$and', '$or']) {
1203
- if (group in condition) {
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
- template(template) {
1239
- for (const [, placeholder] of template.matchAll(/\$\{([^}]+)\}/g)) {
1240
- if (placeholder.startsWith('/')) {
1241
- if (!this.state(placeholder))
1242
- return false;
1243
- continue;
1244
- }
1245
- // Core tries the repeat item first and falls back to a state path of
1246
- // the same name when the item has nothing there.
1247
- if (!this.item(placeholder) || !this.state(`/${placeholder}`)) {
1248
- return false;
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
- state(path) {
1254
- if (path === '' || path === '/')
1255
- return false;
1256
- this.paths.add(path);
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
- * A `$bindItem`, joined the way core joins it — which is not quite how it
1261
- * joins an `$item` read. Outside a repeat it resolves to nothing.
1262
- */
1263
- boundItem(itemPath) {
1264
- if (this.repeatBasePath === undefined)
1265
- return true;
1266
- return this.state(itemPath === ''
1267
- ? this.repeatBasePath
1268
- : `${this.repeatBasePath}/${itemPath}`);
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
- * A read of the repeat item, as the state path it sits at. The item value
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
- * Inject the render context inside a catalog component.
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
- * @example
1321
- * ```ts
1322
- * @Component({
1323
- * selector: 'app-button',
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 injectRenderContext() {
1332
- const ctx = inject(RENDER_CONTEXT, { optional: true });
1333
- if (!ctx) {
1334
- throw new Error('injectRenderContext() must be used inside a component rendered by <json-render>');
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
- return ctx;
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
- * Inject the spec key of the element this component renders.
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
- * Catalog components that collect their children — a tab group discovering
1342
- * its tabs, for instance — need it to order registrations by the spec's
1343
- * `children` array rather than by the order the children happened to
1344
- * announce themselves in.
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
- * @example
1347
- * ```ts
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 injectElementKey() {
1353
- const key = inject(ELEMENT_KEY, { optional: true });
1354
- if (!key) {
1355
- throw new Error('injectElementKey() must be used inside a component rendered by <json-render>');
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 key;
1427
+ return holdsExpression(value);
1358
1428
  }
1359
- /**
1360
- * Inject the current repeat scope, or `null` when the component is not
1361
- * rendered inside a `repeat` block.
1362
- */
1363
- function injectRepeatScope() {
1364
- return inject(REPEAT_SCOPE, { optional: true });
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
- * Report a failure from a fire-and-forget action dispatch. Cancelling a
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
- * What separates the two is whether the repeat descended. Compare against the
1403
- * nearest occurrence above: `/tree/0` then `/tree/0/children/1` is a tree
1404
- * walking down, while `/items/0` then `/items/1` is a spec repeating over a
1405
- * fixed array inside itself, and `/items/0` twice is one with no repeat
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 repeatsItself(path) {
1409
- for (let step = path.parent; step !== null; step = step.parent) {
1410
- if (step.key !== path.key)
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
- return !descendsInto(step.scopePath, path.scopePath);
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
- * Whether two objects hold the same values under the same keys.
1463
+ * Read what a schema reported.
1418
1464
  *
1419
- * `objectsByIdentity` decides what the same object or array reference means.
1420
- * The internal store copies every path it writes and never touches a snapshot
1421
- * it has handed out, so there the same reference is the same content. An
1422
- * external store may write into its snapshot in place, so there only
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 sameValues(a, b, objectsByIdentity) {
1427
- const keys = Object.keys(a);
1428
- if (keys.length !== Object.keys(b).length)
1429
- return false;
1430
- for (const key of keys) {
1431
- if (!Object.hasOwn(b, key))
1432
- return false;
1433
- const value = a[key];
1434
- if (!Object.is(value, b[key]))
1435
- return false;
1436
- if (!objectsByIdentity && typeof value === 'object' && value !== null) {
1437
- return false;
1438
- }
1439
- }
1440
- return true;
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
- * Whether two resolved values hold the same content, however they are built.
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
- * For the dev-mode check on skipped writes, which compares a fresh resolution
1446
- * with an old one: a `$computed` function returns a new object every time,
1447
- * so identity says nothing there. What it can't see into — functions, a `Map`,
1448
- * a class instance — it counts as the same, because a check that warns about
1449
- * a correct element is worse than one that misses a case.
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 sameContent(a, b, seen = new Set()) {
1452
- if (Object.is(a, b))
1453
- return true;
1454
- if (typeof a === 'function' && typeof b === 'function')
1455
- return true;
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
- * Whether a state write at `written` can change the value at `bound`: the
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
- /** Element keys already warned about by the check on skipped writes. */
1482
- const warnedSkippedWrites = new Set();
1483
- const EMPTY_READS = new Set();
1484
- function sameReads(a, b) {
1485
- if (a === b)
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
- if (!a || !b || a.size !== b.size)
1488
- return false;
1489
- for (const path of a)
1490
- if (!b.has(path))
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
- return true;
1493
- }
1494
- /** The chain from the root down to `path`, as a line a human can follow. */
1495
- function describePath(path) {
1496
- const keys = [];
1497
- for (let step = path; step !== null; step = step.parent) {
1498
- keys.push(step.key);
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
- return keys.reverse().join(' \u2192 ');
1501
- }
1502
- /**
1503
- * Renders a single spec element: evaluates visibility, resolves prop
1504
- * expressions against the state model, wires event/watch bindings, and
1505
- * instantiates the catalog component with a {@link RenderContext} injector.
1506
- *
1507
- * @internal Used by `<json-render>` and `<jr-children>`.
1508
- */
1509
- class JrElement {
1510
- elementKey = input.required(...(ngDevMode ? [{ debugName: "elementKey" }] : /* istanbul ignore next */ []));
1511
- root = inject(JsonRenderRootContext);
1512
- state = inject(JsonRenderStateService);
1513
- actions = inject(JsonRenderActionsService);
1514
- repeatScope = inject(REPEAT_SCOPE, { optional: true });
1515
- parentPath = inject(RENDER_PATH, { optional: true });
1516
- devtoolsActive = injectDevtoolsActive();
1517
- /**
1518
- * The raw (unresolved) element from the spec.
1519
- *
1520
- * Compared by content, not identity. A streamed patch shares every element
1521
- * it did not touch, and the identity check settles those at once; but a
1522
- * spec can also arrive as all-new objects — a whole-spec part, a host that
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
- cells = next;
1565
- return next;
1566
- });
1567
- })();
1568
- /**
1569
- * How many times {@link readableState} has taken a snapshot. The dev-mode
1570
- * check compares it between state writes to tell which ones were skipped.
1571
- */
1572
- snapshotsTaken = 0;
1573
- /**
1574
- * The state to resolve against: the whole snapshot, but only as fresh as
1575
- * the paths this element reads. A write elsewhere doesn't reach it, so the
1576
- * element doesn't resolve anything for it. An element whose reads are
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
- const { props: aProps, ...aRest } = a.element;
1653
- const { props: bProps, ...bRest } = b.element;
1654
- // Everything but props is the spec element itself, never state.
1655
- return (sameValues(aRest, bRest, true) &&
1656
- sameValues(aProps, bProps, !untracked(this.root.store)));
1657
- } });
1658
- /** The element with all prop expressions resolved. */
1659
- resolvedElement = computed(() => this.resolution().element, ...(ngDevMode ? [{ debugName: "resolvedElement" }] : /* istanbul ignore next */ []));
1660
- /** Two-way binding paths ($bindState / $bindItem) by prop name. */
1661
- bindings = computed(() => {
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
- * Where this element sits in the tree. Passed down to everything it renders
1675
- * below itself, which is what lets each element see its own ancestry
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
- path = computed(() => {
1679
- const parent = this.parentPath?.() ?? null;
1680
- return {
1681
- key: this.elementKey(),
1682
- depth: (parent?.depth ?? 0) + 1,
1683
- scopePath: this.repeatScope?.basePath() ?? null,
1684
- parent,
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
- * Why this element is not drawn, or null when it is.
1689
- *
1690
- * The cycle half is unconditional — not something `validate` turns on. An
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
- refusal = computed(() => {
1698
- const path = this.path();
1699
- if (repeatsItself(path))
1700
- return 'cycle';
1701
- const maxDepth = this.root.limits()?.maxDepth;
1702
- if (maxDepth !== undefined && path.depth > maxDepth)
1703
- return 'depth';
1704
- return null;
1705
- }, ...(ngDevMode ? [{ debugName: "refusal" }] : /* istanbul ignore next */ []));
1706
- renderCtx = {
1707
- element: this.resolvedElement,
1708
- props: computed(() => this.resolvedElement()?.props ?? {}),
1709
- emit: (event) => this.fireEvent(event),
1710
- on: (event) => this.eventHandle(event),
1711
- bindings: this.bindings,
1712
- loading: computed(() => this.root.loading()),
1713
- setBound: (prop, value) => {
1714
- const path = untracked(this.bindings)?.[prop];
1715
- if (path) {
1716
- this.state.set(path, value);
1717
- }
1718
- else if (isDevMode()) {
1719
- console.warn(`[ngx-json-render] setBound("${prop}"): prop has no $bindState/$bindItem binding`);
1720
- }
1721
- },
1722
- };
1723
- // Typed as Injector for the peer range: see `confirmInjector` in
1724
- // renderer.component.ts.
1725
- outletInjector = Injector.create({
1726
- providers: [
1727
- { provide: RENDER_CONTEXT, useValue: this.renderCtx },
1728
- { provide: ELEMENT_KEY, useValue: this.elementKey },
1729
- { provide: RENDER_PATH, useValue: this.path },
1730
- ],
1731
- parent: inject(Injector),
1732
- });
1733
- constructor() {
1734
- // Warn (once) when this element is refused. Both reasons are silent
1735
- // failures on screen — something the spec asked for is missing — so
1736
- // neither should be silent in the console.
1737
- let warnedRefusal = false;
1738
- effect(() => {
1739
- const reason = this.refusal();
1740
- if (!reason || warnedRefusal)
1741
- return;
1742
- warnedRefusal = true;
1743
- const path = untracked(this.path);
1744
- if (reason === 'cycle') {
1745
- 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"}.`);
1746
- }
1747
- else {
1748
- 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.`);
1749
- }
1750
- });
1751
- // Dev mode: after a state write this element skipped, check that it was
1752
- // right to. Resolving against the whole state has to give what the element
1753
- // already shows; anything else is a read the spec analysis didn't name.
1754
- if (inject(CHECK_SKIPPED_WRITES)) {
1755
- let snapshotsSeen = -1;
1756
- effect(() => {
1757
- this.state.state();
1758
- const shown = this.resolvedElement();
1759
- const shownVisible = this.visible();
1760
- const reads = this.stateReads();
1761
- untracked(() => {
1762
- const skipped = this.snapshotsTaken === snapshotsSeen;
1763
- snapshotsSeen = this.snapshotsTaken;
1764
- if (skipped && shown && reads !== null) {
1765
- this.checkSkippedWrite(shown, shownVisible, reads);
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
- // Count the writes that touch a path this element is bound to. Only an
1771
- // element with bindings listens, and it reads them when a write arrives,
1772
- // so a binding that moves (a repeat item that shifts) is followed without
1773
- // subscribing again.
1774
- effect((onCleanup) => {
1775
- if (!this.bindings())
1776
- return;
1777
- const unsubscribe = this.state.subscribeChanges((changes) => {
1778
- const paths = Object.values(untracked(this.bindings) ?? {});
1779
- if (changes.some((change) => paths.some((path) => touches(change.path, path)))) {
1780
- this.boundWrites.update((count) => count + 1);
1781
- }
1782
- });
1783
- onCleanup(unsubscribe);
1784
- });
1785
- // Warn (once per type) about unknown component types.
1786
- const warnedTypes = new Set();
1787
- effect(() => {
1788
- const el = this.rawElement();
1789
- if (!el || this.entry() || warnedTypes.has(el.type))
1790
- return;
1791
- warnedTypes.add(el.type);
1792
- console.warn(`No renderer for component type: ${el.type}`);
1793
- });
1794
- // Warn about unknown / default slots when the registry carries metadata.
1795
- effect(() => {
1796
- const el = this.rawElement();
1797
- const meta = this.entry();
1798
- if (!el?.slots || !meta?.slots)
1799
- return;
1800
- const available = new Set(meta.slots);
1801
- for (const slotName of Object.keys(el.slots)) {
1802
- const warnKey = `${el.type}:${slotName}`;
1803
- if (warnedSlots.has(warnKey))
1804
- continue;
1805
- if (slotName === 'default') {
1806
- warnedSlots.add(warnKey);
1807
- console.warn(`[json-render] Component "${el.type}" uses slots.default. Use "children" for default slot content.`);
1808
- }
1809
- else if (!available.has(slotName)) {
1810
- warnedSlots.add(warnKey);
1811
- console.warn(`[json-render] Unknown slot "${slotName}" on component "${el.type}". Available slots: ${meta.slots.join(', ')}`);
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
- // Watch effect: fire actions when watched state paths change.
1816
- effect((onCleanup) => {
1817
- // A refused element does not act, not just draw nothing. `watch` is the
1818
- // one thing an element does without being on screen, so leaving it wired
1819
- // would let the element past `maxDepth` — or the one that closes a cycle
1820
- // — keep dispatching actions from behind a cap that was supposed to have
1821
- // stopped it. Reading the signal here also unwires it if the refusal
1822
- // arrives later, when the limits change.
1823
- if (this.refusal())
1824
- return;
1825
- const watchConfig = this.rawElement()?.watch;
1826
- if (!watchConfig)
1827
- return;
1828
- const paths = Object.keys(watchConfig);
1829
- if (paths.length === 0)
1830
- return;
1831
- const unsubscribe = this.state.subscribeChanges((changes) => {
1832
- const changedPaths = new Set(changes.map((change) => change.path));
1833
- void (async () => {
1834
- for (const path of paths) {
1835
- if (!changedPaths.has(path))
1836
- continue;
1837
- const binding = watchConfig[path];
1838
- if (binding)
1839
- await this.dispatch(binding);
1840
- }
1841
- })().catch(reportActionError);
1842
- });
1843
- onCleanup(unsubscribe);
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
- * Resolve this element against the whole current state and warn, once per
1848
- * element key, when that differs from what it shows after skipping a write.
2009
+ * The element with all prop expressions resolved, and the bound writes it
2010
+ * was resolved after.
1849
2011
  *
1850
- * It only reports, and leaves the element as it is: correcting it here would
1851
- * make dev mode render what production doesn't.
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
- checkSkippedWrite(shown, shownVisible, reads) {
1854
- const key = this.elementKey();
2026
+ resolution = computed(() => {
2027
+ const boundWrites = this.boundWrites();
1855
2028
  const el = this.rawElement();
1856
- if (!el || warnedSkippedWrites.has(key))
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
- ...untracked(this.resolutionCtx),
1884
- stateModel: this.state.getSnapshot(),
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
- * Fire-and-forget entry point for event emission: dispatching is async, but
1889
- * a template listener has nowhere to await it, so the failure must be
1890
- * handled here rather than escaping as an unhandled rejection.
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
- fireEvent(eventName) {
1893
- this.emitEvent(eventName).catch(reportActionError);
1894
- }
1895
- async emitEvent(eventName) {
1896
- const binding = untracked(this.rawElement)?.on?.[eventName];
1897
- if (binding)
1898
- await this.dispatch(binding);
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
- * Run an `on` or `watch` binding's actions in order. Params are resolved
1902
- * per action, just before it runs, so a `$state` read in a later action of
1903
- * the chain sees what an earlier one wrote.
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
- async dispatch(binding) {
1906
- for (const b of Array.isArray(binding) ? binding : [binding]) {
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
- emit: () => this.fireEvent(eventName),
1928
- shouldPreventDefault: actionBindings.some((b) => b.preventDefault),
1929
- bound: true,
2101
+ key: this.elementKey(),
2102
+ depth: (parent?.depth ?? 0) + 1,
2103
+ scopePath: this.repeatScope?.basePath() ?? null,
2104
+ parent,
1930
2105
  };
1931
- }
1932
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrElement, deps: [], target: i0.ɵɵFactoryTarget.Component });
1933
- 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: `
1934
- @if (!refusal() && rawElement() && visible() && component()) {
1935
- @if (devtoolsKey(); as dk) {
1936
- <span [attr.data-jr-key]="dk" style="display: contents">
1937
- <ng-container *ngComponentOutlet="component(); injector: outletInjector" />
1938
- </span>
1939
- } @else {
1940
- <ng-container *ngComponentOutlet="component(); injector: outletInjector" />
1941
- }
1942
- }
1943
- `, 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 });
1944
- }
1945
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrElement, decorators: [{
1946
- type: Component,
1947
- args: [{ selector: 'jr-element', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgComponentOutlet], template: `
1948
- @if (!refusal() && rawElement() && visible() && component()) {
1949
- @if (devtoolsKey(); as dk) {
1950
- <span [attr.data-jr-key]="dk" style="display: contents">
1951
- <ng-container *ngComponentOutlet="component(); injector: outletInjector" />
1952
- </span>
1953
- } @else {
1954
- <ng-container *ngComponentOutlet="component(); injector: outletInjector" />
1955
- }
1956
- }
1957
- `, styles: [":host{display:contents}\n"] }]
1958
- }], ctorParameters: () => [], propDecorators: { elementKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "elementKey", required: true }] }] } });
1959
-
1960
- /**
1961
- * A hostile spec can fail the catalog's schema in as many places as it has
1962
- * properties. Report enough to act on and stop; the spec is refused either
1963
- * way, and a console full of them helps nobody.
1964
- */
1965
- const MAX_REPORTED_PROP_ISSUES = 20;
1966
- /**
1967
- * Whether this element's `repeat` descends into the item it is already inside,
1968
- * rather than re-reading a fixed path in state.
1969
- *
1970
- * A relative statePath — `{"$item": "children"}` — resolves against the
1971
- * enclosing item, so each pass reads one level further into the data and the
1972
- * recursion ends where the data does. A plain string is absolute: every pass
1973
- * reads the same array, and a spec that re-enters itself through one never
1974
- * terminates.
1975
- */
1976
- function descendsIntoItems(element) {
1977
- const repeat = element.repeat;
1978
- return repeat !== undefined && typeof repeat.statePath !== 'string';
1979
- }
1980
- /** Every key an element renders below itself: `children` plus every slot. */
1981
- function childKeysOf(element) {
1982
- const slots = element.slots;
1983
- if (!slots)
1984
- return element.children ?? [];
1985
- const fromSlots = Object.values(slots).flat();
1986
- return element.children ? [...element.children, ...fromSlots] : fromSlots;
1987
- }
1988
- /**
1989
- * Walk the element graph once for depth and cycles.
1990
- *
1991
- * Iterative on purpose. A recursive walk would blow the stack on exactly the
1992
- * specs this exists to catch, which would make the guard a second way to
1993
- * crash rather than the fix for the first.
1994
- *
1995
- * Depth is measured from `spec.root`, because that is what renders. Cycles
1996
- * are swept for across every element, including the ones no parent reaches:
1997
- * an unreachable cycle is still a defect, and one edit away from a reachable
1998
- * one.
1999
- *
2000
- * Not every element that reaches itself is a defect, though. Drawing a tree —
2001
- * a comment thread, a file browser, a nested menu — means an element that
2002
- * repeats over a relative path and renders itself for each item, and that ends
2003
- * when the data does. A back edge is only reported when nothing along it
2004
- * descends into the data, which is the case that cannot end.
2005
- *
2006
- * `depthBelow` is memoised per element, so a shared subtree costs one visit
2007
- * instead of one per path into it — without that, a spec whose elements each
2008
- * name the next two is exponential to check. The memo makes the reported
2009
- * depth a lower bound once a cycle has been cut, since which edge closes the
2010
- * cycle depends on the order the walk reached it. The cycle is reported as an
2011
- * error of its own, which is the finding worth acting on.
2012
- */
2013
- function analyseSpecGraph(spec) {
2014
- const elements = spec.elements ?? {};
2015
- const cycles = new Set();
2016
- const depthBelow = new Map();
2017
- const onPath = new Set();
2018
- const walkFrom = (start) => {
2019
- const first = elements[start];
2020
- if (!first || depthBelow.has(start))
2021
- return;
2022
- const stack = [
2023
- { key: start, kids: childKeysOf(first), next: 0, deepest: 0 },
2024
- ];
2025
- onPath.add(start);
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
- // A child that closes the cycle is not followed — that is what keeps
2036
- // this walk finite — and not counted towards depth either.
2037
- if (onPath.has(childKey)) {
2038
- const from = stack.findIndex((frame) => frame.key === childKey);
2039
- const descends = stack
2040
- .slice(from)
2041
- .some((frame) => descendsIntoItems(elements[frame.key]));
2042
- if (!descends)
2043
- cycles.add(childKey);
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
- stack.pop();
2060
- onPath.delete(frame.key);
2061
- const depth = frame.deepest + 1;
2062
- depthBelow.set(frame.key, depth);
2063
- const parent = stack[stack.length - 1];
2064
- if (parent)
2065
- parent.deepest = Math.max(parent.deepest, depth);
2066
- }
2067
- };
2068
- if (spec.root)
2069
- walkFrom(spec.root);
2070
- const depth = (spec.root ? depthBelow.get(spec.root) : undefined) ?? 0;
2071
- for (const key of Object.keys(elements))
2072
- walkFrom(key);
2073
- return { depth, cycles: [...cycles] };
2074
- }
2075
- /** Report a cycle, naming the element that is its own descendant. */
2076
- function cycleIssue(key) {
2077
- return {
2078
- severity: 'error',
2079
- code: 'cycle',
2080
- elementKey: key,
2081
- 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"}.`,
2082
- };
2083
- }
2084
- /** Report a breached element-count cap. */
2085
- function elementCountIssue(count, limit) {
2086
- return {
2087
- severity: 'error',
2088
- code: 'too_many_elements',
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
- if (unknown.length > 0)
2134
- return unknown;
2135
- const schemas = new Map();
2136
- for (const [key, element] of elements) {
2137
- const schema = propsSchemaOf(catalog.data, element.type);
2138
- if (schema)
2139
- schemas.set(key, schema);
2140
- }
2141
- const found = specSchemaIssues(spec, catalog).filter(({ path }) => !(path[0] === 'elements' &&
2142
- typeof path[1] === 'string' &&
2143
- schemas.has(path[1]) &&
2144
- path[2] === 'props'));
2145
- for (const [key, element] of elements) {
2146
- const room = MAX_REPORTED_PROP_ISSUES - found.length;
2147
- if (room <= 0)
2148
- break;
2149
- const schema = schemas.get(key);
2150
- if (schema)
2151
- found.push(...propsIssues(key, element, schema, room));
2152
- }
2153
- return found.slice(0, MAX_REPORTED_PROP_ISSUES).map(toSpecCheckIssue);
2154
- }
2155
- /**
2156
- * The props schema a catalog declares for a component type, if it declares
2157
- * one that can be called.
2158
- */
2159
- function propsSchemaOf(data, type) {
2160
- const components = data
2161
- ?.components;
2162
- if (typeof components !== 'object' ||
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
- catch (error) {
2199
- return [
2200
- {
2201
- path: at,
2202
- message: threwMessage(`the "${element.type}" schema`, error),
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
- if (result.success)
2207
- return [];
2208
- const issues = [];
2209
- for (const { path, message } of readIssues(result.error)) {
2210
- if (issues.length >= room)
2211
- break;
2212
- if (reachesExpression(element.props, path))
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
- return issues;
2217
- }
2218
- /**
2219
- * Whether a props issue is about an expression: it sits on one, inside one,
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
- return holdsExpression(value);
2242
- }
2243
- /** A value core resolves at render time — or will, once it knows the key. */
2244
- function isExpression(value) {
2245
- return (typeof value === 'object' &&
2246
- value !== null &&
2247
- !Array.isArray(value) &&
2248
- Object.keys(value).some((key) => key.startsWith('$')));
2249
- }
2250
- /**
2251
- * Whether anything nested inside `value` is an expression.
2252
- *
2253
- * Iterative, and it remembers what it has seen, for the same reason as
2254
- * {@link analyseSpecGraph}: props are as attacker-shaped as the rest of the
2255
- * spec, and a check that overflowed or looped on them would be a second way
2256
- * to crash.
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
- return false;
2275
- }
2276
- /**
2277
- * Read what a schema reported.
2278
- *
2279
- * Read defensively rather than typed against the schema library: the shape
2280
- * below (`issues`, each with `path` and `message`) is what every version of
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
- path: Array.isArray(path) ? path : [],
2294
- message: typeof message === 'string' ? message : 'is not valid',
2347
+ emit: () => this.fireEvent(eventName),
2348
+ shouldPreventDefault: actionBindings.some((b) => b.preventDefault),
2349
+ bound: true,
2295
2350
  };
2296
- });
2297
- }
2298
- function threwMessage(schema, error) {
2299
- const reason = error instanceof Error ? ` (${error.name}: ${error.message})` : '';
2300
- return `${schema} threw instead of answering${reason}, so this counts as rejected`;
2301
- }
2302
- function toSpecCheckIssue({ path, message }) {
2303
- // Paths are rooted at the spec, so `elements.<key>.props.<prop>` names the
2304
- // element in its second segment.
2305
- const elementKey = path[0] === 'elements' && typeof path[1] === 'string' ? path[1] : undefined;
2306
- const where = path.length > 0 ? path.map(String).join('.') : 'spec';
2307
- return {
2308
- severity: 'error',
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
- * Only read when `validate` is on, and — like the rest of the check —
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, component] of Object.entries(options.components)) {
2882
- const entry = { component };
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;