From 15996b403ca59b5f3359f18b9eefc707ac2a7d6d Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Tue, 30 Jun 2026 15:12:36 +0200 Subject: [PATCH 1/2] Cap re-entrant synchronous Cx updates to avoid React max-update-depth --- packages/cx/src/ui/Cx.tsx | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/cx/src/ui/Cx.tsx b/packages/cx/src/ui/Cx.tsx index f58477932..c7bdab676 100644 --- a/packages/cx/src/ui/Cx.tsx +++ b/packages/cx/src/ui/Cx.tsx @@ -35,6 +35,32 @@ export interface CxState { data?: any; } +// Guard against React's "Maximum update depth exceeded" during very large synchronous render bursts. +// On the initial render of a large document the store can be mutated thousands of times within a single +// React commit: every Instance.setState wraps store.notify() in batchUpdates(), so isBatchingUpdates() stays +// true and every subscribed Cx takes its *synchronous* setState branch on each notification. React counts +// those as nested render-phase updates and aborts past ~50 (surfacing as the recoverable "error during +// concurrent rendering"). Once back-to-back updates exceed the limit we fall back to the coalesced setTimeout +// branch, which renders the latest store data on the next tick. The counter is global (it mirrors React's +// global nested-update counter) and resets after any idle gap, so the guard is inert outside of a pathological +// synchronous storm. trackSyncUpdateBurst() must be called on EVERY update() -- counting only the synchronous +// branch would let the window reset whenever a deferred update lands between two synchronous ones, so the +// counter would never reach the limit. performance.now() gives sub-ms resolution (Date.now() is the fallback; +// Timing.now() can't be used here because it returns 0 in production). +const SYNC_UPDATE_BURST_LIMIT = 30; +const SYNC_UPDATE_BURST_RESET_MS = 10; +const syncUpdateBurstNow: () => number = + typeof performance !== "undefined" && performance.now ? () => performance.now() : () => Date.now(); +let syncUpdateBurstCount = 0; +let syncUpdateBurstLastAt = 0; + +function trackSyncUpdateBurst(): boolean { + let t = syncUpdateBurstNow(); + if (t - syncUpdateBurstLastAt > SYNC_UPDATE_BURST_RESET_MS) syncUpdateBurstCount = 0; + syncUpdateBurstLastAt = t; + return ++syncUpdateBurstCount > SYNC_UPDATE_BURST_LIMIT; +} + export class Cx extends VDOM.Component { widget: Widget; store: View; @@ -159,17 +185,23 @@ export class Cx extends VDOM.Component { update(): void { let data = this.store.getData(); debug(appDataFlag, data); + let overBurstLimit = trackSyncUpdateBurst(); if (this.flags.preparing) this.flags.dirty = true; - else if (isBatchingUpdates() || this.props.immediate) { + // `immediate` is the explicit opt-in to synchronous updates, so it always renders synchronously and is + // never deferred by the burst guard; the guard only throttles batching-driven updates (which is what + // produces the render storm). + else if (this.props.immediate || (isBatchingUpdates() && !overBurstLimit)) { notifyBatchedUpdateStarting(); this.setState({ data: data }, notifyBatchedUpdateCompleted); } else { - //in standard mode sequential store commands are batched + //in standard mode sequential store commands are batched -- as is any update that exceeds the + //synchronous burst limit above, which falls through here to avoid React's max-update-depth abort if (!this.pendingUpdateTimer) { notifyBatchedUpdateStarting(); this.pendingUpdateTimer = setTimeout(() => { delete this.pendingUpdateTimer; - this.setState({ data: data }, notifyBatchedUpdateCompleted); + //read fresh data at fire time so a deferred (coalesced) update always renders the latest state + this.setState({ data: this.store.getData() }, notifyBatchedUpdateCompleted); }, 0); } } From 376c418a190321143ddea822e04614370d280cf7 Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Wed, 1 Jul 2026 15:33:49 +0200 Subject: [PATCH 2/2] fix(Cx): coalesce re-entrant synchronous updates to avoid React max-update-depth Large renders that write to the store while rendering (e.g. a several-hundred-page report) could re-render the root Cx and detached Restates hundreds of times in one synchronous burst, exceeding React's global nested-update limit and crashing or hanging the render. Cx now coalesces re-entrant synchronous updates and yields to a microtask once a burst grows deep, so React's counter resets before continuing. Updates that settle in a single render -- virtually all of them -- behave as before; it only diverges under a deep re-entrant burst. batchUpdatesAndNotify still resolves only once the store reaches a fixpoint, so measure-after-render flows such as page-breaking are unaffected. On by default. If you suspect it causes trouble, opt out at startup with disableSyncUpdateCoalescing() and please report the issue so it can be fixed. The burst threshold can be tuned with enableSyncUpdateCoalescing(limit). --- packages/cx/package.json | 2 +- packages/cx/src/ui/Cx.spec.tsx | 58 ++++++++++ packages/cx/src/ui/Cx.tsx | 168 +++++++++++++++++++++-------- packages/cx/src/ui/batchUpdates.ts | 7 ++ 4 files changed, 187 insertions(+), 48 deletions(-) diff --git a/packages/cx/package.json b/packages/cx/package.json index fe84d1197..afeffaafa 100644 --- a/packages/cx/package.json +++ b/packages/cx/package.json @@ -1,6 +1,6 @@ { "name": "cx", - "version": "26.7.0", + "version": "26.7.3", "description": "Advanced JavaScript UI framework for admin and dashboard applications with ready to use grid, form and chart components.", "exports": { "./data": { diff --git a/packages/cx/src/ui/Cx.spec.tsx b/packages/cx/src/ui/Cx.spec.tsx index 36c199e0e..8aeca323f 100644 --- a/packages/cx/src/ui/Cx.spec.tsx +++ b/packages/cx/src/ui/Cx.spec.tsx @@ -6,6 +6,28 @@ import { bind } from "./bind"; import { createTestRenderer, createTestWidget, act } from "../util/test/createTestRenderer"; import assert from "assert"; import { HtmlElement } from "../widgets/HtmlElement"; +import { batchUpdatesAndNotify } from "./batchUpdates"; + +// A widget that, each time it explores, bumps `n` by one until it reaches `target`. This reproduces the +// shape of a large report's initial render: the render writes to the store, which schedules another +// render, until the store reaches a fixpoint. It drives Cx's coalescing update path across many rounds. +function convergingWidget(target: number) { + return ( + +
{ + let n = instance.store.get("n"); + if (n < target) instance.store.set("n", n + 1); + }} + /> + + ); +} + +function renderedValue(component: any): number { + return Number(component.toJSON().children[0]); +} describe("Cx", () => { it("can render cx content", async () => { @@ -207,4 +229,40 @@ describe("Cx", () => { ["render", "0"], ]); }); + + // Coalescing of re-entrant synchronous updates is the default. These cover the two guarantees that + // matter for large, store-mutating-during-render trees (e.g. report page-breaking). + + it("resolves batchUpdatesAndNotify only after the store reaches its fixpoint", async () => { + const TARGET = 6; // shallow, like page-breaking convergence -> stays fully synchronous under a notifier + let store = new Store({ data: { n: 0 } }); + const component = await createTestRenderer(store, convergingWidget(TARGET)); + assert.equal(renderedValue(component), TARGET); + + let notifiedStoreValue = -1; + let notifiedRenderedValue = -1; + await act(async () => { + batchUpdatesAndNotify( + () => { + store.set("n", 0); // reset -> the render cascade re-converges back up to TARGET + }, + () => { + notifiedStoreValue = store.get("n"); + notifiedRenderedValue = renderedValue(component); + }, + ); + }); + + // The notify callback is what page-breaking relies on: it must run only once the change is fully + // implemented -- both the store and the rendered DOM are at the final value, never an intermediate. + assert.equal(notifiedStoreValue, TARGET, "notify saw a pre-fixpoint store value"); + assert.equal(notifiedRenderedValue, TARGET, "notify fired before the DOM reflected the final value"); + }); + + it("converges a deep render burst without tripping React's max-update-depth guard", async () => { + const TARGET = 200; // far beyond React's ~50 nested-update limit; only survives because of the yield + let store = new Store({ data: { n: 0 } }); + const component = await createTestRenderer(store, convergingWidget(TARGET)); + assert.equal(renderedValue(component), TARGET); + }); }); diff --git a/packages/cx/src/ui/Cx.tsx b/packages/cx/src/ui/Cx.tsx index c7bdab676..69f5bf41f 100644 --- a/packages/cx/src/ui/Cx.tsx +++ b/packages/cx/src/ui/Cx.tsx @@ -5,7 +5,12 @@ import { Instance } from "./Instance"; import { RenderingContext } from "./RenderingContext"; import { debug, appDataFlag } from "../util/Debug"; import { Timing, now, appLoopFlag, vdomRenderFlag } from "../util/Timing"; -import { isBatchingUpdates, notifyBatchedUpdateStarting, notifyBatchedUpdateCompleted } from "./batchUpdates"; +import { + isBatchingUpdates, + notifyBatchedUpdateStarting, + notifyBatchedUpdateCompleted, + hasBatchedUpdateSubscribers, +} from "./batchUpdates"; import { shallowEquals } from "../util/shallowEquals"; import { PureContainer } from "./PureContainer"; import { onIdleCallback } from "../util/onIdleCallback"; @@ -13,6 +18,35 @@ import { getCurrentCulture, pushCulture, popCulture, CultureInfo, ResolvedCultur import { View } from "../data/View"; import { Config } from "./Prop"; +// On by default. Cx coalesces re-entrant synchronous updates and, once a burst grows deep, yields to a +// microtask so React's global per-root nested-update counter resets before continuing -- preventing +// "Maximum update depth exceeded" on large renders that write to the store while they render (e.g. a +// several-hundred-page report). For updates that settle in a single render -- virtually all of them -- +// this is equivalent to the previous behavior; it only diverges under a deep re-entrant render burst. +// If you suspect it causes trouble, opt out at app startup with disableSyncUpdateCoalescing() -- and +// please report the issue so it can be fixed. +let coalesceSyncUpdates = true; + +// Consecutive commit-phase re-render rounds allowed before Cx yields to a microtask. Kept under React's +// ~50 nested-update limit (which is global to the root, not per component); the default trades a little +// initial-render time for a safety margin. Override via enableSyncUpdateCoalescing(limit) if needed. +let syncBurstLimit = 35; + +export function enableSyncUpdateCoalescing(limit?: number): void { + coalesceSyncUpdates = true; + if (limit != null) syncBurstLimit = limit; +} +export function disableSyncUpdateCoalescing(): void { + coalesceSyncUpdates = false; +} + +// Module-global because React's nested-update limit is global to the root, not per component. A large +// initial render can re-render the root Cx and every detached page Restate hundreds of times in one +// synchronous burst; once the burst grows past syncBurstLimit we yield so React's commit finishes +// without a synchronously-scheduled follow-up (which resets its counter) before we render again. +let activeSyncUpdates = 0; // Cx instances with a synchronous setState in flight +let syncBurstRounds = 0; // commit-phase re-render rounds issued since the last yield / burst start + export interface CxProps { widget?: Config; items?: Config; @@ -35,32 +69,6 @@ export interface CxState { data?: any; } -// Guard against React's "Maximum update depth exceeded" during very large synchronous render bursts. -// On the initial render of a large document the store can be mutated thousands of times within a single -// React commit: every Instance.setState wraps store.notify() in batchUpdates(), so isBatchingUpdates() stays -// true and every subscribed Cx takes its *synchronous* setState branch on each notification. React counts -// those as nested render-phase updates and aborts past ~50 (surfacing as the recoverable "error during -// concurrent rendering"). Once back-to-back updates exceed the limit we fall back to the coalesced setTimeout -// branch, which renders the latest store data on the next tick. The counter is global (it mirrors React's -// global nested-update counter) and resets after any idle gap, so the guard is inert outside of a pathological -// synchronous storm. trackSyncUpdateBurst() must be called on EVERY update() -- counting only the synchronous -// branch would let the window reset whenever a deferred update lands between two synchronous ones, so the -// counter would never reach the limit. performance.now() gives sub-ms resolution (Date.now() is the fallback; -// Timing.now() can't be used here because it returns 0 in production). -const SYNC_UPDATE_BURST_LIMIT = 30; -const SYNC_UPDATE_BURST_RESET_MS = 10; -const syncUpdateBurstNow: () => number = - typeof performance !== "undefined" && performance.now ? () => performance.now() : () => Date.now(); -let syncUpdateBurstCount = 0; -let syncUpdateBurstLastAt = 0; - -function trackSyncUpdateBurst(): boolean { - let t = syncUpdateBurstNow(); - if (t - syncUpdateBurstLastAt > SYNC_UPDATE_BURST_RESET_MS) syncUpdateBurstCount = 0; - syncUpdateBurstLastAt = t; - return ++syncUpdateBurstCount > SYNC_UPDATE_BURST_LIMIT; -} - export class Cx extends VDOM.Component { widget: Widget; store: View; @@ -74,6 +82,9 @@ export class Cx extends VDOM.Component { deferCounter: number; pendingUpdateTimer?: NodeJS.Timeout; unsubscribeIdleRequest?: () => void; + // true while a coalesced synchronous setState is in flight for this Cx (re-entrancy guard for update()); + // only used when coalesceSyncUpdates is enabled + stateUpdateInFlight: boolean = false; constructor(props: CxProps) { super(props); @@ -97,13 +108,15 @@ export class Cx extends VDOM.Component { this.state = { deferToken: 0, + data: props.subscribe ? this.store.getData() : null, }; if (props.subscribe) { this.unsubscribe = this.store.subscribe(this.update.bind(this)); - (this.state as any).data = this.store.getData(); } + this.onStateUpdateCompleted = this.onStateUpdateCompleted.bind(this); + this.flags = {}; this.renderCount = 0; @@ -140,13 +153,13 @@ export class Cx extends VDOM.Component { getInstance(): Instance { if (this.props.instance) return this.props.instance; - if (this.instance && (this.instance as any).widget === this.widget) { - if ((this.instance as any).parentStore != this.store) (this.instance as any).setParentStore(this.store); + if (this.instance && this.instance.widget === this.widget) { + if (this.instance.parentStore != this.store) this.instance.setParentStore(this.store); return this.instance; } if (this.widget && this.parentInstance) - return (this.instance = (this.parentInstance as any).getDetachedChild(this.widget, 0, this.store)); + return (this.instance = this.parentInstance.getDetachedChild(this.widget, "0", this.store)); throw new Error("Could not resolve a widget instance in the Cx component."); } @@ -185,28 +198,82 @@ export class Cx extends VDOM.Component { update(): void { let data = this.store.getData(); debug(appDataFlag, data); - let overBurstLimit = trackSyncUpdateBurst(); if (this.flags.preparing) this.flags.dirty = true; - // `immediate` is the explicit opt-in to synchronous updates, so it always renders synchronously and is - // never deferred by the burst guard; the guard only throttles batching-driven updates (which is what - // produces the render storm). - else if (this.props.immediate || (isBatchingUpdates() && !overBurstLimit)) { - notifyBatchedUpdateStarting(); - this.setState({ data: data }, notifyBatchedUpdateCompleted); - } else { - //in standard mode sequential store commands are batched -- as is any update that exceeds the - //synchronous burst limit above, which falls through here to avoid React's max-update-depth abort - if (!this.pendingUpdateTimer) { + // Synchronous path: while batching (incl. batchUpdatesAndNotify, which page-breaking relies on) or for + // `immediate` instances. + else if (this.props.immediate || isBatchingUpdates()) { + if (!coalesceSyncUpdates) { + // Opt-out path (disableSyncUpdateCoalescing()): the original behavior -- render synchronously + // for every update, no coalescing. notifyBatchedUpdateStarting(); - this.pendingUpdateTimer = setTimeout(() => { - delete this.pendingUpdateTimer; - //read fresh data at fire time so a deferred (coalesced) update always renders the latest state - this.setState({ data: this.store.getData() }, notifyBatchedUpdateCompleted); - }, 0); + this.setState({ data: data }, notifyBatchedUpdateCompleted); + return; } + // Coalescing enabled: at most one setState may be in flight per Cx. Re-entrant update() calls (the + // render itself writing to the store, as happens throughout a large initial render) are skipped -- + // the in-flight round re-reads the store on completion (see onStateUpdateCompleted), so nothing is + // dropped. Nesting these setStates deep is what trips React's "Maximum update depth exceeded". + if (this.stateUpdateInFlight) return; + if (activeSyncUpdates === 0) syncBurstRounds = 0; // fresh burst -> reset the shared round counter + activeSyncUpdates++; + this.stateUpdateInFlight = true; + notifyBatchedUpdateStarting(); + this.issueSyncSetState(); + } else { + // standard mode: coalesce sequential store commands into a single deferred update + this.scheduleStateUpdate(); } } + // Issue the next synchronous render round (coalescing path). Once a burst grows past SYNC_BURST_LIMIT we + // render from a microtask instead, so React's commit finishes without a synchronously-scheduled follow-up + // and its global nested-update counter resets before we continue. The yield is suppressed while a + // batchUpdatesAndNotify is in flight: page-breaking convergence is shallow, so staying fully synchronous + // keeps its notify callback firing right after the change commits (and the counter never gets near 50). + issueSyncSetState(): void { + if (hasBatchedUpdateSubscribers() || ++syncBurstRounds <= syncBurstLimit) { + this.setState({ data: this.store.getData() }, this.onStateUpdateCompleted); + } else { + queueMicrotask(() => { + // The event loop has turned, so React's global nested-update counter has reset; realign ours. + // Resetting here (not before scheduling) keeps the counter high through the rest of the current + // commit, so any other Cx re-arming in the same commit also yields instead of extending the chain. + syncBurstRounds = 0; + if (this.stateUpdateInFlight) + this.setState({ data: this.store.getData() }, this.onStateUpdateCompleted); + }); + } + } + + scheduleStateUpdate() { + if (!this.pendingUpdateTimer) { + notifyBatchedUpdateStarting(); + this.pendingUpdateTimer = setTimeout(() => { + delete this.pendingUpdateTimer; + // read fresh data at fire time so the coalesced update renders the latest store state + this.setState({ data: this.store.getData() }, notifyBatchedUpdateCompleted); + }, 0); + } + } + + // Completion callback for the coalescing path's setState. React runs it after the commit, so the DOM + // already reflects this render. If the render wrote to the store, run another round -- keeping the + // batched-update accounting balanced (open the next round before closing this one) so `finished` never + // catches `pending` mid-convergence and batchUpdatesAndNotify resolves only at the store fixpoint. + // Otherwise the burst has settled: clear the in-flight flag and report completion. + onStateUpdateCompleted() { + if (this.state.data === this.store.getData()) { + // Converged: the store didn't change while this round rendered, so the DOM is up to date. + this.stateUpdateInFlight = false; + activeSyncUpdates--; + notifyBatchedUpdateCompleted(); + return; + } + notifyBatchedUpdateStarting(); // open the next round + notifyBatchedUpdateCompleted(); // close this one + this.issueSyncSetState(); + } + waitForIdle(): void { if (!this.props.deferredUntilIdle) return; @@ -224,6 +291,13 @@ export class Cx extends VDOM.Component { } componentWillUnmount(): void { + if (this.stateUpdateInFlight) { + // Release the open pending round so a waiting batchUpdatesAndNotify can settle instead of waiting + // out its fallback timeout, and keep the shared in-flight refcount balanced. + this.stateUpdateInFlight = false; + activeSyncUpdates--; + notifyBatchedUpdateCompleted(); + } if (this.pendingUpdateTimer) clearTimeout(this.pendingUpdateTimer); if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest(); if (this.unsubscribe) this.unsubscribe(); diff --git a/packages/cx/src/ui/batchUpdates.ts b/packages/cx/src/ui/batchUpdates.ts index 6fd4d8f35..4dd42bf78 100644 --- a/packages/cx/src/ui/batchUpdates.ts +++ b/packages/cx/src/ui/batchUpdates.ts @@ -27,6 +27,13 @@ export function isBatchingUpdates(): boolean { return isBatching > 0; } +// True while a batchUpdatesAndNotify accumulator is subscribed, i.e. some caller is waiting for the +// current batch's renders to finish. Cx uses this to stay fully synchronous during page-breaking so the +// notify callback still fires right after the change commits. +export function hasBatchedUpdateSubscribers(): boolean { + return !promiseSubscribers.isEmpty(); +} + export function notifyBatchedUpdateStarting(): void { promiseSubscribers.execute((x: any) => { (x as UpdateCallback).pending++;