cx 26.7.2 → 26.7.4

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
@@ -284,6 +284,12 @@ function batchUpdates(callback) {
284
284
  function isBatchingUpdates() {
285
285
  return isBatching > 0;
286
286
  }
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
+ }
287
293
  function notifyBatchedUpdateStarting() {
288
294
  promiseSubscribers.execute((x) => {
289
295
  x.pending++;
@@ -2094,6 +2100,31 @@ class LinkedListsNode {
2094
2100
  }
2095
2101
  }
2096
2102
 
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;
2115
+ function enableSyncUpdateCoalescing(limit) {
2116
+ coalesceSyncUpdates = true;
2117
+ if (limit != null) syncBurstLimit = limit;
2118
+ }
2119
+ function disableSyncUpdateCoalescing() {
2120
+ coalesceSyncUpdates = false;
2121
+ }
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
2097
2128
  class Cx extends VDOM.Component {
2098
2129
  widget;
2099
2130
  store;
@@ -2107,8 +2138,9 @@ class Cx extends VDOM.Component {
2107
2138
  deferCounter;
2108
2139
  pendingUpdateTimer;
2109
2140
  unsubscribeIdleRequest;
2110
- // 0 when no synchronous setState is in flight; >0 while one is pending (re-entrancy guard for update())
2111
- stateUpdateDepth = 0;
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;
2112
2144
  constructor(props) {
2113
2145
  super(props);
2114
2146
  if (props.instance) {
@@ -2129,10 +2161,10 @@ class Cx extends VDOM.Component {
2129
2161
  }
2130
2162
  this.state = {
2131
2163
  deferToken: 0,
2164
+ data: props.subscribe ? this.store.getData() : null,
2132
2165
  };
2133
2166
  if (props.subscribe) {
2134
2167
  this.unsubscribe = this.store.subscribe(this.update.bind(this));
2135
- this.state.data = this.store.getData();
2136
2168
  }
2137
2169
  this.onStateUpdateCompleted = this.onStateUpdateCompleted.bind(this);
2138
2170
  this.flags = {};
@@ -2195,59 +2227,96 @@ class Cx extends VDOM.Component {
2195
2227
  let data = this.store.getData();
2196
2228
  debug(appDataFlag, data);
2197
2229
  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.
2230
+ // Synchronous path: while batching (incl. batchUpdatesAndNotify, which page-breaking relies on) or for
2231
+ // `immediate` instances.
2204
2232
  else if (this.props.immediate || isBatchingUpdates()) {
2205
- if (this.stateUpdateDepth == 0) {
2206
- this.stateUpdateDepth = 1;
2233
+ if (!coalesceSyncUpdates) {
2234
+ // Opt-out path (disableSyncUpdateCoalescing()): the original behavior -- render synchronously
2235
+ // for every update, no coalescing.
2207
2236
  notifyBatchedUpdateStarting();
2208
2237
  this.setState(
2209
2238
  {
2210
2239
  data: data,
2211
2240
  },
2212
- this.onStateUpdateCompleted,
2241
+ notifyBatchedUpdateCompleted,
2213
2242
  );
2243
+ return;
2214
2244
  }
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();
2215
2255
  } else {
2216
2256
  // standard mode: coalesce sequential store commands into a single deferred update
2217
- if (!this.pendingUpdateTimer) {
2218
- notifyBatchedUpdateStarting();
2219
- this.pendingUpdateTimer = setTimeout(() => {
2220
- delete this.pendingUpdateTimer;
2221
- // read fresh data at fire time so the coalesced update renders the latest store state
2257
+ this.scheduleStateUpdate();
2258
+ }
2259
+ }
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)
2222
2280
  this.setState(
2223
2281
  {
2224
2282
  data: this.store.getData(),
2225
2283
  },
2226
- notifyBatchedUpdateCompleted,
2284
+ this.onStateUpdateCompleted,
2227
2285
  );
2228
- }, 0);
2229
- }
2286
+ });
2230
2287
  }
2231
2288
  }
2232
- // Completion callback for the synchronous setState above. If the store changed while that update was
2233
- // rendering -- i.e. the render itself wrote to the store, as happens throughout a large initial render --
2234
- // render once more with the latest data, keeping this callback armed; otherwise the burst has settled, so
2235
- // clear the in-flight flag and report completion. Re-arming coalesces until the store reaches a fixpoint;
2236
- // a genuinely non-converging update loop is still caught by React's own max-update-depth guard.
2289
+ scheduleStateUpdate() {
2290
+ if (!this.pendingUpdateTimer) {
2291
+ notifyBatchedUpdateStarting();
2292
+ this.pendingUpdateTimer = setTimeout(() => {
2293
+ delete this.pendingUpdateTimer;
2294
+ // read fresh data at fire time so the coalesced update renders the latest store state
2295
+ this.setState(
2296
+ {
2297
+ data: this.store.getData(),
2298
+ },
2299
+ notifyBatchedUpdateCompleted,
2300
+ );
2301
+ }, 0);
2302
+ }
2303
+ }
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.
2237
2309
  onStateUpdateCompleted() {
2238
- let latestData = this.store.getData();
2239
- if (this.state.data === latestData) {
2240
- this.stateUpdateDepth = 0;
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--;
2241
2314
  notifyBatchedUpdateCompleted();
2242
- } else {
2243
- this.stateUpdateDepth++;
2244
- this.setState(
2245
- {
2246
- data: latestData,
2247
- },
2248
- this.onStateUpdateCompleted,
2249
- );
2315
+ return;
2250
2316
  }
2317
+ notifyBatchedUpdateStarting(); // open the next round
2318
+ notifyBatchedUpdateCompleted(); // close this one
2319
+ this.issueSyncSetState();
2251
2320
  }
2252
2321
  waitForIdle() {
2253
2322
  if (!this.props.deferredUntilIdle) return;
@@ -2255,12 +2324,9 @@ class Cx extends VDOM.Component {
2255
2324
  let token = ++this.deferCounter;
2256
2325
  this.unsubscribeIdleRequest = onIdleCallback(
2257
2326
  () => {
2258
- this.setState(
2259
- {
2260
- deferToken: token,
2261
- },
2262
- this.onStateUpdateCompleted,
2263
- );
2327
+ this.setState({
2328
+ deferToken: token,
2329
+ });
2264
2330
  },
2265
2331
  {
2266
2332
  timeout: this.props.idleTimeout || 30000,
@@ -2268,6 +2334,13 @@ class Cx extends VDOM.Component {
2268
2334
  );
2269
2335
  }
2270
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
+ }
2271
2344
  if (this.pendingUpdateTimer) clearTimeout(this.pendingUpdateTimer);
2272
2345
  if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
2273
2346
  if (this.unsubscribe) this.unsubscribe();
@@ -4383,7 +4456,9 @@ export {
4383
4456
  contentAppend,
4384
4457
  createCulture,
4385
4458
  createFunctionalComponent,
4459
+ disableSyncUpdateCoalescing,
4386
4460
  enableCultureSensitiveFormatting,
4461
+ enableSyncUpdateCoalescing,
4387
4462
  equal,
4388
4463
  executeKeyboardShortcuts,
4389
4464
  exploreChildren,
@@ -4399,6 +4474,7 @@ export {
4399
4474
  getDefaultCulture,
4400
4475
  greaterThan,
4401
4476
  greaterThanOrEqual,
4477
+ hasBatchedUpdateSubscribers,
4402
4478
  hasValue,
4403
4479
  isBatchingUpdates,
4404
4480
  isEmpty,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cx",
3
- "version": "26.7.2",
3
+ "version": "26.7.4",
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,40 @@ 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("converges a deep render burst without tripping React's max-update-depth guard", async () => {
263
+ const TARGET = 200; // far beyond React's ~50 nested-update limit; only survives because of the yield
264
+ let store = new Store({ data: { n: 0 } });
265
+ const component = await createTestRenderer(store, convergingWidget(TARGET));
266
+ assert.equal(renderedValue(component), TARGET);
267
+ });
210
268
  });