ngx-json-render 0.7.4 → 0.7.6

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
@@ -661,6 +661,12 @@ const bound = injectBoundProp<string>(() => ctx.props().value, () => ctx.binding
661
661
 
662
662
  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.
663
663
 
664
+ `watch` also fires for writes made on that store from outside the renderer: on
665
+ every store notification the renderer compares the new snapshot with the last
666
+ one, branch by branch by reference, and reports each changed path. A store
667
+ that mutates its snapshot in place therefore re-renders but triggers no
668
+ `watch`; replace objects rather than editing them.
669
+
664
670
  ### What a state write re-renders
665
671
 
666
672
  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.
@@ -849,7 +855,9 @@ Spec checking: `checkSpec`, `formatSpecCheckIssues`, and the types
849
855
 
850
856
  `injectActions().execute()` rejects when the user dismisses a `confirm`
851
857
  dialog, which is a normal gesture rather than a failure — `isActionCancelled(error)`
852
- is how you tell the two apart.
858
+ is how you tell the two apart. One dialog is open at a time: an action with a
859
+ `confirm` dispatched while another is waiting cancels the waiting one, so its
860
+ `execute()` rejects the same way.
853
861
 
854
862
  Registry & schema: `defineRegistry`, `createStoreSetState`, `schema`.
855
863
 
@@ -1,9 +1,8 @@
1
1
  import { NgComponentOutlet } from '@angular/common';
2
2
  import * as i0 from '@angular/core';
3
- import { signal, Injectable, inject, computed, untracked, effect, isDevMode, DestroyRef, InjectionToken, input, output, viewChild, afterNextRender, ChangeDetectionStrategy, Component, Injector, forwardRef } from '@angular/core';
3
+ import { signal, Injectable, isDevMode, inject, computed, untracked, effect, DestroyRef, InjectionToken, input, output, viewChild, afterNextRender, ChangeDetectionStrategy, Component, Injector, forwardRef } from '@angular/core';
4
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
- import { flattenToPointers } from '@json-render/core/store-utils';
7
6
 
8
7
  const EMPTY = signal(undefined, ...(ngDevMode ? [{ debugName: "EMPTY" }] : /* istanbul ignore next */ []));
9
8
  /**
@@ -49,6 +48,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
49
48
  type: Injectable
50
49
  }] });
51
50
 
51
+ /** Nesting below this is kept as one leaf, the same cut-off core uses. */
52
+ const MAX_FLATTEN_DEPTH = 20;
53
+ /** Escape one key for a JSON Pointer: `~` → `~0`, `/` → `~1`. */
54
+ function escapePointerSegment(key) {
55
+ return key.replace(/~/g, '~0').replace(/\//g, '~1');
56
+ }
57
+ /**
58
+ * Flatten plain objects into a `{ pointer: leaf }` map; arrays and anything
59
+ * that is not a plain object are leaves.
60
+ *
61
+ * Core's `flattenToPointers` does the same but joins keys unescaped, so a key
62
+ * such as `a/b` comes back as `/a/b` and a store write puts it at `a` → `b`.
63
+ * Every path built here is escaped, so it reads back the key it came from.
64
+ */
65
+ function flattenToPointers(obj) {
66
+ const result = {};
67
+ const seen = new Set();
68
+ let warned = false;
69
+ const walk = (value, prefix, depth) => {
70
+ for (const [key, child] of Object.entries(value)) {
71
+ const pointer = `${prefix}/${escapePointerSegment(key)}`;
72
+ if (isPlainObject(child) && !seen.has(child)) {
73
+ if (depth < MAX_FLATTEN_DEPTH) {
74
+ seen.add(child);
75
+ walk(child, pointer, depth + 1);
76
+ continue;
77
+ }
78
+ if (isDevMode() && !warned) {
79
+ warned = true;
80
+ console.warn(`[ngx-json-render] state nested deeper than ${MAX_FLATTEN_DEPTH} levels is treated as one value.`);
81
+ }
82
+ }
83
+ result[pointer] = child;
84
+ }
85
+ };
86
+ walk(obj, '', 0);
87
+ return result;
88
+ }
89
+ function isPlainObject(value) {
90
+ return (value !== null &&
91
+ typeof value === 'object' &&
92
+ Object.getPrototypeOf(value) === Object.prototype);
93
+ }
94
+
52
95
  /**
53
96
  * The state store of a `<json-render>` subtree.
54
97
  *
@@ -63,6 +106,10 @@ class JsonRenderStateService {
63
106
  root = inject(JsonRenderRootContext);
64
107
  internalStore = createStateStore({});
65
108
  changeListeners = new Set();
109
+ /** The snapshot the change listeners last heard about. */
110
+ lastSnapshot = {};
111
+ /** True while this service writes, so the store's echo is not reported. */
112
+ writing = false;
66
113
  _state = signal({}, { ...(ngDevMode ? { debugName: "_state" } : /* istanbul ignore next */ {}), equal: () => false });
67
114
  /** The current state model as a signal. */
68
115
  state = this._state.asReadonly();
@@ -82,9 +129,23 @@ class JsonRenderStateService {
82
129
  console.warn(`[ngx-json-render] switching from ${initialMode} to ${mode} mode is not supported.`);
83
130
  }
84
131
  }
85
- untracked(() => this._state.set(store.getSnapshot()));
132
+ const controlled = store !== this.internalStore;
133
+ this.lastSnapshot = store.getSnapshot();
134
+ untracked(() => this._state.set(this.lastSnapshot));
86
135
  const unsubscribe = store.subscribe(() => {
87
- this._state.set(store.getSnapshot());
136
+ const prev = this.lastSnapshot;
137
+ const next = store.getSnapshot();
138
+ // Recorded before the listeners run: a watch action may write again.
139
+ this.lastSnapshot = next;
140
+ this._state.set(next);
141
+ // A write the host made on its own store reaches no `set`/`update`
142
+ // here, so the diff is the only way `watch` hears about it. Our own
143
+ // writes report themselves, with the paths they were given.
144
+ if (controlled && !this.writing && this.changeListeners.size > 0) {
145
+ const changes = diffState(prev, next);
146
+ if (changes.length > 0)
147
+ this.notifyChanges(changes);
148
+ }
88
149
  });
89
150
  onCleanup(unsubscribe);
90
151
  });
