cx 26.6.0 → 26.7.1

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.
Files changed (69) hide show
  1. package/build/data/ExposedValueView.js +1 -1
  2. package/build/data/comparer.d.ts +6 -8
  3. package/build/data/comparer.d.ts.map +1 -1
  4. package/build/ui/Cx.d.ts +5 -0
  5. package/build/ui/Cx.d.ts.map +1 -1
  6. package/build/ui/Cx.js +34 -3
  7. package/build/ui/Prop.d.ts +1 -1
  8. package/build/ui/Prop.d.ts.map +1 -1
  9. package/build/ui/Restate.d.ts +6 -0
  10. package/build/ui/Restate.d.ts.map +1 -1
  11. package/build/ui/Restate.js +2 -1
  12. package/build/ui/adapter/ArrayAdapter.d.ts +4 -6
  13. package/build/ui/adapter/ArrayAdapter.d.ts.map +1 -1
  14. package/build/ui/adapter/GroupAdapter.d.ts +22 -2
  15. package/build/ui/adapter/GroupAdapter.d.ts.map +1 -1
  16. package/build/ui/adapter/GroupAdapter.js +80 -5
  17. package/build/widgets/DocumentTitle.d.ts.map +1 -1
  18. package/build/widgets/DocumentTitle.js +2 -0
  19. package/build/widgets/drag-drop/DragSource.d.ts +3 -2
  20. package/build/widgets/drag-drop/DragSource.d.ts.map +1 -1
  21. package/build/widgets/drag-drop/DropZone.d.ts +4 -4
  22. package/build/widgets/drag-drop/DropZone.d.ts.map +1 -1
  23. package/build/widgets/drag-drop/ops.d.ts +13 -2
  24. package/build/widgets/drag-drop/ops.d.ts.map +1 -1
  25. package/build/widgets/drag-drop/ops.js +36 -9
  26. package/build/widgets/form/NumberField.js +1 -0
  27. package/build/widgets/grid/Grid.d.ts +19 -3
  28. package/build/widgets/grid/Grid.d.ts.map +1 -1
  29. package/build/widgets/grid/Grid.js +2 -0
  30. package/build/widgets/grid/TreeNode.d.ts +2 -0
  31. package/build/widgets/grid/TreeNode.d.ts.map +1 -1
  32. package/build/widgets/overlay/captureMouse.d.ts +4 -4
  33. package/build/widgets/overlay/captureMouse.d.ts.map +1 -1
  34. package/build/widgets/overlay/captureMouse.js +8 -3
  35. package/dist/data.js +1 -1
  36. package/dist/manifest.js +866 -866
  37. package/dist/ui.js +130 -12
  38. package/dist/widgets.css +4 -0
  39. package/dist/widgets.js +43 -10
  40. package/package.json +1 -1
  41. package/src/data/ExposedValueView.spec.ts +57 -0
  42. package/src/data/ExposedValueView.ts +1 -1
  43. package/src/data/comparer.ts +6 -7
  44. package/src/ui/Cx.tsx +44 -3
  45. package/src/ui/DataProxy.ts +55 -55
  46. package/src/ui/Prop.ts +1 -1
  47. package/src/ui/Rescope.ts +50 -50
  48. package/src/ui/Restate.tsx +9 -0
  49. package/src/ui/adapter/ArrayAdapter.ts +6 -8
  50. package/src/ui/adapter/GroupAdapter.spec.ts +75 -0
  51. package/src/ui/adapter/GroupAdapter.ts +103 -10
  52. package/src/ui/exprHelpers.ts +96 -96
  53. package/src/widgets/DocumentTitle.ts +2 -0
  54. package/src/widgets/Sandbox.ts +104 -104
  55. package/src/widgets/drag-drop/DragSource.tsx +3 -3
  56. package/src/widgets/drag-drop/DropZone.tsx +5 -5
  57. package/src/widgets/drag-drop/ops.tsx +54 -12
  58. package/src/widgets/form/ColorField.scss +112 -112
  59. package/src/widgets/form/DateTimeField.scss +111 -111
  60. package/src/widgets/form/LookupField.maps.scss +26 -26
  61. package/src/widgets/form/MonthField.scss +113 -113
  62. package/src/widgets/form/NumberField.scss +4 -0
  63. package/src/widgets/form/NumberField.tsx +1 -0
  64. package/src/widgets/form/Select.scss +104 -104
  65. package/src/widgets/form/TextField.scss +66 -66
  66. package/src/widgets/grid/Grid.tsx +23 -2
  67. package/src/widgets/grid/TreeNode.tsx +3 -0
  68. package/src/widgets/nav/MenuItem.tsx +525 -525
  69. package/src/widgets/overlay/captureMouse.ts +14 -7
package/dist/ui.js CHANGED
@@ -2094,6 +2094,30 @@ class LinkedListsNode {
2094
2094
  }
