ngx-json-render 0.7.7 → 0.7.8
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 +7 -3
- package/fesm2022/ngx-json-render.mjs +125 -31
- package/fesm2022/ngx-json-render.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-json-render.d.ts +31 -13
package/README.md
CHANGED
|
@@ -686,14 +686,18 @@ A write reaches only the components whose resolved props actually changed. Writi
|
|
|
686
686
|
- Objects and arrays compare by reference with the built-in store, which copies every path it writes.
|
|
687
687
|
- With an external `store`, objects and arrays always count as changed, because such a store may write into its snapshot in place.
|
|
688
688
|
|
|
689
|
-
Elements with a `$bindState` or `$bindItem` prop
|
|
689
|
+
Elements with a `$bindState` or `$bindItem` prop also re-run on every write that touches their bound path — the path itself, a container above it, or a path inside it — even when the value comes back to what it was. 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. Writes anywhere else reach them like any other element.
|
|
690
690
|
|
|
691
|
-
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
|
|
691
|
+
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, `visible` conditions, and the paths `$bindState` and `$bindItem` write back to — 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.
|
|
692
692
|
|
|
693
|
-
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)
|
|
693
|
+
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) and any prop with a `$`-key the renderer doesn't recognise.
|
|
694
694
|
|
|
695
695
|
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.
|
|
696
696
|
|
|
697
|
+
### What a new spec re-renders
|
|
698
|
+
|
|
699
|
+
A spec element is compared by content, not by identity. A streamed patch shares every element it did not touch, but a spec can also arrive as all-new objects — a whole-spec part, a host that fetches the spec again, `buildSpecFromParts` replaying a message — and then only the elements whose content changed resolve and re-render. The same goes for `spec.state` in uncontrolled mode: only leaves whose content changed are written to the store, so handing over the same spec again keeps what the user has changed since, including edits inside an array.
|
|
700
|
+
|
|
697
701
|
### A note on inputs
|
|
698
702
|
|
|
699
703
|
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.
|
|
@@ -92,6 +92,37 @@ function isPlainObject(value) {
|
|
|
92
92
|
Object.getPrototypeOf(value) === Object.prototype);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Whether two values hold the same JSON content.
|
|
97
|
+
*
|
|
98
|
+
* For telling a value that arrived as a new object from one that changed — a
|
|
99
|
+
* spec element, or a leaf of `spec.state`, when the host hands over a spec
|
|
100
|
+
* built from fresh objects. Plain objects and arrays are compared by what
|
|
101
|
+
* they hold; anything else only by identity, which data parsed from JSON
|
|
102
|
+
* never needs. A container reached twice on the way down counts as
|
|
103
|
+
* different: JSON cannot refer to itself, and treating such a value as
|
|
104
|
+
* changed is what the renderer did before it compared anything.
|
|
105
|
+
*/
|
|
106
|
+
function sameJson(a, b, seen = new Set()) {
|
|
107
|
+
if (Object.is(a, b))
|
|
108
|
+
return true;
|
|
109
|
+
if (!isPlainContainer(a) || !isPlainContainer(b))
|
|
110
|
+
return false;
|
|
111
|
+
if (Array.isArray(a) !== Array.isArray(b) || seen.has(a))
|
|
112
|
+
return false;
|
|
113
|
+
seen.add(a);
|
|
114
|
+
const keys = Object.keys(a);
|
|
115
|
+
if (keys.length !== Object.keys(b).length)
|
|
116
|
+
return false;
|
|
117
|
+
return keys.every((key) => Object.hasOwn(b, key) && sameJson(a[key], b[key], seen));
|
|
118
|
+
}
|
|
119
|
+
function isPlainContainer(value) {
|
|
120
|
+
if (typeof value !== 'object' || value === null)
|
|
121
|
+
return false;
|
|
122
|
+
const prototype = Object.getPrototypeOf(value);
|
|
123
|
+
return (Array.isArray(value) || prototype === Object.prototype || prototype === null);
|
|
124
|
+
}
|
|
125
|
+
|
|
95
126
|
/**
|
|
96
127
|
* The state store of a `<json-render>` subtree.
|
|
97
128
|
*
|
|
@@ -151,7 +182,10 @@ class JsonRenderStateService {
|
|
|
151
182
|
});
|
|
152
183
|
// Uncontrolled mode: when the (resolved) initial state changes — e.g.
|
|
153
184
|
// `spec.state` grows while streaming — diff it against the previous
|
|
154
|
-
// initial state and apply only the changed leaves to the store.
|
|
185
|
+
// initial state and apply only the changed leaves to the store. Leaves
|
|
186
|
+
// compare by content: arrays are leaves, and a spec handed over again as
|
|
187
|
+
// new objects would otherwise write every array back, over whatever the
|
|
188
|
+
// user has since changed inside it.
|
|
155
189
|
let prevFlat = {};
|
|
156
190
|
effect(() => {
|
|
157
191
|
if (this.root.store())
|
|
@@ -166,7 +200,7 @@ class JsonRenderStateService {
|
|
|
166
200
|
]);
|
|
167
201
|
const updates = {};
|
|
168
202
|
for (const key of allKeys) {
|
|
169
|
-
if (prevFlat[key]
|
|
203
|
+
if (!sameJson(prevFlat[key], nextFlat[key])) {
|
|
170
204
|
updates[key] = key in nextFlat ? nextFlat[key] : undefined;
|
|
171
205
|
}
|
|
172
206
|
}
|
|
@@ -1085,8 +1119,6 @@ function injectDevtoolsActive() {
|
|
|
1085
1119
|
*
|
|
1086
1120
|
* - a registered directive, whose `resolve` receives the whole context and may
|
|
1087
1121
|
* read any path at all;
|
|
1088
|
-
* - a `$bindState` or `$bindItem` prop — the renderer keeps two-way bound
|
|
1089
|
-
* elements resolving on every write, see `JrElement.resolvedElement`;
|
|
1090
1122
|
* - a `$`-prefixed key it doesn't recognise, which is what an expression added
|
|
1091
1123
|
* to core after this was written would look like;
|
|
1092
1124
|
* - a read of the whole state (`''` or `'/'`), and a condition it can't parse.
|
|
@@ -1123,10 +1155,13 @@ class StateReader {
|
|
|
1123
1155
|
return this.item(value['$item']);
|
|
1124
1156
|
if ('$index' in value && value['$index'] === true)
|
|
1125
1157
|
return true;
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1158
|
+
// A two-way binding reads the path it writes back to.
|
|
1159
|
+
if (typeof value['$bindState'] === 'string') {
|
|
1160
|
+
return this.state(value['$bindState']);
|
|
1161
|
+
}
|
|
1162
|
+
if (typeof value['$bindItem'] === 'string') {
|
|
1163
|
+
return this.boundItem(value['$bindItem']);
|
|
1164
|
+
}
|
|
1130
1165
|
if ('$cond' in value && '$then' in value && '$else' in value) {
|
|
1131
1166
|
return (this.condition(value['$cond']) &&
|
|
1132
1167
|
this.value(value['$then']) &&
|
|
@@ -1220,6 +1255,17 @@ class StateReader {
|
|
|
1220
1255
|
this.paths.add(path);
|
|
1221
1256
|
return true;
|
|
1222
1257
|
}
|
|
1258
|
+
/**
|
|
1259
|
+
* A `$bindItem`, joined the way core joins it — which is not quite how it
|
|
1260
|
+
* joins an `$item` read. Outside a repeat it resolves to nothing.
|
|
1261
|
+
*/
|
|
1262
|
+
boundItem(itemPath) {
|
|
1263
|
+
if (this.repeatBasePath === undefined)
|
|
1264
|
+
return true;
|
|
1265
|
+
return this.state(itemPath === ''
|
|
1266
|
+
? this.repeatBasePath
|
|
1267
|
+
: `${this.repeatBasePath}/${itemPath}`);
|
|
1268
|
+
}
|
|
1223
1269
|
/**
|
|
1224
1270
|
* A read of the repeat item, as the state path it sits at. The item value
|
|
1225
1271
|
* reaches the element on its own, but only as the same object when an
|
|
@@ -1422,6 +1468,15 @@ function sameContent(a, b, seen = new Set()) {
|
|
|
1422
1468
|
return keys.every((key) => Object.hasOwn(b, key) &&
|
|
1423
1469
|
sameContent(a[key], b[key], seen));
|
|
1424
1470
|
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Whether a state write at `written` can change the value at `bound`: the
|
|
1473
|
+
* same path, a container above it, or a path inside it.
|
|
1474
|
+
*/
|
|
1475
|
+
function touches(written, bound) {
|
|
1476
|
+
return (written === bound ||
|
|
1477
|
+
bound.startsWith(`${written}/`) ||
|
|
1478
|
+
written.startsWith(`${bound}/`));
|
|
1479
|
+
}
|
|
1425
1480
|
/** Element keys already warned about by the check on skipped writes. */
|
|
1426
1481
|
const warnedSkippedWrites = new Set();
|
|
1427
1482
|
const EMPTY_READS = new Set();
|
|
@@ -1458,8 +1513,17 @@ class JrElement {
|
|
|
1458
1513
|
repeatScope = inject(REPEAT_SCOPE, { optional: true });
|
|
1459
1514
|
parentPath = inject(RENDER_PATH, { optional: true });
|
|
1460
1515
|
devtoolsActive = injectDevtoolsActive();
|
|
1461
|
-
/**
|
|
1462
|
-
|
|
1516
|
+
/**
|
|
1517
|
+
* The raw (unresolved) element from the spec.
|
|
1518
|
+
*
|
|
1519
|
+
* Compared by content, not identity. A streamed patch shares every element
|
|
1520
|
+
* it did not touch, and the identity check settles those at once; but a
|
|
1521
|
+
* spec can also arrive as all-new objects — a whole-spec part, a host that
|
|
1522
|
+
* fetches it again, `buildSpecFromParts` replaying a message — and then an
|
|
1523
|
+
* element whose content is unchanged would resolve, and every container
|
|
1524
|
+
* with a fresh `children` array re-render, for nothing.
|
|
1525
|
+
*/
|
|
1526
|
+
rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], { ...(ngDevMode ? { debugName: "rawElement" } : /* istanbul ignore next */ {}), equal: (a, b) => sameJson(a, b) });
|
|
1463
1527
|
/**
|
|
1464
1528
|
* The state paths this element's expressions can read, or null when the
|
|
1465
1529
|
* spec doesn't say — see {@link collectStateReads} for when that is.
|
|
@@ -1543,40 +1607,55 @@ class JrElement {
|
|
|
1543
1607
|
return evaluateVisibility(el.visible, this.resolutionCtx());
|
|
1544
1608
|
}, ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
|
|
1545
1609
|
/**
|
|
1546
|
-
*
|
|
1610
|
+
* How many state writes have touched a path this element is two-way bound
|
|
1611
|
+
* to. See {@link resolution} for why those writes count on their own.
|
|
1612
|
+
*/
|
|
1613
|
+
boundWrites = signal(0, ...(ngDevMode ? [{ debugName: "boundWrites" }] : /* istanbul ignore next */ []));
|
|
1614
|
+
/**
|
|
1615
|
+
* The element with all prop expressions resolved, and the bound writes it
|
|
1616
|
+
* was resolved after.
|
|
1547
1617
|
*
|
|
1548
|
-
* Resolving builds a new props object
|
|
1549
|
-
*
|
|
1550
|
-
*
|
|
1551
|
-
*
|
|
1618
|
+
* Resolving builds a new props object every time, so a result that holds
|
|
1619
|
+
* the same values as the last one counts as unchanged and keeps the old
|
|
1620
|
+
* object; otherwise one keystroke would re-run every catalog template that
|
|
1621
|
+
* happened to resolve.
|
|
1552
1622
|
*
|
|
1553
|
-
*
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1556
|
-
*
|
|
1557
|
-
*
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1623
|
+
* A write to a path the element is bound to always counts, though. Its
|
|
1624
|
+
* component writes the user's input into the DOM before it reaches state,
|
|
1625
|
+
* so the DOM can differ from a prop whose value never did: typing and a
|
|
1626
|
+
* `clearStatePath` in the same turn take the prop from '' through 'milk'
|
|
1627
|
+
* back to '' with nobody reading 'milk'. The README tells catalog authors
|
|
1628
|
+
* to sync such inputs with an effect on props, and that effect only gets to
|
|
1629
|
+
* put the DOM right if it runs. Writes anywhere else compare by value, like
|
|
1630
|
+
* any element's.
|
|
1560
1631
|
*/
|
|
1561
|
-
|
|
1632
|
+
resolution = computed(() => {
|
|
1633
|
+
const boundWrites = this.boundWrites();
|
|
1562
1634
|
const el = this.rawElement();
|
|
1563
1635
|
if (!el)
|
|
1564
|
-
return undefined;
|
|
1636
|
+
return { element: undefined, boundWrites };
|
|
1565
1637
|
return {
|
|
1566
|
-
|
|
1567
|
-
|
|
1638
|
+
element: {
|
|
1639
|
+
...el,
|
|
1640
|
+
props: resolveElementProps(el.props ?? {}, this.resolutionCtx()),
|
|
1641
|
+
},
|
|
1642
|
+
boundWrites,
|
|
1568
1643
|
};
|
|
1569
|
-
}, { ...(ngDevMode ? { debugName: "
|
|
1570
|
-
if (a
|
|
1644
|
+
}, { ...(ngDevMode ? { debugName: "resolution" } : /* istanbul ignore next */ {}), equal: (a, b) => {
|
|
1645
|
+
if (a.boundWrites !== b.boundWrites)
|
|
1646
|
+
return false;
|
|
1647
|
+
if (a.element === b.element)
|
|
1571
1648
|
return true;
|
|
1572
|
-
if (!a || !b
|
|
1649
|
+
if (!a.element || !b.element)
|
|
1573
1650
|
return false;
|
|
1574
|
-
const { props: aProps, ...aRest } = a;
|
|
1575
|
-
const { props: bProps, ...bRest } = b;
|
|
1651
|
+
const { props: aProps, ...aRest } = a.element;
|
|
1652
|
+
const { props: bProps, ...bRest } = b.element;
|
|
1576
1653
|
// Everything but props is the spec element itself, never state.
|
|
1577
1654
|
return (sameValues(aRest, bRest, true) &&
|
|
1578
1655
|
sameValues(aProps, bProps, !untracked(this.root.store)));
|
|
1579
1656
|
} });
|
|
1657
|
+
/** The element with all prop expressions resolved. */
|
|
1658
|
+
resolvedElement = computed(() => this.resolution().element, ...(ngDevMode ? [{ debugName: "resolvedElement" }] : /* istanbul ignore next */ []));
|
|
1580
1659
|
/** Two-way binding paths ($bindState / $bindItem) by prop name. */
|
|
1581
1660
|
bindings = computed(() => {
|
|
1582
1661
|
const el = this.rawElement();
|
|
@@ -1687,6 +1766,21 @@ class JrElement {
|
|
|
1687
1766
|
});
|
|
1688
1767
|
});
|
|
1689
1768
|
}
|
|
1769
|
+
// Count the writes that touch a path this element is bound to. Only an
|
|
1770
|
+
// element with bindings listens, and it reads them when a write arrives,
|
|
1771
|
+
// so a binding that moves (a repeat item that shifts) is followed without
|
|
1772
|
+
// subscribing again.
|
|
1773
|
+
effect((onCleanup) => {
|
|
1774
|
+
if (!this.bindings())
|
|
1775
|
+
return;
|
|
1776
|
+
const unsubscribe = this.state.subscribeChanges((changes) => {
|
|
1777
|
+
const paths = Object.values(untracked(this.bindings) ?? {});
|
|
1778
|
+
if (changes.some((change) => paths.some((path) => touches(change.path, path)))) {
|
|
1779
|
+
this.boundWrites.update((count) => count + 1);
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
onCleanup(unsubscribe);
|
|
1783
|
+
});
|
|
1690
1784
|
// Warn (once per type) about unknown component types.
|
|
1691
1785
|
const warnedTypes = new Set();
|
|
1692
1786
|
effect(() => {
|