ngx-json-render 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/fesm2022/ngx-json-render.mjs +381 -5
- package/fesm2022/ngx-json-render.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-json-render.d.ts +52 -1
package/README.md
CHANGED
|
@@ -594,6 +594,24 @@ const bound = injectBoundProp<string>(() => ctx.props().value, () => ctx.binding
|
|
|
594
594
|
|
|
595
595
|
Share one store across renderers (or drive it from your own state management) by passing a core `StateStore` — `createStateStore()`, or `createStoreAdapter()` over Redux/NgRx/etc. — via the `store` input. `createStoreSetState(store)` adapts a whole-state updater to fine-grained path writes.
|
|
596
596
|
|
|
597
|
+
### What a state write re-renders
|
|
598
|
+
|
|
599
|
+
A write reaches only the components whose resolved props actually changed. Writing `/user/name` re-runs the template of the text that shows it, not the rest of the tree. So `ctx.props()` and `ctx.element()` are the signals to read: a template that reads anything else, such as a mutable object or `Date.now()`, is no longer refreshed by unrelated writes.
|
|
600
|
+
|
|
601
|
+
"Changed" is decided per prop value:
|
|
602
|
+
|
|
603
|
+
- Primitives compare by value.
|
|
604
|
+
- Objects and arrays compare by reference with the built-in store, which copies every path it writes.
|
|
605
|
+
- With an external `store`, objects and arrays always count as changed, because such a store may write into its snapshot in place.
|
|
606
|
+
|
|
607
|
+
Elements with a `$bindState` or `$bindItem` prop still re-run on every write. Their DOM can hold the user's input before state does, and the note below depends on them getting the chance to put it right.
|
|
608
|
+
|
|
609
|
+
Before any of that, a write only makes the elements that can read its path resolve their props at all. The renderer works the paths out from each element's spec — `$state`, `$item`, `$template` placeholders, both branches of a `$cond`, `$computed` arguments and `visible` conditions — so a write to `/user/name` leaves the todo list alone, and its `$computed` functions don't run. A `$computed` function should therefore depend only on its arguments: one that also reads the clock or a variable outside the spec is no longer called again by unrelated writes.
|
|
610
|
+
|
|
611
|
+
Some elements keep resolving on every write, because their reads can't be known from the spec: those using a directive (its `resolve` gets the whole state), those with a two-way binding, and any prop with a `$`-key the renderer doesn't recognise.
|
|
612
|
+
|
|
613
|
+
In dev mode each element that skipped a write resolves anyway, against the whole state, and the console warns once if that gives anything other than what it shows. The warning means a `$computed` function reads more than its arguments, or the renderer missed a read, which is a bug worth reporting. The element itself is left as production would leave it. The check calls the element's `$computed` functions again, so in dev mode they still run on every write.
|
|
614
|
+
|
|
597
615
|
### A note on inputs
|
|
598
616
|
|
|
599
617
|
If a catalog component renders `<input [value]="ctx.props().value">`, remember that one-way bindings do not re-assert the DOM when the bound value returns to its previously applied value while the user typed in between (e.g. `pushState` + `clearStatePath`). Sync imperatively instead — see `InputComponent` in the demo app for the pattern.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NgComponentOutlet } from '@angular/common';
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
3
|
import { signal, Injectable, inject, computed, untracked, effect, isDevMode, DestroyRef, InjectionToken, input, output, viewChild, afterNextRender, ChangeDetectionStrategy, Component, Injector, forwardRef } from '@angular/core';
|
|
4
|
-
import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, autoFixSpec, validateSpec, createDirectiveRegistry,
|
|
4
|
+
import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, resolveRepeatStatePath, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, autoFixSpec, validateSpec, createDirectiveRegistry, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
|
|
5
5
|
export { autoFixSpec, createStateStore, formatSpecIssues, nestedToFlat, validateSpec } from '@json-render/core';
|
|
6
6
|
import { flattenToPointers } from '@json-render/core/store-utils';
|
|
7
7
|
|
|
@@ -967,6 +967,169 @@ function injectDevtoolsActive() {
|
|
|
967
967
|
return active.asReadonly();
|
|
968
968
|
}
|
|
969
969
|
|
|
970
|
+
/**
|
|
971
|
+
* The state paths an element's expressions can read, or `null` when that
|
|
972
|
+
* can't be known from the spec.
|
|
973
|
+
*
|
|
974
|
+
* Worked out from the spec alone, before anything resolves, so that an element
|
|
975
|
+
* can depend on those paths rather than on the whole state. The answer may
|
|
976
|
+
* name more paths than a given resolution reads — both branches of a `$cond`,
|
|
977
|
+
* say — but never fewer: a path left out would be a write the element misses,
|
|
978
|
+
* and it would keep showing the old value with nothing in the console.
|
|
979
|
+
*
|
|
980
|
+
* `null` is the safe answer, and it is given whenever the spec holds something
|
|
981
|
+
* this reader does not model:
|
|
982
|
+
*
|
|
983
|
+
* - a registered directive, whose `resolve` receives the whole context and may
|
|
984
|
+
* read any path at all;
|
|
985
|
+
* - a `$bindState` or `$bindItem` prop — the renderer keeps two-way bound
|
|
986
|
+
* elements resolving on every write, see `JrElement.resolvedElement`;
|
|
987
|
+
* - a `$`-prefixed key it doesn't recognise, which is what an expression added
|
|
988
|
+
* to core after this was written would look like;
|
|
989
|
+
* - a read of the whole state (`''` or `'/'`), and a condition it can't parse.
|
|
990
|
+
*
|
|
991
|
+
* The walk mirrors core's `resolvePropValue` and `evaluateVisibility`, in the
|
|
992
|
+
* same order, because the order decides which expression an object is.
|
|
993
|
+
*/
|
|
994
|
+
function collectStateReads(element, directives, repeatBasePath) {
|
|
995
|
+
const reader = new StateReader(directives, repeatBasePath);
|
|
996
|
+
const understood = reader.condition(element.visible) &&
|
|
997
|
+
Object.values(element.props ?? {}).every((value) => reader.value(value));
|
|
998
|
+
return understood ? reader.paths : null;
|
|
999
|
+
}
|
|
1000
|
+
function isObject(value) {
|
|
1001
|
+
return typeof value === 'object' && value !== null;
|
|
1002
|
+
}
|
|
1003
|
+
/** Comparison operators a single condition may carry, as core reads them. */
|
|
1004
|
+
const COMPARISONS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'];
|
|
1005
|
+
class StateReader {
|
|
1006
|
+
directives;
|
|
1007
|
+
repeatBasePath;
|
|
1008
|
+
paths = new Set();
|
|
1009
|
+
constructor(directives, repeatBasePath) {
|
|
1010
|
+
this.directives = directives;
|
|
1011
|
+
this.repeatBasePath = repeatBasePath;
|
|
1012
|
+
}
|
|
1013
|
+
/** A prop value. Returns false when it can read state this can't name. */
|
|
1014
|
+
value(value) {
|
|
1015
|
+
if (!isObject(value))
|
|
1016
|
+
return true;
|
|
1017
|
+
if (typeof value['$state'] === 'string')
|
|
1018
|
+
return this.state(value['$state']);
|
|
1019
|
+
if (typeof value['$item'] === 'string')
|
|
1020
|
+
return this.item(value['$item']);
|
|
1021
|
+
if ('$index' in value && value['$index'] === true)
|
|
1022
|
+
return true;
|
|
1023
|
+
if (typeof value['$bindState'] === 'string')
|
|
1024
|
+
return false;
|
|
1025
|
+
if (typeof value['$bindItem'] === 'string')
|
|
1026
|
+
return false;
|
|
1027
|
+
if ('$cond' in value && '$then' in value && '$else' in value) {
|
|
1028
|
+
return (this.condition(value['$cond']) &&
|
|
1029
|
+
this.value(value['$then']) &&
|
|
1030
|
+
this.value(value['$else']));
|
|
1031
|
+
}
|
|
1032
|
+
if (typeof value['$computed'] === 'string') {
|
|
1033
|
+
// The function is handed its resolved arguments and nothing else.
|
|
1034
|
+
const args = value['args'];
|
|
1035
|
+
return (!args || Object.values(Object(args)).every((arg) => this.value(arg)));
|
|
1036
|
+
}
|
|
1037
|
+
if (typeof value['$template'] === 'string') {
|
|
1038
|
+
return this.template(value['$template']);
|
|
1039
|
+
}
|
|
1040
|
+
if (Array.isArray(value))
|
|
1041
|
+
return value.every((item) => this.value(item));
|
|
1042
|
+
if (this.directives) {
|
|
1043
|
+
for (const name of this.directives.keys()) {
|
|
1044
|
+
if (name in value)
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1049
|
+
if (key.startsWith('$') || !this.value(entry))
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
/** A `visible` condition, or the condition of a `$cond`. */
|
|
1055
|
+
condition(condition) {
|
|
1056
|
+
if (condition === undefined || typeof condition === 'boolean')
|
|
1057
|
+
return true;
|
|
1058
|
+
if (Array.isArray(condition)) {
|
|
1059
|
+
return condition.every((single) => this.singleCondition(single));
|
|
1060
|
+
}
|
|
1061
|
+
if (!isObject(condition))
|
|
1062
|
+
return false;
|
|
1063
|
+
for (const group of ['$and', '$or']) {
|
|
1064
|
+
if (group in condition) {
|
|
1065
|
+
const children = condition[group];
|
|
1066
|
+
return (Array.isArray(children) &&
|
|
1067
|
+
children.every((child) => this.condition(child)));
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return this.singleCondition(condition);
|
|
1071
|
+
}
|
|
1072
|
+
singleCondition(condition) {
|
|
1073
|
+
if (!isObject(condition))
|
|
1074
|
+
return false;
|
|
1075
|
+
let understood;
|
|
1076
|
+
if ('$index' in condition) {
|
|
1077
|
+
understood = true;
|
|
1078
|
+
}
|
|
1079
|
+
else if ('$item' in condition) {
|
|
1080
|
+
const item = condition['$item'];
|
|
1081
|
+
understood = typeof item === 'string' && this.item(item);
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
// Core reads `$state` here without checking it: anything but a string
|
|
1085
|
+
// path reads the whole state.
|
|
1086
|
+
const path = condition['$state'];
|
|
1087
|
+
understood = typeof path === 'string' && this.state(path);
|
|
1088
|
+
}
|
|
1089
|
+
// Core looks only at the first operator present. Reading all of them is
|
|
1090
|
+
// a superset of that.
|
|
1091
|
+
for (const operator of COMPARISONS) {
|
|
1092
|
+
const operand = condition[operator];
|
|
1093
|
+
if (isObject(operand) && typeof operand['$state'] === 'string') {
|
|
1094
|
+
understood = this.state(operand['$state']) && understood;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return understood;
|
|
1098
|
+
}
|
|
1099
|
+
template(template) {
|
|
1100
|
+
for (const [, placeholder] of template.matchAll(/\$\{([^}]+)\}/g)) {
|
|
1101
|
+
if (placeholder.startsWith('/')) {
|
|
1102
|
+
if (!this.state(placeholder))
|
|
1103
|
+
return false;
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
// Core tries the repeat item first and falls back to a state path of
|
|
1107
|
+
// the same name when the item has nothing there.
|
|
1108
|
+
if (!this.item(placeholder) || !this.state(`/${placeholder}`)) {
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return true;
|
|
1113
|
+
}
|
|
1114
|
+
state(path) {
|
|
1115
|
+
if (path === '' || path === '/')
|
|
1116
|
+
return false;
|
|
1117
|
+
this.paths.add(path);
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* A read of the repeat item, as the state path it sits at. The item value
|
|
1122
|
+
* reaches the element on its own, but only as the same object when an
|
|
1123
|
+
* external store edits it in place, so the path is what notices the edit.
|
|
1124
|
+
*/
|
|
1125
|
+
item(itemPath) {
|
|
1126
|
+
if (this.repeatBasePath === undefined)
|
|
1127
|
+
return true;
|
|
1128
|
+
const path = resolveRepeatStatePath({ $item: itemPath }, this.repeatBasePath);
|
|
1129
|
+
return path !== undefined && this.state(path);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
970
1133
|
/**
|
|
971
1134
|
* The render context of the element currently being rendered.
|
|
972
1135
|
* Provided by the renderer for every catalog component instance.
|
|
@@ -992,6 +1155,15 @@ const REPEAT_SCOPE = new InjectionToken('ngx-json-render REPEAT_SCOPE');
|
|
|
992
1155
|
* `renderLimits.maxDepth`.
|
|
993
1156
|
*/
|
|
994
1157
|
const RENDER_PATH = new InjectionToken('ngx-json-render RENDER_PATH');
|
|
1158
|
+
/**
|
|
1159
|
+
* Whether an element that skipped a state write checks that it was right to:
|
|
1160
|
+
* that resolving it against the whole state would not give anything else.
|
|
1161
|
+
* On in dev mode, off in production.
|
|
1162
|
+
*
|
|
1163
|
+
* @internal Not part of the public API. The check calls `$computed`
|
|
1164
|
+
* functions again, so tests that count those calls turn it off.
|
|
1165
|
+
*/
|
|
1166
|
+
const CHECK_SKIPPED_WRITES = new InjectionToken('ngx-json-render CHECK_SKIPPED_WRITES', { providedIn: 'root', factory: () => isDevMode() });
|
|
995
1167
|
/**
|
|
996
1168
|
* Inject the render context inside a catalog component.
|
|
997
1169
|
*
|
|
@@ -1091,6 +1263,75 @@ function repeatsItself(path) {
|
|
|
1091
1263
|
}
|
|
1092
1264
|
return false;
|
|
1093
1265
|
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Whether two objects hold the same values under the same keys.
|
|
1268
|
+
*
|
|
1269
|
+
* `objectsByIdentity` decides what the same object or array reference means.
|
|
1270
|
+
* The internal store copies every path it writes and never touches a snapshot
|
|
1271
|
+
* it has handed out, so there the same reference is the same content. An
|
|
1272
|
+
* external store may write into its snapshot in place, so there only
|
|
1273
|
+
* primitives can be trusted to compare equal, and any object value counts as
|
|
1274
|
+
* changed.
|
|
1275
|
+
*/
|
|
1276
|
+
function sameValues(a, b, objectsByIdentity) {
|
|
1277
|
+
const keys = Object.keys(a);
|
|
1278
|
+
if (keys.length !== Object.keys(b).length)
|
|
1279
|
+
return false;
|
|
1280
|
+
for (const key of keys) {
|
|
1281
|
+
if (!Object.hasOwn(b, key))
|
|
1282
|
+
return false;
|
|
1283
|
+
const value = a[key];
|
|
1284
|
+
if (!Object.is(value, b[key]))
|
|
1285
|
+
return false;
|
|
1286
|
+
if (!objectsByIdentity && typeof value === 'object' && value !== null) {
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return true;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Whether two resolved values hold the same content, however they are built.
|
|
1294
|
+
*
|
|
1295
|
+
* For the dev-mode check on skipped writes, which compares a fresh resolution
|
|
1296
|
+
* with an old one: a `$computed` function returns a new object every time,
|
|
1297
|
+
* so identity says nothing there. What it can't see into — functions, a `Map`,
|
|
1298
|
+
* a class instance — it counts as the same, because a check that warns about
|
|
1299
|
+
* a correct element is worse than one that misses a case.
|
|
1300
|
+
*/
|
|
1301
|
+
function sameContent(a, b, seen = new Set()) {
|
|
1302
|
+
if (Object.is(a, b))
|
|
1303
|
+
return true;
|
|
1304
|
+
if (typeof a === 'function' && typeof b === 'function')
|
|
1305
|
+
return true;
|
|
1306
|
+
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b)
|
|
1307
|
+
return false;
|
|
1308
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b))
|
|
1309
|
+
return false;
|
|
1310
|
+
if (a instanceof Date)
|
|
1311
|
+
return a.getTime() === b.getTime();
|
|
1312
|
+
const plain = Array.isArray(a) || Object.getPrototypeOf(a) === Object.prototype;
|
|
1313
|
+
if (!plain || seen.has(a))
|
|
1314
|
+
return true;
|
|
1315
|
+
seen.add(a);
|
|
1316
|
+
const keys = Object.keys(a);
|
|
1317
|
+
if (keys.length !== Object.keys(b).length)
|
|
1318
|
+
return false;
|
|
1319
|
+
return keys.every((key) => Object.hasOwn(b, key) &&
|
|
1320
|
+
sameContent(a[key], b[key], seen));
|
|
1321
|
+
}
|
|
1322
|
+
/** Element keys already warned about by the check on skipped writes. */
|
|
1323
|
+
const warnedSkippedWrites = new Set();
|
|
1324
|
+
const EMPTY_READS = new Set();
|
|
1325
|
+
function sameReads(a, b) {
|
|
1326
|
+
if (a === b)
|
|
1327
|
+
return true;
|
|
1328
|
+
if (!a || !b || a.size !== b.size)
|
|
1329
|
+
return false;
|
|
1330
|
+
for (const path of a)
|
|
1331
|
+
if (!b.has(path))
|
|
1332
|
+
return false;
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1094
1335
|
/** The chain from the root down to `path`, as a line a human can follow. */
|
|
1095
1336
|
function describePath(path) {
|
|
1096
1337
|
const keys = [];
|
|
@@ -1116,11 +1357,71 @@ class JrElement {
|
|
|
1116
1357
|
devtoolsActive = injectDevtoolsActive();
|
|
1117
1358
|
/** The raw (unresolved) element from the spec. */
|
|
1118
1359
|
rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], ...(ngDevMode ? [{ debugName: "rawElement" }] : /* istanbul ignore next */ []));
|
|
1360
|
+
/**
|
|
1361
|
+
* The state paths this element's expressions can read, or null when the
|
|
1362
|
+
* spec doesn't say — see {@link collectStateReads} for when that is.
|
|
1363
|
+
*/
|
|
1364
|
+
stateReads = computed(() => {
|
|
1365
|
+
const el = this.rawElement();
|
|
1366
|
+
if (!el)
|
|
1367
|
+
return EMPTY_READS;
|
|
1368
|
+
return collectStateReads(el, this.root.directiveRegistry(), this.repeatScope?.basePath());
|
|
1369
|
+
}, { ...(ngDevMode ? { debugName: "stateReads" } : /* istanbul ignore next */ {}), equal: sameReads });
|
|
1370
|
+
/**
|
|
1371
|
+
* One signal per path this element reads, each holding the value at that
|
|
1372
|
+
* path. They are rebuilt only when the paths change, and a cell that stays
|
|
1373
|
+
* keeps its last value, so a path that didn't change is not a change.
|
|
1374
|
+
*
|
|
1375
|
+
* A cell compares like a resolved prop does: primitives by value, objects
|
|
1376
|
+
* by reference with the internal store (which copies every path it writes)
|
|
1377
|
+
* and never equal with an external `store`, which may write into its
|
|
1378
|
+
* snapshot in place.
|
|
1379
|
+
*/
|
|
1380
|
+
stateCells = (() => {
|
|
1381
|
+
let cells = new Map();
|
|
1382
|
+
return computed(() => {
|
|
1383
|
+
const reads = this.stateReads();
|
|
1384
|
+
if (reads === null)
|
|
1385
|
+
return null;
|
|
1386
|
+
const next = new Map();
|
|
1387
|
+
for (const path of reads) {
|
|
1388
|
+
next.set(path, cells.get(path) ??
|
|
1389
|
+
computed(() => getByPath(this.state.state(), path), {
|
|
1390
|
+
equal: (a, b) => Object.is(a, b) &&
|
|
1391
|
+
(typeof a !== 'object' ||
|
|
1392
|
+
a === null ||
|
|
1393
|
+
!untracked(this.root.store)),
|
|
1394
|
+
}));
|
|
1395
|
+
}
|
|
1396
|
+
cells = next;
|
|
1397
|
+
return next;
|
|
1398
|
+
});
|
|
1399
|
+
})();
|
|
1400
|
+
/**
|
|
1401
|
+
* How many times {@link readableState} has taken a snapshot. The dev-mode
|
|
1402
|
+
* check compares it between state writes to tell which ones were skipped.
|
|
1403
|
+
*/
|
|
1404
|
+
snapshotsTaken = 0;
|
|
1405
|
+
/**
|
|
1406
|
+
* The state to resolve against: the whole snapshot, but only as fresh as
|
|
1407
|
+
* the paths this element reads. A write elsewhere doesn't reach it, so the
|
|
1408
|
+
* element doesn't resolve anything for it. An element whose reads are
|
|
1409
|
+
* unknown follows every write, as all elements did before.
|
|
1410
|
+
*/
|
|
1411
|
+
readableState = computed(() => {
|
|
1412
|
+
this.snapshotsTaken++;
|
|
1413
|
+
const cells = this.stateCells();
|
|
1414
|
+
if (cells === null)
|
|
1415
|
+
return this.state.state();
|
|
1416
|
+
for (const cell of cells.values())
|
|
1417
|
+
cell();
|
|
1418
|
+
return untracked(this.state.state);
|
|
1419
|
+
}, { ...(ngDevMode ? { debugName: "readableState" } : /* istanbul ignore next */ {}), equal: () => false });
|
|
1119
1420
|
/** Prop/visibility resolution context (state + repeat scope + extensions). */
|
|
1120
1421
|
resolutionCtx = computed(() => {
|
|
1121
1422
|
const scope = this.repeatScope;
|
|
1122
1423
|
return {
|
|
1123
|
-
stateModel: this.
|
|
1424
|
+
stateModel: this.readableState(),
|
|
1124
1425
|
...(scope
|
|
1125
1426
|
? {
|
|
1126
1427
|
repeatItem: scope.item(),
|
|
@@ -1138,7 +1439,22 @@ class JrElement {
|
|
|
1138
1439
|
return true;
|
|
1139
1440
|
return evaluateVisibility(el.visible, this.resolutionCtx());
|
|
1140
1441
|
}, ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
|
|
1141
|
-
/**
|
|
1442
|
+
/**
|
|
1443
|
+
* The element with all prop expressions resolved.
|
|
1444
|
+
*
|
|
1445
|
+
* Resolving builds a new props object on every state write, whatever the
|
|
1446
|
+
* write touched. Handing that on as a change would re-run every catalog
|
|
1447
|
+
* template in the tree for one keystroke, so a result that holds the same
|
|
1448
|
+
* values as the last one counts as unchanged and keeps the old object.
|
|
1449
|
+
*
|
|
1450
|
+
* Two-way bound elements are the exception, and keep treating every
|
|
1451
|
+
* resolution as a change. Their component writes the user's input into the
|
|
1452
|
+
* DOM before it reaches state, so the DOM can differ from a prop whose
|
|
1453
|
+
* value never did: typing and a `clearStatePath` in the same turn take
|
|
1454
|
+
* the prop from '' through 'milk' back to '' with nobody reading 'milk'.
|
|
1455
|
+
* The README tells catalog authors to sync such inputs with an effect on
|
|
1456
|
+
* props, and that effect only gets to put the DOM right if it runs.
|
|
1457
|
+
*/
|
|
1142
1458
|
resolvedElement = computed(() => {
|
|
1143
1459
|
const el = this.rawElement();
|
|
1144
1460
|
if (!el)
|
|
@@ -1147,14 +1463,24 @@ class JrElement {
|
|
|
1147
1463
|
...el,
|
|
1148
1464
|
props: resolveElementProps(el.props ?? {}, this.resolutionCtx()),
|
|
1149
1465
|
};
|
|
1150
|
-
}, ...(ngDevMode ?
|
|
1466
|
+
}, { ...(ngDevMode ? { debugName: "resolvedElement" } : /* istanbul ignore next */ {}), equal: (a, b) => {
|
|
1467
|
+
if (a === b)
|
|
1468
|
+
return true;
|
|
1469
|
+
if (!a || !b || untracked(this.bindings))
|
|
1470
|
+
return false;
|
|
1471
|
+
const { props: aProps, ...aRest } = a;
|
|
1472
|
+
const { props: bProps, ...bRest } = b;
|
|
1473
|
+
// Everything but props is the spec element itself, never state.
|
|
1474
|
+
return (sameValues(aRest, bRest, true) &&
|
|
1475
|
+
sameValues(aProps, bProps, !untracked(this.root.store)));
|
|
1476
|
+
} });
|
|
1151
1477
|
/** Two-way binding paths ($bindState / $bindItem) by prop name. */
|
|
1152
1478
|
bindings = computed(() => {
|
|
1153
1479
|
const el = this.rawElement();
|
|
1154
1480
|
return el
|
|
1155
1481
|
? resolveBindings(el.props ?? {}, this.resolutionCtx())
|
|
1156
1482
|
: undefined;
|
|
1157
|
-
}, ...(ngDevMode ?
|
|
1483
|
+
}, { ...(ngDevMode ? { debugName: "bindings" } : /* istanbul ignore next */ {}), equal: (a, b) => a === b || (!!a && !!b && sameValues(a, b, true)) });
|
|
1158
1484
|
entry = computed(() => {
|
|
1159
1485
|
const el = this.rawElement();
|
|
1160
1486
|
return el ? this.root.resolveEntry(el.type) : undefined;
|
|
@@ -1237,6 +1563,25 @@ class JrElement {
|
|
|
1237
1563
|
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.`);
|
|
1238
1564
|
}
|
|
1239
1565
|
});
|
|
1566
|
+
// Dev mode: after a state write this element skipped, check that it was
|
|
1567
|
+
// right to. Resolving against the whole state has to give what the element
|
|
1568
|
+
// already shows; anything else is a read the spec analysis didn't name.
|
|
1569
|
+
if (inject(CHECK_SKIPPED_WRITES)) {
|
|
1570
|
+
let snapshotsSeen = -1;
|
|
1571
|
+
effect(() => {
|
|
1572
|
+
this.state.state();
|
|
1573
|
+
const shown = this.resolvedElement();
|
|
1574
|
+
const shownVisible = this.visible();
|
|
1575
|
+
const reads = this.stateReads();
|
|
1576
|
+
untracked(() => {
|
|
1577
|
+
const skipped = this.snapshotsTaken === snapshotsSeen;
|
|
1578
|
+
snapshotsSeen = this.snapshotsTaken;
|
|
1579
|
+
if (skipped && shown && reads !== null) {
|
|
1580
|
+
this.checkSkippedWrite(shown, shownVisible, reads);
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1240
1585
|
// Warn (once per type) about unknown component types.
|
|
1241
1586
|
const warnedTypes = new Set();
|
|
1242
1587
|
effect(() => {
|
|
@@ -1298,6 +1643,37 @@ class JrElement {
|
|
|
1298
1643
|
onCleanup(unsubscribe);
|
|
1299
1644
|
});
|
|
1300
1645
|
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Resolve this element against the whole current state and warn, once per
|
|
1648
|
+
* element key, when that differs from what it shows after skipping a write.
|
|
1649
|
+
*
|
|
1650
|
+
* It only reports, and leaves the element as it is: correcting it here would
|
|
1651
|
+
* make dev mode render what production doesn't.
|
|
1652
|
+
*/
|
|
1653
|
+
checkSkippedWrite(shown, shownVisible, reads) {
|
|
1654
|
+
const key = this.elementKey();
|
|
1655
|
+
const el = this.rawElement();
|
|
1656
|
+
if (!el || warnedSkippedWrites.has(key))
|
|
1657
|
+
return;
|
|
1658
|
+
const ctx = this.liveResolutionCtx();
|
|
1659
|
+
let props;
|
|
1660
|
+
let visible;
|
|
1661
|
+
try {
|
|
1662
|
+
props = resolveElementProps(el.props ?? {}, ctx);
|
|
1663
|
+
visible = el.visible === undefined || evaluateVisibility(el.visible, ctx);
|
|
1664
|
+
}
|
|
1665
|
+
catch {
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
const differs = Object.keys({ ...props, ...shown.props }).filter((prop) => !sameContent(props[prop], shown.props[prop]));
|
|
1669
|
+
if (visible !== shownVisible)
|
|
1670
|
+
differs.push('visible');
|
|
1671
|
+
if (differs.length === 0)
|
|
1672
|
+
return;
|
|
1673
|
+
warnedSkippedWrites.add(key);
|
|
1674
|
+
const paths = reads.size > 0 ? [...reads].join(', ') : 'no state paths';
|
|
1675
|
+
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.`);
|
|
1676
|
+
}
|
|
1301
1677
|
/**
|
|
1302
1678
|
* Resolution context with a live state snapshot, so `$state` references in
|
|
1303
1679
|
* later actions of a chain see mutations from earlier ones.
|