cx 26.7.3 → 26.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/dist/ui.js CHANGED
@@ -268,6 +268,7 @@ CSS.classPrefix = "cx";
268
268
  CSSHelper.alias("cx", CSS);
269
269
 
270
270
  let isBatching = 0;
271
+ let updateSequence = 0;
271
272
  let promiseSubscribers = new SubscriberList();
272
273
  function batchUpdates(callback) {
273
274
  if (VDOM$1.DOM.unstable_batchedUpdates)
@@ -284,14 +285,21 @@ function batchUpdates(callback) {
284
285
  function isBatchingUpdates() {
285
286
  return isBatching > 0;
286
287
  }
288
+ // Returns a sequence number identifying the update; pass it to notifyBatchedUpdateCompleted once the
289
+ // update is rendered.
287
290
  function notifyBatchedUpdateStarting() {
291
+ let seq = ++updateSequence;
288
292
  promiseSubscribers.execute((x) => {
289
293
  x.pending++;
290
294
  });
295
+ return seq;
291
296
  }
292
- function notifyBatchedUpdateCompleted() {
297
+ function notifyBatchedUpdateCompleted(seq) {
293
298
  promiseSubscribers.execute((x) => {
294
299
  let cb = x;
300
+ // ignore updates that started before this subscriber attached -- counting them would let the notify
301
+ // callback fire before the updates the subscriber is actually waiting on are rendered
302
+ if (seq <= cb.watermark) return;
295
303
  cb.finished++;
296
304
  if (cb.finished >= cb.pending) cb.complete(true);
297
305
  });
@@ -303,6 +311,7 @@ function batchUpdatesAndNotify(callback, notifyCallback, timeout = 1000) {
303
311
  const update = {
304
312
  pending: 0,
305
313
  finished: 0,
314
+ watermark: updateSequence,
306
315
  complete: (success) => {
307
316
  if (!done) {
308
317
  done = true;
@@ -2094,6 +2103,36 @@ class LinkedListsNode {
2094
2103
  }
2095
2104
  }
2096
2105
 
2106
+ // On by default. Once a synchronous update burst grows deep, Cx starts issuing updates from microtasks
2107
+ // so React's global nested-update counter resets before continuing -- preventing "Maximum update depth
2108
+ // exceeded" on large renders that write to the store while they render (e.g. a several-hundred-page
2109
+ // report). For updates that settle in a single render -- virtually all of them -- this is equivalent to
2110
+ // rendering synchronously; it only diverges under a deep re-entrant render burst. If you suspect it
2111
+ // causes trouble, opt out at app startup with disableSyncUpdateCoalescing() -- and please report the
2112
+ // issue so it can be fixed.
2113
+ // Synchronous updates allowed within one burst before Cx switches to issuing updates from a microtask.
2114
+ // Kept under React's ~50 nested-update limit (which is global to the root, not per component); the
2115
+ // default trades a little initial-render time for a safety margin. Override via
2116
+ // enableSyncUpdateCoalescing(limit) if needed.
2117
+ const defaultSyncBurstLimit = 35;
2118
+ let syncBurstLimit = defaultSyncBurstLimit;
2119
+ // Microtask-issued updates allowed within one burst before Cx escalates to setTimeout. Deep enough that
2120
+ // only a store that never converges reaches it; timeouts let the event loop turn, so the page stays
2121
+ // responsive and batchUpdatesAndNotify fallback timers can fire instead of the tab hanging.
2122
+ let microtaskBurstLimit = 1000;
2123
+ function enableSyncUpdateCoalescing(limit) {
2124
+ syncBurstLimit = limit ?? defaultSyncBurstLimit;
2125
+ }
2126
+ function disableSyncUpdateCoalescing() {
2127
+ syncBurstLimit = Infinity;
2128
+ }
2129
+ // Module-global because React's nested-update limit is global to the root, not per component. The burst
2130
+ // counter resets only when all notifications settle (see completeNotification) -- tying the burst
2131
+ // window to unsettled work makes it immune to how React schedules the flushes. Legacy React chains
2132
+ // re-entrant updates synchronously within one task, but React 19 may run each round from its own
2133
+ // microtask, so no task/microtask boundary is a reliable reset point.
2134
+ let syncBurstRounds = 0; // updates issued in the current burst
2135
+ let outstandingNotifications = 0; // reported notifications not yet rendered, across all Cx instances
2097
2136
  class Cx extends VDOM.Component {
2098
2137
  widget;
2099
2138
  store;
@@ -2102,13 +2141,18 @@ class Cx extends VDOM.Component {
2102
2141
  flags;
2103
2142
  renderCount;
2104
2143
  unsubscribe;
2105
- componentDidCatch;
2106
2144
  forceUpdateCallback;
2107
2145
  deferCounter;
2108
2146
  pendingUpdateTimer;
2109
2147
  unsubscribeIdleRequest;
2110
- // 0 when no synchronous setState is in flight; >0 while one is pending (re-entrancy guard for update())
2111
- stateUpdateDepth = 0;
2148
+ // store notifications reported to batchUpdatesAndNotify subscribers but not yet rendered and completed
2149
+ owedNotifications = new Set();
2150
+ // setState is not allowed before the component mounts; pre-mount notifications set flags.dirty instead
2151
+ // and componentDidMount picks them up (see update())
2152
+ mounted = false;
2153
+ // true once this instance has contributed to syncBurstRounds for the current render round; cleared on
2154
+ // render, so bursts are counted per round rather than per notification (see update())
2155
+ burstRoundCounted = false;
2112
2156
  constructor(props) {
2113
2157
  super(props);
2114
2158
  if (props.instance) {
@@ -2134,13 +2178,11 @@ class Cx extends VDOM.Component {
2134
2178
  if (props.subscribe) {
2135
2179
  this.unsubscribe = this.store.subscribe(this.update.bind(this));
2136
2180
  }
2137
- this.onStateUpdateCompleted = this.onStateUpdateCompleted.bind(this);
2138
2181
  this.flags = {};
2139
2182
  this.renderCount = 0;
2140
- if (props.onError) this.componentDidCatch = this.componentDidCatchHandler.bind(this);
2141
2183
  this.forceUpdateCallback = this.forceUpdate.bind(this);
2142
- this.deferCounter = 0;
2143
- this.waitForIdle();
2184
+ // deferredUntilIdle content stays hidden until the idle callback scheduled on mount bumps the token
2185
+ this.deferCounter = props.deferredUntilIdle ? 1 : 0;
2144
2186
  }
2145
2187
  UNSAFE_componentWillReceiveProps(props) {
2146
2188
  let newStore = props.instance ? props.instance.store : props.store ? props.store : props.parentInstance.store;
@@ -2170,6 +2212,10 @@ class Cx extends VDOM.Component {
2170
2212
  throw new Error("Could not resolve a widget instance in the Cx component.");
2171
2213
  }
2172
2214
  render() {
2215
+ this.burstRoundCounted = false;
2216
+ // an error was captured and is being dispatched to the onError callback (see componentDidCatch);
2217
+ // render nothing until the callback repairs the state
2218
+ if (this.state.error) return null;
2173
2219
  if (this.props.deferredUntilIdle && this.state.deferToken < this.deferCounter) return null;
2174
2220
  let cultureInfo = this.props.cultureInfo ?? getCurrentCulture();
2175
2221
  return jsx(CxContext, {
@@ -2183,6 +2229,10 @@ class Cx extends VDOM.Component {
2183
2229
  });
2184
2230
  }
2185
2231
  componentDidMount() {
2232
+ this.mounted = true;
2233
+ // schedule the deferredUntilIdle reveal here rather than in the constructor -- the idle callback
2234
+ // could otherwise fire before the component mounts and issue a setState React does not allow yet
2235
+ this.waitForIdle();
2186
2236
  this.componentDidUpdate();
2187
2237
  if (this.props.options && this.props.options.onPipeUpdate) this.props.options.onPipeUpdate(this.update.bind(this));
2188
2238
  }
@@ -2194,32 +2244,68 @@ class Cx extends VDOM.Component {
2194
2244
  update() {
2195
2245
  let data = this.store.getData();
2196
2246
  debug(appDataFlag, data);
2197
- if (this.flags.preparing) this.flags.dirty = true;
2198
- // Synchronous path (while batching, or for `immediate` instances). At most one setState may be in flight
2199
- // per Cx: if one is already pending (stateUpdateDepth != 0) skip issuing another. Re-entrant setStates
2200
- // nested deep -- e.g. a large initial render that writes to the store while it renders -- are what trip
2201
- // React's "Maximum update depth exceeded"; coalescing them into a single in-flight update avoids that.
2202
- // The pending update's completion callback re-reads the store and renders again if it changed
2203
- // (see onStateUpdateCompleted), so no change is dropped.
2247
+ if (this.flags.preparing || !this.mounted) this.flags.dirty = true;
2248
+ // Synchronous path: while batching (incl. batchUpdatesAndNotify, which page-breaking relies on) or for
2249
+ // `immediate` instances.
2204
2250
  else if (this.props.immediate || isBatchingUpdates()) {
2205
- if (this.stateUpdateDepth == 0) {
2206
- this.stateUpdateDepth = 1;
2207
- notifyBatchedUpdateStarting();
2208
- this.setState(
2209
- {
2210
- data: data,
2211
- },
2212
- this.onStateUpdateCompleted,
2213
- );
2251
+ // Every notification is reported to batchUpdatesAndNotify subscribers up front and completed only
2252
+ // once the setState that renders it commits, so notify callbacks never fire before the DOM reflects
2253
+ // the change. Each notification always gets its own setState; only its timing escalates as the
2254
+ // burst grows: synchronous at first, then from a microtask (lets React's commit finish so its
2255
+ // global nested-update counter resets instead of tripping "Maximum update depth exceeded"), and
2256
+ // finally from a timeout (lets the event loop turn, so a store that never converges degrades to a
2257
+ // responsive page instead of a frozen tab).
2258
+ let seq = notifyBatchedUpdateStarting();
2259
+ this.owedNotifications.add(seq);
2260
+ outstandingNotifications++;
2261
+ // Count render rounds rather than notifications: only the first notification an instance
2262
+ // receives per render round bumps the shared depth (the flag clears on render). This tracks
2263
+ // React's nested-commit count -- the thing the escalation must stay under -- so a round that
2264
+ // writes many values doesn't burn through the budget in one go.
2265
+ if (!this.burstRoundCounted) {
2266
+ this.burstRoundCounted = true;
2267
+ ++syncBurstRounds;
2268
+ if (process.env.NODE_ENV !== "production" && syncBurstRounds === microtaskBurstLimit + 1)
2269
+ console.error(
2270
+ "Cx: store updates are not converging after " +
2271
+ microtaskBurstLimit +
2272
+ " render rounds -- possible update loop. Updates are now issued from timeouts to keep the page responsive. Look for code that writes a new value to the store on every render.",
2273
+ );
2214
2274
  }
2275
+ if (syncBurstRounds <= syncBurstLimit) this.issueStateUpdate(seq);
2276
+ else if (syncBurstRounds <= microtaskBurstLimit) queueMicrotask(() => this.issueStateUpdate(seq));
2277
+ else setTimeout(() => this.issueStateUpdate(seq), 0);
2215
2278
  } else {
2216
2279
  // standard mode: coalesce sequential store commands into a single deferred update
2217
2280
  this.scheduleStateUpdate();
2218
2281
  }
2219
2282
  }
2283
+ // Render the latest store data and report the notification completed once the commit is done, so
2284
+ // batchUpdatesAndNotify resolves only when the DOM reflects the store.
2285
+ issueStateUpdate(seq) {
2286
+ // skip notifications no longer owed -- unmount may have already released them
2287
+ if (!this.owedNotifications.has(seq)) return;
2288
+ this.setState(
2289
+ {
2290
+ data: this.store.getData(),
2291
+ },
2292
+ () => this.completeNotification(seq),
2293
+ );
2294
+ }
2295
+ completeNotification(seq) {
2296
+ // skip notifications no longer owed -- unmount may have already released them
2297
+ if (!this.owedNotifications.delete(seq)) return;
2298
+ outstandingNotifications--;
2299
+ notifyBatchedUpdateCompleted(seq);
2300
+ // everything settled -- the next burst starts fresh, and so does React's nested-update counter
2301
+ // (the commit that settled the last notification ends without scheduling further synchronous work)
2302
+ if (outstandingNotifications === 0) syncBurstRounds = 0;
2303
+ }
2220
2304
  scheduleStateUpdate() {
2221
2305
  if (!this.pendingUpdateTimer) {
2222
- notifyBatchedUpdateStarting();
2306
+ let seq = notifyBatchedUpdateStarting();
2307
+ this.owedNotifications.add(seq);
2308
+ outstandingNotifications++;
2223
2309
  this.pendingUpdateTimer = setTimeout(() => {
2224
2310
  delete this.pendingUpdateTimer;
2225
2311
  // read fresh data at fire time so the coalesced update renders the latest store state
@@ -2227,45 +2313,20 @@ class Cx extends VDOM.Component {
2227
2313
  {
2228
2314
  data: this.store.getData(),
2229
2315
  },
2230
- notifyBatchedUpdateCompleted,
2316
+ () => this.completeNotification(seq),
2231
2317
  );
2232
2318
  }, 0);
2233
2319
  }
2234
2320
  }
2235
- // Completion callback for the synchronous setState above. If the store changed while that update was
2236
- // rendering -- i.e. the render itself wrote to the store, as happens throughout a large initial render --
2237
- // render once more with the latest data, keeping this callback armed; otherwise the burst has settled, so
2238
- // clear the in-flight flag and report completion. Re-arming coalesces until the store reaches a fixpoint;
2239
- // a genuinely non-converging update loop is still caught by React's own max-update-depth guard.
2240
- onStateUpdateCompleted() {
2241
- let latestData = this.store.getData();
2242
- notifyBatchedUpdateCompleted();
2243
- if (this.state.data === latestData) {
2244
- this.stateUpdateDepth = 0;
2245
- } else {
2246
- this.stateUpdateDepth++;
2247
- if (this.stateUpdateDepth < 20 && isBatchingUpdates())
2248
- this.setState(
2249
- {
2250
- data: latestData,
2251
- },
2252
- this.onStateUpdateCompleted,
2253
- );
2254
- else this.scheduleStateUpdate();
2255
- }
2256
- }
2257
2321
  waitForIdle() {
2258
2322
  if (!this.props.deferredUntilIdle) return;
2259
2323
  if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
2260
2324
  let token = ++this.deferCounter;
2261
2325
  this.unsubscribeIdleRequest = onIdleCallback(
2262
2326
  () => {
2263
- this.setState(
2264
- {
2265
- deferToken: token,
2266
- },
2267
- this.onStateUpdateCompleted,
2268
- );
2327
+ this.setState({
2328
+ deferToken: token,
2329
+ });
2269
2330
  },
2270
2331
  {
2271
2332
  timeout: this.props.idleTimeout || 30000,
@@ -2273,6 +2334,9 @@ class Cx extends VDOM.Component {
2273
2334
  );
2274
2335
  }
2275
2336
  componentWillUnmount() {
2337
+ // Release notifications that will never render so a waiting batchUpdatesAndNotify can settle instead
2338
+ // of waiting out its fallback timeout.
2339
+ for (let seq of this.owedNotifications) this.completeNotification(seq);
2276
2340
  if (this.pendingUpdateTimer) clearTimeout(this.pendingUpdateTimer);
2277
2341
  if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
2278
2342
  if (this.unsubscribe) this.unsubscribe();
@@ -2291,9 +2355,25 @@ class Cx extends VDOM.Component {
2291
2355
  props.cultureInfo !== this.props.cultureInfo
2292
2356
  );
2293
2357
  }
2294
- componentDidCatchHandler(error, info) {
2358
+ // Render null for the failed subtree while the error is dispatched to componentDidCatch below --
2359
+ // returning a state update here is what React expects of an error boundary (rendering the broken
2360
+ // children again would just rethrow).
2361
+ static getDerivedStateFromError() {
2362
+ return {
2363
+ error: true,
2364
+ };
2365
+ }
2366
+ componentDidCatch(error, info) {
2295
2367
  this.flags.preparing = false;
2368
+ // without an onError callback this instance is not an error boundary -- rethrow so the error
2369
+ // reaches the nearest ancestor boundary, matching the behavior before getDerivedStateFromError
2370
+ // was introduced
2371
+ if (!this.props.onError) throw error;
2296
2372
  this.props.onError(error, this.getInstance(), info);
2373
+ // the callback had a chance to repair the state (e.g. replace the failing content) -- resume rendering
2374
+ this.setState({
2375
+ error: false,
2376
+ });
2297
2377
  }
2298
2378
  }
2299
2379
  class CxContext extends VDOM.Component {
@@ -4388,7 +4468,9 @@ export {
4388
4468
  contentAppend,
4389
4469
  createCulture,
4390
4470
  createFunctionalComponent,
4471
+ disableSyncUpdateCoalescing,
4391
4472
  enableCultureSensitiveFormatting,
4473
+ enableSyncUpdateCoalescing,
4392
4474
  equal,
4393
4475
  executeKeyboardShortcuts,
4394
4476
  exploreChildren,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cx",
3
- "version": "26.7.3",
3
+ "version": "26.7.5",
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": {
@@ -6,6 +6,28 @@ import { bind } from "./bind";
6
6
  import { createTestRenderer, createTestWidget, act } from "../util/test/createTestRenderer";
7
7
  import assert from "assert";
8
8
  import { HtmlElement } from "../widgets/HtmlElement";
9
+ import { batchUpdatesAndNotify } from "./batchUpdates";
10
+
11
+ // A widget that, each time it explores, bumps `n` by one until it reaches `target`. This reproduces the
12
+ // shape of a large report's initial render: the render writes to the store, which schedules another
13
+ // render, until the store reaches a fixpoint. It drives Cx's coalescing update path across many rounds.
14
+ function convergingWidget(target: number) {
15
+ return (
16
+ <cx>
17
+ <div
18
+ text={bind("n")}
19
+ onExplore={(context: any, instance: any) => {
20
+ let n = instance.store.get("n");
21
+ if (n < target) instance.store.set("n", n + 1);
22
+ }}
23
+ />
24
+ </cx>
25
+ );
26
+ }
27
+
28
+ function renderedValue(component: any): number {
29
+ return Number(component.toJSON().children[0]);
30
+ }
9
31
 
10
32
  describe("Cx", () => {
11
33
  it("can render cx content", async () => {
@@ -207,4 +229,67 @@ describe("Cx", () => {
207
229
  ["render", "0"],
208
230
  ]);
209
231
  });
232
+
233
+ // Coalescing of re-entrant synchronous updates is the default. These cover the two guarantees that
234
+ // matter for large, store-mutating-during-render trees (e.g. report page-breaking).
235
+
236
+ it("resolves batchUpdatesAndNotify only after the store reaches its fixpoint", async () => {
237
+ const TARGET = 6; // shallow, like page-breaking convergence -> stays fully synchronous under a notifier
238
+ let store = new Store({ data: { n: 0 } });
239
+ const component = await createTestRenderer(store, convergingWidget(TARGET));
240
+ assert.equal(renderedValue(component), TARGET);
241
+
242
+ let notifiedStoreValue = -1;
243
+ let notifiedRenderedValue = -1;
244
+ await act(async () => {
245
+ batchUpdatesAndNotify(
246
+ () => {
247
+ store.set("n", 0); // reset -> the render cascade re-converges back up to TARGET
248
+ },
249
+ () => {
250
+ notifiedStoreValue = store.get("n");
251
+ notifiedRenderedValue = renderedValue(component);
252
+ },
253
+ );
254
+ });
255
+
256
+ // The notify callback is what page-breaking relies on: it must run only once the change is fully
257
+ // implemented -- both the store and the rendered DOM are at the final value, never an intermediate.
258
+ assert.equal(notifiedStoreValue, TARGET, "notify saw a pre-fixpoint store value");
259
+ assert.equal(notifiedRenderedValue, TARGET, "notify fired before the DOM reflected the final value");
260
+ });
261
+
262
+ it("does not fire the notify callback early when an update is already in flight", async () => {
263
+ const TARGET = 50;
264
+ let store = new Store({ data: { n: 0 } });
265
+ const component = await createTestRenderer(store, convergingWidget(TARGET));
266
+ assert.equal(renderedValue(component), TARGET);
267
+
268
+ let notifiedStoreValue = -1;
269
+ let notifiedRenderedValue = -1;
270
+ await act(async () => {
271
+ store.set("n", 0); // puts an update in flight before the notifier below subscribes
272
+ batchUpdatesAndNotify(
273
+ () => {
274
+ store.set("n", 1);
275
+ },
276
+ () => {
277
+ notifiedStoreValue = store.get("n");
278
+ notifiedRenderedValue = renderedValue(component);
279
+ },
280
+ );
281
+ });
282
+
283
+ // The write issued inside batchUpdatesAndNotify must be rendered before the notify callback runs,
284
+ // even though it landed while a previous update was still converging.
285
+ assert.equal(notifiedStoreValue, TARGET, "notify saw a pre-fixpoint store value");
286
+ assert.equal(notifiedRenderedValue, TARGET, "notify fired before the DOM reflected the final value");
287
+ });
288
+
289
+ it("converges a deep render burst without tripping React's max-update-depth guard", async () => {
290
+ const TARGET = 200; // far beyond React's ~50 nested-update limit; only survives because of the yield
291
+ let store = new Store({ data: { n: 0 } });
292
+ const component = await createTestRenderer(store, convergingWidget(TARGET));
293
+ assert.equal(renderedValue(component), TARGET);
294
+ });
210
295
  });