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 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 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.
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 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.
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), those with a two-way binding, and any prop with a `$`-key the renderer doesn't recognise.
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] !== nextFlat[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
- if (typeof value['$bindState'] === 'string')
1127
- return false;
1128
- if (typeof value['$bindItem'] === 'string')
1129
- return false;
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
- /** The raw (unresolved) element from the spec. */
1462
- rawElement = computed(() => this.root.spec()?.elements?.[this.elementKey()], ...(ngDevMode ? [{ debugName: "rawElement" }] : /* istanbul ignore next */ []));
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
- * The element with all prop expressions resolved.
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 on every state write, whatever the
1549
- * write touched. Handing that on as a change would re-run every catalog
1550
- * template in the tree for one keystroke, so a result that holds the same
1551
- * values as the last one counts as unchanged and keeps the old object.
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
- * Two-way bound elements are the exception, and keep treating every
1554
- * resolution as a change. Their component writes the user's input into the
1555
- * DOM before it reaches state, so the DOM can differ from a prop whose
1556
- * value never did: typing and a `clearStatePath` in the same turn take
1557
- * the prop from '' through 'milk' back to '' with nobody reading 'milk'.
1558
- * The README tells catalog authors to sync such inputs with an effect on
1559
- * props, and that effect only gets to put the DOM right if it runs.
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
- resolvedElement = computed(() => {
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
- ...el,
1567
- props: resolveElementProps(el.props ?? {}, this.resolutionCtx()),
1638
+ element: {
1639
+ ...el,
1640
+ props: resolveElementProps(el.props ?? {}, this.resolutionCtx()),
1641
+ },
1642
+ boundWrites,
1568
1643
  };
1569
- }, { ...(ngDevMode ? { debugName: "resolvedElement" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1570
- if (a === b)
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 || untracked(this.bindings))
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(() => {