@@ -124,7 +185,7 @@ class JsonRenderStateService {
124
185
  const store = untracked(this.currentStore);
125
186
  const prev = store.getSnapshot();
126
187
  const prevValue = getByPath(prev, path);
127
- store.set(path, value);
188
+ this.write(store, () => store.set(path, value));
128
189
  if (prevValue !== value) {
129
190
  const changes = [{ path, value }];
130
191
  this.notifyChanges(changes);
@@ -137,7 +198,7 @@ class JsonRenderStateService {
137
198
  update(updates) {
138
199
  const store = untracked(this.currentStore);
139
200
  const prev = store.getSnapshot();
140
- store.update(updates);
201
+ this.write(store, () => store.update(updates));
141
202
  const changes = [];
142
203
  for (const [path, value] of Object.entries(updates)) {
143
204
  if (getByPath(prev, path) !== value) {
@@ -157,8 +218,9 @@ class JsonRenderStateService {
157
218
  }
158
219
  /**
159
220
  * Register a listener called with the list of changed paths whenever state
160
- * is written through this service (element `watch` fields rely on this).
161
- * Returns an unsubscribe function.
221
+ * is written through this service, or, in controlled mode, whenever the
222
+ * external store changes (element `watch` fields rely on this). Returns an
223
+ * unsubscribe function.
162
224
  */
163
225
  subscribeChanges(listener) {
164
226
  this.changeListeners.add(listener);
@@ -166,6 +228,17 @@ class JsonRenderStateService {
166
228
  this.changeListeners.delete(listener);
167
229
  };
168
230
  }
231
+ write(store, apply) {
232
+ const wasWriting = this.writing;
233
+ this.writing = true;
234
+ try {
235
+ apply();
236
+ }
237
+ finally {
238
+ this.writing = wasWriting;
239
+ }
240
+ this.lastSnapshot = store.getSnapshot();
241
+ }
169
242
  notifyChanges(changes) {
170
243
  for (const listener of this.changeListeners) {
171
244
  listener(changes);
@@ -177,6 +250,35 @@ class JsonRenderStateService {
177
250
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderStateService, decorators: [{
178
251
  type: Injectable
179
252
  }], ctorParameters: () => [] });
253
+ /**
254
+ * Every path whose value differs between two state snapshots, each container
255
+ * before what it holds, values taken from `next`. Subtrees are compared by
256
+ * reference, so an unchanged branch costs nothing, and a store that mutates a
257
+ * snapshot in place reports no change: the same rule as a signal's `set`.
258
+ */
259
+ function diffState(prev, next) {
260
+ const changes = [];
261
+ const walk = (a, b, path) => {
262
+ if (a === b)
263
+ return;
264
+ if (path)
265
+ changes.push({ path, value: b });
266
+ const keys = new Set([...childKeys(a), ...childKeys(b)]);
267
+ for (const key of keys) {
268
+ walk(child(a, key), child(b, key), `${path}/${escapePointerSegment(key)}`);
269
+ }
270
+ };
271
+ walk(prev, next, '');
272
+ return changes;
273
+ }
274
+ function childKeys(value) {
275
+ return value !== null && typeof value === 'object' ? Object.keys(value) : [];
276
+ }
277
+ function child(value, key) {
278
+ return value !== null && typeof value === 'object'
279
+ ? value[key]
280
+ : undefined;
281
+ }
180
282
  /**
181
283
  * Inject the state store of the nearest `<json-render>` renderer.
182
284
  * Must be called from within the renderer's subtree (e.g. a catalog
@@ -348,17 +450,9 @@ class JsonRenderValidationService {
348
450
  /** Validate a single field and record the result. */
349
451
  validate(path, config) {
350
452
  const currentState = this.state.getSnapshot();
351
- const segments = path.split('/').filter(Boolean);
352
- let value = currentState;
353
- for (const seg of segments) {
354
- if (value != null && typeof value === 'object') {
355
- value = value[seg];
356
- }
357
- else {
358
- value = undefined;
359
- break;
360
- }
361
- }
453
+ // The store's own pointer walk, so `~1` and `~0` mean what they mean
454
+ // everywhere else a path is read.
455
+ const value = getByPath(currentState, path);
362
456
  const result = runValidation(config, {
363
457
  value,
364
458
  stateModel: currentState,
@@ -665,21 +759,19 @@ class JsonRenderActionsService {
665
759
  return;
666
760
  }
667
761
  if (resolved.confirm) {
762
+ // One question at a time: a confirm arriving while another is open
763
+ // cancels that one, so its caller settles instead of waiting forever.
764
+ untracked(this._pendingConfirmation)?.reject();
668
765
  // Awaited, not returned: a returned promise would let the `finally`
669
766
  // below settle the observers before the user has even answered.
670
767
  await new Promise((resolve, reject) => {
671
- this._pendingConfirmation.set({
768
+ const pending = {
672
769
  action: resolved,
673
770
  handler,
674
- resolve: () => {
675
- this._pendingConfirmation.set(null);
676
- resolve();
677
- },
678
- reject: () => {
679
- this._pendingConfirmation.set(null);
680
- reject(new ActionCancelledError());
681
- },
682
- });
771
+ resolve: () => this.answer(pending, resolve),
772
+ reject: () => this.answer(pending, () => reject(new ActionCancelledError())),
773
+ };
774
+ this._pendingConfirmation.set(pending);
683
775
  });
684
776
  }
685
777
  await this.runHandler(resolved, handler);
@@ -709,6 +801,17 @@ class JsonRenderActionsService {
709
801
  cancel() {
710
802
  untracked(this._pendingConfirmation)?.reject();
711
803
  }
804
+ /**
805
+ * Close `pending` and run `settle`, but only while it is still the open
806
+ * question: a late answer to one that was replaced must not close its
807
+ * successor.
808
+ */
809
+ answer(pending, settle) {
810
+ if (untracked(this._pendingConfirmation) !== pending)
811
+ return;
812
+ this._pendingConfirmation.set(null);
813
+ settle();
814
+ }
712
815
  /**
713
816
  * Validate every registered field and write `{ valid, errors }` to the
714
817
  * action's `statePath` (default `/formValidation`). Returns null when there