2095
2095
  }
2096
2096
 
2097
+ // Optional guard (opt in per-instance via the `limitSyncBursts` prop) against React's "Maximum update depth
2098
+ // exceeded" during very large synchronous render bursts. On the initial render of a large document the store
2099
+ // can be mutated thousands of times within a single React commit: every Instance.setState wraps store.notify()
2100
+ // in batchUpdates(), so isBatchingUpdates() stays true and a subscribed Cx takes its *synchronous* setState
2101
+ // branch on each notification. React counts those as nested render-phase updates and aborts past ~50
2102
+ // (surfacing as the recoverable "error during concurrent rendering"). When a Cx opts in, once back-to-back
2103
+ // updates exceed the limit it falls back to the coalesced setTimeout branch, which renders the latest store
2104
+ // data on the next tick. The counter is global (it mirrors React's global nested-update counter) and resets
2105
+ // after any idle gap. It is advanced on every update() of an opted-in Cx -- counting only the synchronous
2106
+ // branch would let the window reset whenever a deferred update lands between two synchronous ones, so the
2107
+ // counter would never reach the limit. performance.now() gives sub-ms resolution (Date.now() is the fallback;
2108
+ // Timing.now() can't be used here because it returns 0 in production).
2109
+ const SYNC_UPDATE_BURST_LIMIT = 30;
2110
+ const SYNC_UPDATE_BURST_RESET_MS = 10;
2111
+ const syncUpdateBurstNow =
2112
+ typeof performance !== "undefined" && performance.now ? () => performance.now() : () => Date.now();
2113
+ let syncUpdateBurstCount = 0;
2114
+ let syncUpdateBurstLastAt = 0;
2115
+ function trackSyncUpdateBurst() {
2116
+ let t = syncUpdateBurstNow();
2117
+ if (t - syncUpdateBurstLastAt > SYNC_UPDATE_BURST_RESET_MS) syncUpdateBurstCount = 0;
2118
+ syncUpdateBurstLastAt = t;
2119
+ return ++syncUpdateBurstCount > SYNC_UPDATE_BURST_LIMIT;
2120
+ }
2097
2121
  class Cx extends VDOM.Component {
2098
2122
  widget;
2099
2123
  store;
@@ -2192,7 +2216,11 @@ class Cx extends VDOM.Component {
2192
2216
  let data = this.store.getData();
2193
2217
  debug(appDataFlag, data);
2194
2218
  if (this.flags.preparing) this.flags.dirty = true;
2195
- else if (isBatchingUpdates() || this.props.immediate) {
2219
+ // `immediate` always renders synchronously (its contract). Otherwise, while batching, a Cx that opts in
2220
+ // via `limitSyncBursts` defers once a synchronous burst gets too deep (avoids React's max-update-depth).
2221
+ // Without the flag the condition is unchanged -- trackSyncUpdateBurst() is never even called, so the
2222
+ // guard has zero effect on instances that don't opt in.
2223
+ else if (this.props.immediate || (isBatchingUpdates() && !(this.props.limitSyncBursts && trackSyncUpdateBurst()))) {
2196
2224
  notifyBatchedUpdateStarting();
2197
2225
  this.setState(
2198
2226
  {
@@ -2201,14 +2229,17 @@ class Cx extends VDOM.Component {
2201
2229
  notifyBatchedUpdateCompleted,
2202
2230
  );
2203
2231
  } else {
2204
- //in standard mode sequential store commands are batched
2232
+ //in standard mode sequential store commands are batched -- as is any update that exceeds the
2233
+ //synchronous burst limit above, which falls through here to avoid React's max-update-depth abort
2205
2234
  if (!this.pendingUpdateTimer) {
2206
2235
  notifyBatchedUpdateStarting();
2207
2236
  this.pendingUpdateTimer = setTimeout(() => {
2208
2237
  delete this.pendingUpdateTimer;
2238
+ //for opted-in instances read fresh data at fire time so a deferred (coalesced) update isn't
2239
+ //left stale after a burst; otherwise keep the original captured-data behavior unchanged
2209
2240
  this.setState(
2210
2241
  {
2211
- data: data,
2242
+ data: this.props.limitSyncBursts ? this.store.getData() : data,
2212
2243
  },
2213
2244
  notifyBatchedUpdateCompleted,
2214
2245
  );
@@ -2662,6 +2693,7 @@ class Restate extends PureContainerBase {
2662
2693
  deferredUntilIdle: instance.data.deferredUntilIdle,
2663
2694
  idleTimeout: instance.data.idleTimeout,
2664
2695
  immediate: this.immediate,
2696
+ limitSyncBursts: this.limitSyncBursts,
2665
2697
  cultureInfo: instance.cultureInfo,
2666
2698
  },
2667
2699
  key,
@@ -2671,6 +2703,7 @@ class Restate extends PureContainerBase {
2671
2703
  Restate.prototype.detached = false;
2672
2704
  Restate.prototype.waitForIdle = false;
2673
2705
  Restate.prototype.immediate = false;
2706
+ Restate.prototype.limitSyncBursts = false;
2674
2707
  Restate.prototype.culture = null;
2675
2708
  const PrivateStore = Restate;
2676
2709
  class RestateStore extends Store {
@@ -3829,9 +3862,38 @@ function startHotAppLoop(appModule, element, store, widgets, options = {}) {
3829
3862
  }
3830
3863
 
3831
3864
  class GroupAdapter extends ArrayAdapter {
3865
+ /**
3866
+ * Per-level comparer derived from the active record sorters, used to reorder groups when
3867
+ * `sortGroupsBySorters` is set. A level's entry is `null` when no active sorter maps to that
3868
+ * level's key/aggregate, so the grouping's own configured order is kept.
3869
+ */
3870
+ groupSortComparers;
3832
3871
  constructor(config) {
3833
3872
  super(config);
3834
3873
  }
3874
+ sort(sorters) {
3875
+ super.sort(sorters);
3876
+ // When enabled, derive a group comparer from the active record sorters so that interactive
3877
+ // column sorting also reorders the groups. A column only reorders groups at levels where its
3878
+ // field is a group key or aggregate; other levels keep their configured order. The record-level
3879
+ // value selector is ignored — the field is resolved against the group's key/aggregates/name.
3880
+ if (this.sortGroupsBySorters && this.groupings) {
3881
+ const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
3882
+ const colSorters = isNonEmptyArray(sorters)
3883
+ ? sorters.map((s) => ({
3884
+ field: s.field,
3885
+ direction: s.direction,
3886
+ comparer: s.comparer,
3887
+ }))
3888
+ : [];
3889
+ this.groupSortComparers = this.groupings.map((g) => {
3890
+ if (colSorters.length === 0) return null;
3891
+ const fields = groupFieldNames(g, this.aggregates);
3892
+ const applicable = colSorters.filter((s) => s.field && fields.has(s.field));
3893
+ return applicable.length > 0 ? buildGroupComparer(applicable, cultureComparer) : null;
3894
+ });
3895
+ }
3896
+ }
3835
3897
  init() {
3836
3898
  super.init();
3837
3899
  if (this.groupRecordsAlias) {
@@ -3866,8 +3928,12 @@ class GroupAdapter extends ArrayAdapter {
3866
3928
  grouper.reset();
3867
3929
  grouper.processAll(records);
3868
3930
  let results = grouper.getResults();
3869
- if (grouping.comparer && !this.preserveOrder) {
3870
- results.sort(grouping.comparer);
3931
+ // An active column sort that maps to this level's key/aggregate (when `sortGroupsBySorters` is
3932
+ // enabled) takes over; otherwise the grouping's configured comparer (or implicit key order) applies.
3933
+ const dynamicComparer = this.sortGroupsBySorters ? this.groupSortComparers?.[level] : undefined;
3934
+ const comparer = dynamicComparer ?? grouping.comparer;
3935
+ if (comparer && !this.preserveOrder) {
3936
+ results.sort(comparer);
3871
3937
  }
3872
3938
  results.forEach((gr) => {
3873
3939
  keys.push(gr.key);
@@ -3949,15 +4015,26 @@ class GroupAdapter extends ArrayAdapter {
3949
4015
  (r) => r.store.getData(),
3950
4016
  g.text,
3951
4017
  );
4018
+ const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
4019
+ // Sort groups by an aggregate/key/name through `sortField`/`sortDirection` or a `sorters` array.
4020
+ let sortSorters = null;
4021
+ if (isNonEmptyArray(g.sorters)) sortSorters = g.sorters;
4022
+ else if (g.sortField)
4023
+ sortSorters = [
4024
+ {
4025
+ field: g.sortField,
4026
+ direction: g.sortDirection ?? "ASC",
4027
+ },
4028
+ ];
4029
+ // `comparer`, `sortField`/`sorters` and the implicit key order are equivalent alternatives; an
4030
+ // explicit `comparer` wins, then declarative sorters, then sorting by key in declaration order.
3952
4031
  const comparer =
3953
4032
  g.comparer ??
3954
- (groupSorters.length > 0
3955
- ? getComparer(
3956
- groupSorters,
3957
- (x) => x.key,
3958
- this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined,
3959
- )
3960
- : null);
4033
+ (sortSorters
4034
+ ? buildGroupComparer(sortSorters, cultureComparer)
4035
+ : groupSorters.length > 0
4036
+ ? getComparer(groupSorters, (x) => x.key, cultureComparer)
4037
+ : null);
3961
4038
  return {
3962
4039
  ...g,
3963
4040
  key: resolvedKey,
@@ -3972,6 +4049,47 @@ class GroupAdapter extends ArrayAdapter {
3972
4049
  }
3973
4050
  GroupAdapter.prototype.groupName = "$group";
3974
4051
  GroupAdapter.prototype.preserveOrder = false;
4052
+ GroupAdapter.prototype.sortGroupsBySorters = false;
4053
+ // The set of fields a group can be sorted by at a given level: its key fields, its aggregate aliases
4054
+ // and the group name. Used to decide whether an active column sort applies to that grouping level.
4055
+ function groupFieldNames(grouping, adapterAggregates) {
4056
+ const names = new Set();
4057
+ for (const k of grouping.grouper.keys) names.add(k.name);
4058
+ const aggregates = {
4059
+ ...adapterAggregates,
4060
+ ...grouping.aggregates,
4061
+ };
4062
+ for (const a in aggregates) names.add(a);
4063
+ names.add("name");
4064
+ names.add("$name");
4065
+ return names;
4066
+ }
4067
+ // Reads a sort field straight from the GroupResult — first its key fields, then its aggregates, then
4068
+ // its name. Returns a plain selector so groups can be sorted without cloning the result per comparison.
4069
+ function groupFieldSelector(field) {
4070
+ if (field === "name" || field === "$name") return (gr) => gr?.name;
4071
+ return (gr) => {
4072
+ if (gr?.key && field in gr.key) return gr.key[field];
4073
+ if (gr?.aggregates && field in gr.aggregates) return gr.aggregates[field];
4074
+ return undefined;
4075
+ };
4076
+ }
4077
+ // Builds a group comparer from a list of sorters. Each sorter resolves by its explicit `value`
4078
+ // selector, or by `field` looked up against the group's key/aggregates/name.
4079
+ function buildGroupComparer(sorters, cultureComparer) {
4080
+ return getComparer(
4081
+ sorters.map((s) =>
4082
+ isDefined(s.value) || !s.field
4083
+ ? s
4084
+ : {
4085
+ ...s,
4086
+ value: groupFieldSelector(s.field),
4087
+ },
4088
+ ),
4089
+ undefined,
4090
+ cultureComparer,
4091
+ );
4092
+ }
3975
4093
  function serializeKey(data) {
3976
4094
  if (isDataRecord(data)) {
3977
4095
  return Object.keys(data)
package/dist/widgets.css CHANGED
@@ -1086,6 +1086,10 @@
1086
1086
  padding-left: 26px;
1087
1087
  }
1088
1088
 
1089
+ .cxs-clear > .cxe-numberfield-input {
1090
+ padding-right: 22px;
1091
+ }
1092
+
1089
1093
  .cxe-numberfield-tool {
1090
1094
  pointer-events: none;
1091
1095
  display: block;
package/dist/widgets.js CHANGED
@@ -1124,6 +1124,7 @@ class DocumentTitle extends Widget {
1124
1124
  super.explore(context, instance);
1125
1125
  }
1126
1126
  prepare(context, instance) {
1127
+ if (typeof document == "undefined") return;
1127
1128
  if (context.documentTitle.activeInstance == instance) document.title = context.documentTitle.title;
1128
1129
  }
1129
1130
  render() {
@@ -2050,6 +2051,7 @@ function captureMouse2(e, options) {
2050
2051
  }
2051
2052
  }
2052
2053
  e.stopPropagation();
2054
+ return tear;
2053
2055
  function move(e) {
2054
2056
  if (!active) {
2055
2057
  tear();
@@ -2105,7 +2107,11 @@ function captureMouseOrTouch2(e, options) {
2105
2107
  el.addEventListener("touchmove", move);
2106
2108
  el.addEventListener("touchend", end);
2107
2109
  e.stopPropagation();
2108
- } else captureMouse2(e, options);
2110
+ return () => {
2111
+ el.removeEventListener("touchmove", move);
2112
+ el.removeEventListener("touchend", end);
2113
+ };
2114
+ } else return captureMouse2(e, options);
2109
2115
  }
2110
2116
  /**
2111
2117
  * Legacy function for capturing mouse events with individual parameters
@@ -2116,7 +2122,7 @@ function captureMouseOrTouch2(e, options) {
2116
2122
  * @param cursor - CSS cursor style for the capture surface
2117
2123
  */
2118
2124
  function captureMouse(e, onMouseMove, onMouseUp, captureData, cursor) {
2119
- captureMouse2(e, {
2125
+ return captureMouse2(e, {
2120
2126
  onMouseMove,
2121
2127
  onMouseUp,
2122
2128
  captureData,
@@ -2132,7 +2138,7 @@ function captureMouse(e, onMouseMove, onMouseUp, captureData, cursor) {
2132
2138
  * @param cursor - CSS cursor style for the capture surface
2133
2139
  */
2134
2140
  function captureMouseOrTouch(e, onMouseMove, onMouseUp, captureData, cursor) {
2135
- captureMouseOrTouch2(e, {
2141
+ return captureMouseOrTouch2(e, {
2136
2142
  onMouseMove,
2137
2143
  onMouseUp,
2138
2144
  captureData,
@@ -2403,6 +2409,7 @@ let puppet = null;
2403
2409
  let scrollTimer = null;
2404
2410
  let vscrollParent = null;
2405
2411
  let hscrollParent = null;
2412
+ let releaseCapture = null;
2406
2413
  function registerDropZone(dropZone) {
2407
2414
  dropZones.push(dropZone);
2408
2415
  return () => {
@@ -2488,7 +2495,15 @@ function initiateDragDrop(e, options = {}, onDragEnd) {
2488
2495
  dragStartedZones.set(zone, true);
2489
2496
  });
2490
2497
  notifyDragMove(e);
2491
- captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
2498
+ releaseCapture = captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
2499
+ document.addEventListener("keydown", onDragKeyDown, true);
2500
+ }
2501
+ function onDragKeyDown(e) {
2502
+ if (!puppet || e.key !== "Escape") return;
2503
+ //cancel the operation without dropping; there is no pointer event to report
2504
+ e.preventDefault();
2505
+ e.stopPropagation();
2506
+ notifyDragDrop(null, true);
2492
2507
  }
2493
2508
  function notifyDragMove(e, _captureData) {
2494
2509
  let event = getDragEvent(e, "dragmove");
@@ -2613,21 +2628,36 @@ function clearScrollTimer() {
2613
2628
  scrollTimer = null;
2614
2629
  }
2615
2630
  }
2616
- function notifyDragDrop(e) {
2631
+ function notifyDragDrop(e, cancelled = false) {
2632
+ if (!puppet) return;
2617
2633
  clearScrollTimer();
2618
- let event = getDragEvent(e, "dragdrop");
2634
+ document.removeEventListener("keydown", onDragKeyDown, true);
2619
2635
  if (puppet.stop) puppet.stop();
2620
- if (activeZone && activeZone.onDrop) event.result = activeZone.onDrop(event);
2636
+ //a keyboard cancellation has no pointer event, so the drop and away notifications are skipped
2637
+ let dropEvent = !cancelled && e ? getDragEvent(e, "dragdrop") : null;
2638
+ let result;
2639
+ if (dropEvent && activeZone && activeZone.onDrop) result = activeZone.onDrop(dropEvent);
2640
+ let endEvent = {
2641
+ type: "dragend",
2642
+ event: e,
2643
+ cursor: e ? getCursorPos(e) : null,
2644
+ source: puppet.source,
2645
+ cancelled,
2646
+ result,
2647
+ };
2621
2648
  dropZones.forEach((zone) => {
2622
- if (nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(event);
2649
+ if (dropEvent && nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(dropEvent);
2623
2650
  if (!dragStartedZones.has(zone)) return;
2624
- if (zone.onDragEnd) zone.onDragEnd(event);
2651
+ if (zone.onDragEnd) zone.onDragEnd(endEvent);
2625
2652
  });
2626
- if (puppet.onDragEnd) puppet.onDragEnd(event);
2653
+ if (puppet.onDragEnd) puppet.onDragEnd(endEvent);
2654
+ //tear down the mouse/touch capture when cancelling; on a normal drop the capture ends itself
2655
+ if (cancelled && releaseCapture) releaseCapture();
2627
2656
  nearZones = null;
2628
2657
  activeZone = null;
2629
2658
  puppet = null;
2630
2659
  dragStartedZones = null;
2660
+ releaseCapture = null;
2631
2661
  }
2632
2662
  function getDragEvent(event, type) {
2633
2663
  return {
@@ -9790,6 +9820,7 @@ let Input$1 = class Input extends VDOM.Component {
9790
9820
  visited: state.visited,
9791
9821
  focus: this.state.focus,
9792
9822
  icon: !!icon,
9823
+ clear: insideButton != null,
9793
9824
  empty: empty && !data.placeholder,
9794
9825
  error: data.error && (state.visited || !suppressErrorsUntilVisited || !empty),
9795
9826
  }),
@@ -15038,6 +15069,7 @@ class Grid extends ContainerBase {
15038
15069
  sortOptions: this.sortOptions,
15039
15070
  groupings: grouping,
15040
15071
  preserveOrder: this.preserveGroupOrder,
15072
+ sortGroupsBySorters: this.sortGroups,
15041
15073
  },
15042
15074
  this.dataAdapter,
15043
15075
  );
@@ -15983,6 +16015,7 @@ Grid.prototype.hoverChannel = "default";
15983
16015
  Grid.prototype.focusable = null; // automatically resolved
15984
16016
  Grid.prototype.allowsFileDrops = false;
15985
16017
  Grid.prototype.preserveGroupOrder = false;
16018
+ Grid.prototype.sortGroups = false;
15986
16019
  Widget.alias("grid", Grid);
15987
16020
  Localization.registerPrototype("cx/widgets/Grid", Grid);
15988
16021
  class GridComponent extends VDOM.Component {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cx",
3
- "version": "26.6.0",
3
+ "version": "26.7.1",
4
4
  "description": "Advanced JavaScript UI framework for admin and dashboard applications with ready to use grid, form and chart components.",
5
5
  "exports": {
6
6
  "./data": {
@@ -0,0 +1,57 @@
1
+ import assert from "assert";
2
+
3
+ import { Binding } from "./Binding";
4
+ import { Store } from "./Store";
5
+ import { ExposedValueView } from "./ExposedValueView";
6
+
7
+ describe("ExposedValueView", () => {
8
+ // Mirrors how Sandbox wires it: a `storage` object keyed by a slot `key`,
9
+ // exposed to children under `recordName` (e.g. "$page").
10
+ const getView = (key = "~/ordering/new") => {
11
+ let store = new Store({
12
+ data: {
13
+ pages: {
14
+ "~/ordering/new": { description: "draft order", count: 3 },
15
+ },
16
+ },
17
+ });
18
+
19
+ let view = new ExposedValueView({
20
+ store,
21
+ containerBinding: Binding.get("pages"),
22
+ key,
23
+ recordName: "$page",
24
+ });
25
+
26
+ return { store, view };
27
+ };
28
+
29
+ it("exposes the keyed slot under the record name", () => {
30
+ let { view } = getView();
31
+ assert.equal(view.get("$page.description"), "draft order");
32
+ assert.equal(view.get("$page.count"), 3);
33
+ });
34
+
35
+ it("writes back to the keyed slot on set", () => {
36
+ let { store, view } = getView();
37
+ view.set("$page.description", "edited");
38
+ assert.equal(store.get("pages")["~/ordering/new"].description, "edited");
39
+ });
40
+
41
+ it("deletes a property within the exposed record", () => {
42
+ let { store, view } = getView();
43
+ view.delete("$page.count");
44
+ assert.equal(view.get("$page.count"), undefined);
45
+ assert.ok(!store.get("pages")["~/ordering/new"].hasOwnProperty("count"));
46
+ });
47
+
48
+ it("deletes the entire exposed record (removes the keyed slot)", () => {
49
+ let { store, view } = getView();
50
+ view.delete("$page");
51
+ assert.equal(view.get("$page"), undefined);
52
+ assert.ok(
53
+ !store.get("pages").hasOwnProperty("~/ordering/new"),
54
+ "the keyed storage slot should be removed"
55
+ );
56
+ });
57
+ });
@@ -67,7 +67,7 @@ export class ExposedValueView extends View {
67
67
  if (path == this.recordName) {
68
68
  data = this.getData();
69
69
  container = this.containerBinding.value(data);
70
- if (!container || !container.hasOwnProperty(path)) return false;
70
+ if (!container || !container.hasOwnProperty(this.key)) return false;
71
71
  newContainer = Object.assign({}, container);
72
72
  delete newContainer[this.key];
73
73
  this.store.set(this.containerBinding.path, newContainer);
@@ -1,17 +1,16 @@
1
1
  import { getSelector } from "./getSelector";
2
2
  import { isDefined } from "../util/isDefined";
3
3
  import { defaultCompare } from "./defaultCompare";
4
+ import type { CollatorOptions, Sorter } from "../ui/Prop";
4
5
 
5
- interface Sorter {
6
- value?: any;
7
- field?: string;
8
- direction?: string;
6
+ export interface ExtendedSorter extends Sorter {
9
7
  comparer?: (a: any, b: any) => number;
10
8
  compare?: (a: any, b: any) => number;
9
+ sortOptions?: CollatorOptions;
11
10
  }
12
11
 
13
12
  export function getComparer(
14
- sorters: Sorter[],
13
+ sorters: ExtendedSorter[],
15
14
  dataAccessor?: (x: any) => any,
16
15
  comparer?: (a: any, b: any) => number,
17
16
  ): (a: any, b: any) => number {
@@ -51,7 +50,7 @@ export function getComparer(
51
50
  }
52
51
 
53
52
  export function indexSorter(
54
- sorters: Sorter[],
53
+ sorters: ExtendedSorter[],
55
54
  dataAccessor?: (x: any) => any,
56
55
  compare?: (a: any, b: any) => number,
57
56
  ): (data: any[]) => number[] {
@@ -64,7 +63,7 @@ export function indexSorter(
64
63
  }
65
64
 
66
65
  export function sorter(
67
- sorters: Sorter[],
66
+ sorters: ExtendedSorter[],
68
67
  dataAccessor?: (x: any) => any,
69
68
  compare?: (a: any, b: any) => number,
70
69
  ): (data: any[]) => any[] {
package/src/ui/Cx.tsx CHANGED
@@ -21,6 +21,11 @@ export interface CxProps {
21
21
  parentInstance?: Instance;
22
22
  subscribe?: boolean;
23
23
  immediate?: boolean;
24
+ /** Cap re-entrant synchronous updates: while batching, once a burst of back-to-back updates gets too
25
+ * deep, defer the excess to the coalesced (setTimeout) branch instead of updating synchronously. Avoids
26
+ * React's "Maximum update depth exceeded" on very large synchronous render bursts (e.g. the initial
27
+ * render of a huge document). Off by default; has no effect on instances that don't set it. */
28
+ limitSyncBursts?: boolean;
24
29
  deferredUntilIdle?: boolean;
25
30
  idleTimeout?: number;
26
31
  options?: any;
@@ -35,6 +40,32 @@ export interface CxState {
35
40
  data?: any;
36
41
  }
37
42
 
43
+ // Optional guard (opt in per-instance via the `limitSyncBursts` prop) against React's "Maximum update depth
44
+ // exceeded" during very large synchronous render bursts. On the initial render of a large document the store
45
+ // can be mutated thousands of times within a single React commit: every Instance.setState wraps store.notify()
46
+ // in batchUpdates(), so isBatchingUpdates() stays true and a subscribed Cx takes its *synchronous* setState
47
+ // branch on each notification. React counts those as nested render-phase updates and aborts past ~50
48
+ // (surfacing as the recoverable "error during concurrent rendering"). When a Cx opts in, once back-to-back
49
+ // updates exceed the limit it falls back to the coalesced setTimeout branch, which renders the latest store
50
+ // data on the next tick. The counter is global (it mirrors React's global nested-update counter) and resets
51
+ // after any idle gap. It is advanced on every update() of an opted-in Cx -- counting only the synchronous
52
+ // branch would let the window reset whenever a deferred update lands between two synchronous ones, so the
53
+ // counter would never reach the limit. performance.now() gives sub-ms resolution (Date.now() is the fallback;
54
+ // Timing.now() can't be used here because it returns 0 in production).
55
+ const SYNC_UPDATE_BURST_LIMIT = 30;
56
+ const SYNC_UPDATE_BURST_RESET_MS = 10;
57
+ const syncUpdateBurstNow: () => number =
58
+ typeof performance !== "undefined" && performance.now ? () => performance.now() : () => Date.now();
59
+ let syncUpdateBurstCount = 0;
60
+ let syncUpdateBurstLastAt = 0;
61
+
62
+ function trackSyncUpdateBurst(): boolean {
63
+ let t = syncUpdateBurstNow();
64
+ if (t - syncUpdateBurstLastAt > SYNC_UPDATE_BURST_RESET_MS) syncUpdateBurstCount = 0;
65
+ syncUpdateBurstLastAt = t;
66
+ return ++syncUpdateBurstCount > SYNC_UPDATE_BURST_LIMIT;
67
+ }
68
+
38
69
  export class Cx extends VDOM.Component<CxProps, CxState> {
39
70
  widget: Widget;
40
71
  store: View;
@@ -160,16 +191,26 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
160
191
  let data = this.store.getData();
161
192
  debug(appDataFlag, data);
162
193
  if (this.flags.preparing) this.flags.dirty = true;
163
- else if (isBatchingUpdates() || this.props.immediate) {
194
+ // `immediate` always renders synchronously (its contract). Otherwise, while batching, a Cx that opts in
195
+ // via `limitSyncBursts` defers once a synchronous burst gets too deep (avoids React's max-update-depth).
196
+ // Without the flag the condition is unchanged -- trackSyncUpdateBurst() is never even called, so the
197
+ // guard has zero effect on instances that don't opt in.
198
+ else if (this.props.immediate || (isBatchingUpdates() && !(this.props.limitSyncBursts && trackSyncUpdateBurst()))) {
164
199
  notifyBatchedUpdateStarting();
165
200
  this.setState({ data: data }, notifyBatchedUpdateCompleted);
166
201
  } else {
167
- //in standard mode sequential store commands are batched
202
+ //in standard mode sequential store commands are batched -- as is any update that exceeds the
203
+ //synchronous burst limit above, which falls through here to avoid React's max-update-depth abort
168
204
  if (!this.pendingUpdateTimer) {
169
205
  notifyBatchedUpdateStarting();
170
206
  this.pendingUpdateTimer = setTimeout(() => {
171
207
  delete this.pendingUpdateTimer;
172
- this.setState({ data: data }, notifyBatchedUpdateCompleted);
208
+ //for opted-in instances read fresh data at fire time so a deferred (coalesced) update isn't
209
+ //left stale after a burst; otherwise keep the original captured-data behavior unchanged
210
+ this.setState(
211
+ { data: this.props.limitSyncBursts ? this.store.getData() : data },
212
+ notifyBatchedUpdateCompleted,
213
+ );
173
214
  }, 0);
174
215
  }
175
216
  }
@@ -1,55 +1,55 @@
1
- import { AccessorChain } from "../data";
2
- import { NestedDataView } from "../data/NestedDataView";
3
- import { StructuredProp, WritableProp } from "./Prop";
4
- import { PureContainerBase, PureContainerConfig } from "./PureContainer";
5
- import { StructuredInstanceDataAccessor } from "./StructuredInstanceDataAccessor";
6
-
7
- export interface DataProxyConfig extends PureContainerConfig {
8
- /** Data object with computed values to be exposed in the local store. */
9
- data?: StructuredProp;
10
-
11
- /** Binding to a value to be exposed under the `alias` name. */
12
- value?: WritableProp<any>;
13
-
14
- /** Alias name under which `value` is exposed in the local store. */
15
- alias?: string | AccessorChain<any>;
16
-
17
- /** Indicate that parent store data should not be mutated. */
18
- immutable?: boolean;
19
-
20
- /** Indicate that local store data should not be mutated. */
21
- sealed?: boolean;
22
- }
23
-
24
- export class DataProxy extends PureContainerBase<DataProxyConfig> {
25
- declare data?: any;
26
- declare alias?: string;
27
- declare value?: any;
28
- declare immutable: boolean;
29
- declare sealed: boolean;
30
-
31
- init() {
32
- if (!this.data) this.data = {};
33
-
34
- if (this.alias) this.data[this.alias] = this.value;
35
-
36
- super.init();
37
- }
38
-
39
- initInstance(context: any, instance: any) {
40
- instance.store = new NestedDataView({
41
- store: instance.parentStore,
42
- nestedData: new StructuredInstanceDataAccessor({ instance, data: this.data, useParentStore: true }),
43
- immutable: this.immutable,
44
- sealed: this.sealed,
45
- });
46
- super.initInstance(context, instance);
47
- }
48
-
49
- applyParentStore(instance: any) {
50
- instance.store.setStore(instance.parentStore);
51
- }
52
- }
53
-
54
- DataProxy.prototype.immutable = false;
55
- DataProxy.prototype.sealed = false;
1
+ import { AccessorChain } from "../data";
2
+ import { NestedDataView } from "../data/NestedDataView";
3
+ import { StructuredProp, WritableProp } from "./Prop";
4
+ import { PureContainerBase, PureContainerConfig } from "./PureContainer";
5
+ import { StructuredInstanceDataAccessor } from "./StructuredInstanceDataAccessor";
6
+
7
+ export interface DataProxyConfig extends PureContainerConfig {
8
+ /** Data object with computed values to be exposed in the local store. */
9
+ data?: StructuredProp;
10
+
11
+ /** Binding to a value to be exposed under the `alias` name. */
12
+ value?: WritableProp<any>;
13
+
14
+ /** Alias name under which `value` is exposed in the local store. */
15
+ alias?: string | AccessorChain<any>;
16
+
17
+ /** Indicate that parent store data should not be mutated. */
18
+ immutable?: boolean;
19
+
20
+ /** Indicate that local store data should not be mutated. */
21
+ sealed?: boolean;
22
+ }
23
+
24
+ export class DataProxy extends PureContainerBase<DataProxyConfig> {
25
+ declare data?: any;
26
+ declare alias?: string;
27
+ declare value?: any;
28
+ declare immutable: boolean;
29
+ declare sealed: boolean;
30
+
31
+ init() {
32
+ if (!this.data) this.data = {};
33
+
34
+ if (this.alias) this.data[this.alias] = this.value;
35
+
36
+ super.init();
37
+ }
38
+
39
+ initInstance(context: any, instance: any) {
40
+ instance.store = new NestedDataView({
41
+ store: instance.parentStore,
42
+ nestedData: new StructuredInstanceDataAccessor({ instance, data: this.data, useParentStore: true }),
43
+ immutable: this.immutable,
44
+ sealed: this.sealed,
45
+ });
46
+ super.initInstance(context, instance);
47
+ }
48
+
49
+ applyParentStore(instance: any) {
50
+ instance.store.setStore(instance.parentStore);
51
+ }
52
+ }
53
+
54
+ DataProxy.prototype.immutable = false;
55
+ DataProxy.prototype.sealed = false;
package/src/ui/Prop.ts CHANGED
@@ -127,7 +127,7 @@ export type SortDirection = "ASC" | "DESC";
127
127
 
128
128
  export interface Sorter {
129
129
  field?: string;
130
- value?: (record: DataRecord) => any;
130
+ value?: Prop<any>;
131
131
  direction: SortDirection;
132
132
  }
133
133