ngx-json-render 0.7.4 → 0.7.5

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
 
@@ -63,6 +63,10 @@ class JsonRenderStateService {
63
63
  root = inject(JsonRenderRootContext);
64
64
  internalStore = createStateStore({});
65
65
  changeListeners = new Set();
66
+ /** The snapshot the change listeners last heard about. */
67
+ lastSnapshot = {};
68
+ /** True while this service writes, so the store's echo is not reported. */
69
+ writing = false;
66
70
  _state = signal({}, { ...(ngDevMode ? { debugName: "_state" } : /* istanbul ignore next */ {}), equal: () => false });
67
71
  /** The current state model as a signal. */
68
72
  state = this._state.asReadonly();
@@ -82,9 +86,23 @@ class JsonRenderStateService {
82
86
  console.warn(`[ngx-json-render] switching from ${initialMode} to ${mode} mode is not supported.`);
83
87
  }
84
88
  }
85
- untracked(() => this._state.set(store.getSnapshot()));
89
+ const controlled = store !== this.internalStore;
90
+ this.lastSnapshot = store.getSnapshot();
91
+ untracked(() => this._state.set(this.lastSnapshot));
86
92
  const unsubscribe = store.subscribe(() => {
87
- this._state.set(store.getSnapshot());
93
+ const prev = this.lastSnapshot;
94
+ const next = store.getSnapshot();
95
+ // Recorded before the listeners run: a watch action may write again.
96
+ this.lastSnapshot = next;
97
+ this._state.set(next);
98
+ // A write the host made on its own store reaches no `set`/`update`
99
+ // here, so the diff is the only way `watch` hears about it. Our own
100
+ // writes report themselves, with the paths they were given.
101
+ if (controlled && !this.writing && this.changeListeners.size > 0) {
102
+ const changes = diffState(prev, next);
103
+ if (changes.length > 0)
104
+ this.notifyChanges(changes);
105
+ }
88
106
  });
89
107
  onCleanup(unsubscribe);
90
108
  });
@@ -124,7 +142,7 @@ class JsonRenderStateService {
124
142
  const store = untracked(this.currentStore);
125
143
  const prev = store.getSnapshot();
126
144
  const prevValue = getByPath(prev, path);
127
- store.set(path, value);
145
+ this.write(store, () => store.set(path, value));
128
146
  if (prevValue !== value) {
129
147
  const changes = [{ path, value }];
130
148
  this.notifyChanges(changes);
@@ -137,7 +155,7 @@ class JsonRenderStateService {
137
155
  update(updates) {
138
156
  const store = untracked(this.currentStore);
139
157
  const prev = store.getSnapshot();
140
- store.update(updates);
158
+ this.write(store, () => store.update(updates));
141
159
  const changes = [];
142
160
  for (const [path, value] of Object.entries(updates)) {
143
161
  if (getByPath(prev, path) !== value) {
@@ -157,8 +175,9 @@ class JsonRenderStateService {
157
175
  }
158
176
  /**
159
177
  * 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.
178
+ * is written through this service, or, in controlled mode, whenever the
179
+ * external store changes (element `watch` fields rely on this). Returns an
180
+ * unsubscribe function.
162
181
  */
163
182
  subscribeChanges(listener) {
164
183
  this.changeListeners.add(listener);
@@ -166,6 +185,17 @@ class JsonRenderStateService {
166
185
  this.changeListeners.delete(listener);
167
186
  };
168
187
  }
188
+ write(store, apply) {
189
+ const wasWriting = this.writing;
190
+ this.writing = true;
191
+ try {
192
+ apply();
193
+ }
194
+ finally {
195
+ this.writing = wasWriting;
196
+ }
197
+ this.lastSnapshot = store.getSnapshot();
198
+ }
169
199
  notifyChanges(changes) {
170
200
  for (const listener of this.changeListeners) {
171
201
  listener(changes);
@@ -177,6 +207,38 @@ class JsonRenderStateService {
177
207
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderStateService, decorators: [{
178
208
  type: Injectable
179
209
  }], ctorParameters: () => [] });
210
+ /**
211
+ * Every path whose value differs between two state snapshots, each container
212
+ * before what it holds, values taken from `next`. Subtrees are compared by
213
+ * reference, so an unchanged branch costs nothing, and a store that mutates a
214
+ * snapshot in place reports no change: the same rule as a signal's `set`.
215
+ */
216
+ function diffState(prev, next) {
217
+ const changes = [];
218
+ const walk = (a, b, path) => {
219
+ if (a === b)
220
+ return;
221
+ if (path)
222
+ changes.push({ path, value: b });
223
+ const keys = new Set([...childKeys(a), ...childKeys(b)]);
224
+ for (const key of keys) {
225
+ walk(child(a, key), child(b, key), `${path}/${escapePointer(key)}`);
226
+ }
227
+ };
228
+ walk(prev, next, '');
229
+ return changes;
230
+ }
231
+ function childKeys(value) {
232
+ return value !== null && typeof value === 'object' ? Object.keys(value) : [];
233
+ }
234
+ function child(value, key) {
235
+ return value !== null && typeof value === 'object'
236
+ ? value[key]
237
+ : undefined;
238
+ }
239
+ function escapePointer(key) {
240
+ return key.replace(/~/g, '~0').replace(/\//g, '~1');
241
+ }
180
242
  /**
181
243
  * Inject the state store of the nearest `<json-render>` renderer.
182
244
  * Must be called from within the renderer's subtree (e.g. a catalog
@@ -665,21 +727,19 @@ class JsonRenderActionsService {
665
727
  return;
666
728
  }
667
729
  if (resolved.confirm) {
730
+ // One question at a time: a confirm arriving while another is open
731
+ // cancels that one, so its caller settles instead of waiting forever.
732
+ untracked(this._pendingConfirmation)?.reject();
668
733
  // Awaited, not returned: a returned promise would let the `finally`
669
734
  // below settle the observers before the user has even answered.
670
735
  await new Promise((resolve, reject) => {
671
- this._pendingConfirmation.set({
736
+ const pending = {
672
737
  action: resolved,
673
738
  handler,
674
- resolve: () => {
675
- this._pendingConfirmation.set(null);
676
- resolve();
677
- },
678
- reject: () => {
679
- this._pendingConfirmation.set(null);
680
- reject(new ActionCancelledError());
681
- },
682
- });
739
+ resolve: () => this.answer(pending, resolve),
740
+ reject: () => this.answer(pending, () => reject(new ActionCancelledError())),
741
+ };
742
+ this._pendingConfirmation.set(pending);
683
743
  });
684
744
  }
685
745
  await this.runHandler(resolved, handler);
@@ -709,6 +769,17 @@ class JsonRenderActionsService {
709
769
  cancel() {
710
770
  untracked(this._pendingConfirmation)?.reject();
711
771
  }
772
+ /**
773
+ * Close `pending` and run `settle`, but only while it is still the open
774
+ * question: a late answer to one that was replaced must not close its
775
+ * successor.
776
+ */
777
+ answer(pending, settle) {
778
+ if (untracked(this._pendingConfirmation) !== pending)
779
+ return;
780
+ this._pendingConfirmation.set(null);
781
+ settle();
782
+ }
712
783
  /**
713
784
  * Validate every registered field and write `{ valid, errors }` to the
714
785
  * action's `statePath` (default `/formValidation`). Returns null when there