cx 26.7.4 → 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,20 +285,21 @@ function batchUpdates(callback) {
284
285
  function isBatchingUpdates() {
285
286
  return isBatching > 0;
286
287
  }
287
- // True while a batchUpdatesAndNotify accumulator is subscribed, i.e. some caller is waiting for the
288
- // current batch's renders to finish. Cx uses this to stay fully synchronous during page-breaking so the
289
- // notify callback still fires right after the change commits.
290
- function hasBatchedUpdateSubscribers() {
291
- return !promiseSubscribers.isEmpty();
292
- }
288
+ // Returns a sequence number identifying the update; pass it to notifyBatchedUpdateCompleted once the
289
+ // update is rendered.
293
290
  function notifyBatchedUpdateStarting() {
291
+ let seq = ++updateSequence;
294
292
  promiseSubscribers.execute((x) => {
295
293
  x.pending++;
296
294
  });
295
+ return seq;
297
296
  }
298
- function notifyBatchedUpdateCompleted() {
297
+ function notifyBatchedUpdateCompleted(seq) {
299
298
  promiseSubscribers.execute((x) => {
300
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;
301
303
  cb.finished++;
302
304
  if (cb.finished >= cb.pending) cb.complete(true);
303
305
  });
@@ -309,6 +311,7 @@ function batchUpdatesAndNotify(callback, notifyCallback, timeout = 1000) {
309
311
  const update = {
310
312
  pending: 0,
311
313
  finished: 0,
314
+ watermark: updateSequence,
312
315
  complete: (success) => {
313
316
  if (!done) {
314
317
  done = true;
@@ -2100,31 +2103,36 @@ class LinkedListsNode {
2100
2103
  }
2101
2104
  }
2102
2105
 
2103
- // On by default. Cx coalesces re-entrant synchronous updates and, once a burst grows deep, yields to a
2104
- // microtask so React's global per-root nested-update counter resets before continuing -- preventing
2105
- // "Maximum update depth exceeded" on large renders that write to the store while they render (e.g. a
2106
- // several-hundred-page report). For updates that settle in a single render -- virtually all of them --
2107
- // this is equivalent to the previous behavior; it only diverges under a deep re-entrant render burst.
2108
- // If you suspect it causes trouble, opt out at app startup with disableSyncUpdateCoalescing() -- and
2109
- // please report the issue so it can be fixed.
2110
- let coalesceSyncUpdates = true;
2111
- // Consecutive commit-phase re-render rounds allowed before Cx yields to a microtask. Kept under React's
2112
- // ~50 nested-update limit (which is global to the root, not per component); the default trades a little
2113
- // initial-render time for a safety margin. Override via enableSyncUpdateCoalescing(limit) if needed.
2114
- let syncBurstLimit = 35;
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;
2115
2123
  function enableSyncUpdateCoalescing(limit) {
2116
- coalesceSyncUpdates = true;
2117
- if (limit != null) syncBurstLimit = limit;
2124
+ syncBurstLimit = limit ?? defaultSyncBurstLimit;
2118
2125
  }
2119
2126
  function disableSyncUpdateCoalescing() {
2120
- coalesceSyncUpdates = false;
2127
+ syncBurstLimit = Infinity;
2121
2128
  }
2122
- // Module-global because React's nested-update limit is global to the root, not per component. A large
2123
- // initial render can re-render the root Cx and every detached page Restate hundreds of times in one
2124
- // synchronous burst; once the burst grows past syncBurstLimit we yield so React's commit finishes
2125
- // without a synchronously-scheduled follow-up (which resets its counter) before we render again.
2126
- let activeSyncUpdates = 0; // Cx instances with a synchronous setState in flight
2127
- let syncBurstRounds = 0; // commit-phase re-render rounds issued since the last yield / burst start
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
2128
2136
  class Cx extends VDOM.Component {
2129
2137
  widget;
2130
2138
  store;
@@ -2133,14 +2141,18 @@ class Cx extends VDOM.Component {
2133
2141
  flags;
2134
2142
  renderCount;
2135
2143
  unsubscribe;
2136
- componentDidCatch;
2137
2144
  forceUpdateCallback;
2138
2145
  deferCounter;
2139
2146
  pendingUpdateTimer;
2140
2147
  unsubscribeIdleRequest;
2141
- // true while a coalesced synchronous setState is in flight for this Cx (re-entrancy guard for update());
2142
- // only used when coalesceSyncUpdates is enabled
2143
- stateUpdateInFlight = false;
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;
2144
2156
  constructor(props) {
2145
2157
  super(props);
2146
2158
  if (props.instance) {
@@ -2166,13 +2178,11 @@ class Cx extends VDOM.Component {
2166
2178
  if (props.subscribe) {
2167
2179
  this.unsubscribe = this.store.subscribe(this.update.bind(this));
2168
2180
  }
2169
- this.onStateUpdateCompleted = this.onStateUpdateCompleted.bind(this);
2170
2181
  this.flags = {};
2171
2182
  this.renderCount = 0;
2172
- if (props.onError) this.componentDidCatch = this.componentDidCatchHandler.bind(this);
2173
2183
  this.forceUpdateCallback = this.forceUpdate.bind(this);
2174
- this.deferCounter = 0;
2175
- this.waitForIdle();
2184
+ // deferredUntilIdle content stays hidden until the idle callback scheduled on mount bumps the token
2185
+ this.deferCounter = props.deferredUntilIdle ? 1 : 0;
2176
2186
  }
2177
2187
  UNSAFE_componentWillReceiveProps(props) {
2178
2188
  let newStore = props.instance ? props.instance.store : props.store ? props.store : props.parentInstance.store;
@@ -2202,6 +2212,10 @@ class Cx extends VDOM.Component {
2202
2212
  throw new Error("Could not resolve a widget instance in the Cx component.");
2203
2213
  }
2204
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;
2205
2219
  if (this.props.deferredUntilIdle && this.state.deferToken < this.deferCounter) return null;
2206
2220
  let cultureInfo = this.props.cultureInfo ?? getCurrentCulture();
2207
2221
  return jsx(CxContext, {
@@ -2215,6 +2229,10 @@ class Cx extends VDOM.Component {
2215
2229
  });
2216
2230
  }
2217
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();
2218
2236
  this.componentDidUpdate();
2219
2237
  if (this.props.options && this.props.options.onPipeUpdate) this.props.options.onPipeUpdate(this.update.bind(this));
2220
2238
  }
@@ -2226,69 +2244,68 @@ class Cx extends VDOM.Component {
2226
2244
  update() {
2227
2245
  let data = this.store.getData();
2228
2246
  debug(appDataFlag, data);
2229
- if (this.flags.preparing) this.flags.dirty = true;
2247
+ if (this.flags.preparing || !this.mounted) this.flags.dirty = true;
2230
2248
  // Synchronous path: while batching (incl. batchUpdatesAndNotify, which page-breaking relies on) or for
2231
2249
  // `immediate` instances.
2232
2250
  else if (this.props.immediate || isBatchingUpdates()) {
2233
- if (!coalesceSyncUpdates) {
2234
- // Opt-out path (disableSyncUpdateCoalescing()): the original behavior -- render synchronously
2235
- // for every update, no coalescing.
2236
- notifyBatchedUpdateStarting();
2237
- this.setState(
2238
- {
2239
- data: data,
2240
- },
2241
- notifyBatchedUpdateCompleted,
2242
- );
2243
- return;
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
+ );
2244
2274
  }
2245
- // Coalescing enabled: at most one setState may be in flight per Cx. Re-entrant update() calls (the
2246
- // render itself writing to the store, as happens throughout a large initial render) are skipped --
2247
- // the in-flight round re-reads the store on completion (see onStateUpdateCompleted), so nothing is
2248
- // dropped. Nesting these setStates deep is what trips React's "Maximum update depth exceeded".
2249
- if (this.stateUpdateInFlight) return;
2250
- if (activeSyncUpdates === 0) syncBurstRounds = 0; // fresh burst -> reset the shared round counter
2251
- activeSyncUpdates++;
2252
- this.stateUpdateInFlight = true;
2253
- notifyBatchedUpdateStarting();
2254
- this.issueSyncSetState();
2275
+ if (syncBurstRounds <= syncBurstLimit) this.issueStateUpdate(seq);
2276
+ else if (syncBurstRounds <= microtaskBurstLimit) queueMicrotask(() => this.issueStateUpdate(seq));
2277
+ else setTimeout(() => this.issueStateUpdate(seq), 0);
2255
2278
  } else {
2256
2279
  // standard mode: coalesce sequential store commands into a single deferred update
2257
2280
  this.scheduleStateUpdate();
2258
2281
  }
2259
2282
  }
2260
- // Issue the next synchronous render round (coalescing path). Once a burst grows past SYNC_BURST_LIMIT we
2261
- // render from a microtask instead, so React's commit finishes without a synchronously-scheduled follow-up
2262
- // and its global nested-update counter resets before we continue. The yield is suppressed while a
2263
- // batchUpdatesAndNotify is in flight: page-breaking convergence is shallow, so staying fully synchronous
2264
- // keeps its notify callback firing right after the change commits (and the counter never gets near 50).
2265
- issueSyncSetState() {
2266
- if (hasBatchedUpdateSubscribers() || ++syncBurstRounds <= syncBurstLimit) {
2267
- this.setState(
2268
- {
2269
- data: this.store.getData(),
2270
- },
2271
- this.onStateUpdateCompleted,
2272
- );
2273
- } else {
2274
- queueMicrotask(() => {
2275
- // The event loop has turned, so React's global nested-update counter has reset; realign ours.
2276
- // Resetting here (not before scheduling) keeps the counter high through the rest of the current
2277
- // commit, so any other Cx re-arming in the same commit also yields instead of extending the chain.
2278
- syncBurstRounds = 0;
2279
- if (this.stateUpdateInFlight)
2280
- this.setState(
2281
- {
2282
- data: this.store.getData(),
2283
- },
2284
- this.onStateUpdateCompleted,
2285
- );
2286
- });
2287
- }
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;
2288
2303
  }
2289
2304
  scheduleStateUpdate() {
2290
2305
  if (!this.pendingUpdateTimer) {
2291
- notifyBatchedUpdateStarting();
2306
+ let seq = notifyBatchedUpdateStarting();
2307
+ this.owedNotifications.add(seq);
2308
+ outstandingNotifications++;
2292
2309
  this.pendingUpdateTimer = setTimeout(() => {
2293
2310
  delete this.pendingUpdateTimer;
2294
2311
  // read fresh data at fire time so the coalesced update renders the latest store state
@@ -2296,28 +2313,11 @@ class Cx extends VDOM.Component {
2296
2313
  {
2297
2314
  data: this.store.getData(),
2298
2315
  },
2299
- notifyBatchedUpdateCompleted,
2316
+ () => this.completeNotification(seq),
2300
2317
  );
2301
2318
  }, 0);
2302
2319
  }
2303
2320
  }
2304
- // Completion callback for the coalescing path's setState. React runs it after the commit, so the DOM
2305
- // already reflects this render. If the render wrote to the store, run another round -- keeping the
2306
- // batched-update accounting balanced (open the next round before closing this one) so `finished` never
2307
- // catches `pending` mid-convergence and batchUpdatesAndNotify resolves only at the store fixpoint.
2308
- // Otherwise the burst has settled: clear the in-flight flag and report completion.
2309
- onStateUpdateCompleted() {
2310
- if (this.state.data === this.store.getData()) {
2311
- // Converged: the store didn't change while this round rendered, so the DOM is up to date.
2312
- this.stateUpdateInFlight = false;
2313
- activeSyncUpdates--;
2314
- notifyBatchedUpdateCompleted();
2315
- return;
2316
- }
2317
- notifyBatchedUpdateStarting(); // open the next round
2318
- notifyBatchedUpdateCompleted(); // close this one
2319
- this.issueSyncSetState();
2320
- }
2321
2321
  waitForIdle() {
2322
2322
  if (!this.props.deferredUntilIdle) return;
2323
2323
  if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
@@ -2334,13 +2334,9 @@ class Cx extends VDOM.Component {
2334
2334
  );
2335
2335
  }
2336
2336
  componentWillUnmount() {
2337
- if (this.stateUpdateInFlight) {
2338
- // Release the open pending round so a waiting batchUpdatesAndNotify can settle instead of waiting
2339
- // out its fallback timeout, and keep the shared in-flight refcount balanced.
2340
- this.stateUpdateInFlight = false;
2341
- activeSyncUpdates--;
2342
- notifyBatchedUpdateCompleted();
2343
- }
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);
2344
2340
  if (this.pendingUpdateTimer) clearTimeout(this.pendingUpdateTimer);
2345
2341
  if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
2346
2342
  if (this.unsubscribe) this.unsubscribe();
@@ -2359,9 +2355,25 @@ class Cx extends VDOM.Component {
2359
2355
  props.cultureInfo !== this.props.cultureInfo
2360
2356
  );
2361
2357
  }
2362
- 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) {
2363
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;
2364
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
+ });
2365
2377
  }
2366
2378
  }
2367
2379
  class CxContext extends VDOM.Component {
@@ -4474,7 +4486,6 @@ export {
4474
4486
  getDefaultCulture,
4475
4487
  greaterThan,
4476
4488
  greaterThanOrEqual,
4477
- hasBatchedUpdateSubscribers,
4478
4489
  hasValue,
4479
4490
  isBatchingUpdates,
4480
4491
  isEmpty,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cx",
3
- "version": "26.7.4",
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": {
@@ -259,6 +259,33 @@ describe("Cx", () => {
259
259
  assert.equal(notifiedRenderedValue, TARGET, "notify fired before the DOM reflected the final value");
260
260
  });
261
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
+
262
289
  it("converges a deep render burst without tripping React's max-update-depth guard", async () => {
263
290
  const TARGET = 200; // far beyond React's ~50 nested-update limit; only survives because of the yield
264
291
  let store = new Store({ data: { n: 0 } });