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/src/ui/Cx.tsx CHANGED
@@ -13,6 +13,41 @@ import { getCurrentCulture, pushCulture, popCulture, CultureInfo, ResolvedCultur
13
13
  import { View } from "../data/View";
14
14
  import { Config } from "./Prop";
15
15
 
16
+ // On by default. Once a synchronous update burst grows deep, Cx starts issuing updates from microtasks
17
+ // so React's global nested-update counter resets before continuing -- preventing "Maximum update depth
18
+ // exceeded" on large renders that write to the store while they render (e.g. a several-hundred-page
19
+ // report). For updates that settle in a single render -- virtually all of them -- this is equivalent to
20
+ // rendering synchronously; it only diverges under a deep re-entrant render burst. If you suspect it
21
+ // causes trouble, opt out at app startup with disableSyncUpdateCoalescing() -- and please report the
22
+ // issue so it can be fixed.
23
+
24
+ // Synchronous updates allowed within one burst before Cx switches to issuing updates from a microtask.
25
+ // Kept under React's ~50 nested-update limit (which is global to the root, not per component); the
26
+ // default trades a little initial-render time for a safety margin. Override via
27
+ // enableSyncUpdateCoalescing(limit) if needed.
28
+ const defaultSyncBurstLimit = 35;
29
+ let syncBurstLimit = defaultSyncBurstLimit;
30
+
31
+ // Microtask-issued updates allowed within one burst before Cx escalates to setTimeout. Deep enough that
32
+ // only a store that never converges reaches it; timeouts let the event loop turn, so the page stays
33
+ // responsive and batchUpdatesAndNotify fallback timers can fire instead of the tab hanging.
34
+ let microtaskBurstLimit = 1000;
35
+
36
+ export function enableSyncUpdateCoalescing(limit?: number): void {
37
+ syncBurstLimit = limit ?? defaultSyncBurstLimit;
38
+ }
39
+ export function disableSyncUpdateCoalescing(): void {
40
+ syncBurstLimit = Infinity;
41
+ }
42
+
43
+ // Module-global because React's nested-update limit is global to the root, not per component. The burst
44
+ // counter resets only when all notifications settle (see completeNotification) -- tying the burst
45
+ // window to unsettled work makes it immune to how React schedules the flushes. Legacy React chains
46
+ // re-entrant updates synchronously within one task, but React 19 may run each round from its own
47
+ // microtask, so no task/microtask boundary is a reliable reset point.
48
+ let syncBurstRounds = 0; // updates issued in the current burst
49
+ let outstandingNotifications = 0; // reported notifications not yet rendered, across all Cx instances
50
+
16
51
  export interface CxProps {
17
52
  widget?: Config;
18
53
  items?: Config;
@@ -33,6 +68,7 @@ export interface CxProps {
33
68
  export interface CxState {
34
69
  deferToken: number;
35
70
  data?: any;
71
+ error?: boolean;
36
72
  }
37
73
 
38
74
  export class Cx extends VDOM.Component<CxProps, CxState> {
@@ -43,13 +79,18 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
43
79
  flags: { preparing?: boolean; dirty?: boolean; rendering?: boolean };
44
80
  renderCount: number;
45
81
  unsubscribe?: () => void;
46
- componentDidCatch?: (error: Error, info: any) => void;
47
82
  forceUpdateCallback: () => void;
48
83
  deferCounter: number;
49
84
  pendingUpdateTimer?: NodeJS.Timeout;
50
85
  unsubscribeIdleRequest?: () => void;
51
- // 0 when no synchronous setState is in flight; >0 while one is pending (re-entrancy guard for update())
52
- stateUpdateDepth: number = 0;
86
+ // store notifications reported to batchUpdatesAndNotify subscribers but not yet rendered and completed
87
+ owedNotifications: Set<number> = new Set();
88
+ // setState is not allowed before the component mounts; pre-mount notifications set flags.dirty instead
89
+ // and componentDidMount picks them up (see update())
90
+ mounted: boolean = false;
91
+ // true once this instance has contributed to syncBurstRounds for the current render round; cleared on
92
+ // render, so bursts are counted per round rather than per notification (see update())
93
+ burstRoundCounted: boolean = false;
53
94
 
54
95
  constructor(props: CxProps) {
55
96
  super(props);
@@ -80,17 +121,13 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
80
121
  this.unsubscribe = this.store.subscribe(this.update.bind(this));
81
122
  }
82
123
 
83
- this.onStateUpdateCompleted = this.onStateUpdateCompleted.bind(this);
84
-
85
124
  this.flags = {};
86
125
  this.renderCount = 0;
87
126
 
88
- if (props.onError) this.componentDidCatch = this.componentDidCatchHandler.bind(this);
89
-
90
127
  this.forceUpdateCallback = this.forceUpdate.bind(this);
91
128
 
92
- this.deferCounter = 0;
93
- this.waitForIdle();
129
+ // deferredUntilIdle content stays hidden until the idle callback scheduled on mount bumps the token
130
+ this.deferCounter = props.deferredUntilIdle ? 1 : 0;
94
131
  }
95
132
 
96
133
  UNSAFE_componentWillReceiveProps(props: CxProps): void {
@@ -130,6 +167,12 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
130
167
  }
131
168
 
132
169
  render() {
170
+ this.burstRoundCounted = false;
171
+
172
+ // an error was captured and is being dispatched to the onError callback (see componentDidCatch);
173
+ // render nothing until the callback repairs the state
174
+ if (this.state.error) return null;
175
+
133
176
  if (this.props.deferredUntilIdle && this.state.deferToken < this.deferCounter) return null;
134
177
 
135
178
  let cultureInfo = this.props.cultureInfo ?? getCurrentCulture();
@@ -148,6 +191,10 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
148
191
  }
149
192
 
150
193
  componentDidMount(): void {
194
+ this.mounted = true;
195
+ // schedule the deferredUntilIdle reveal here rather than in the constructor -- the idle callback
196
+ // could otherwise fire before the component mounts and issue a setState React does not allow yet
197
+ this.waitForIdle();
151
198
  this.componentDidUpdate();
152
199
 
153
200
  if (this.props.options && this.props.options.onPipeUpdate)
@@ -163,54 +210,74 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
163
210
  update(): void {
164
211
  let data = this.store.getData();
165
212
  debug(appDataFlag, data);
166
- if (this.flags.preparing) this.flags.dirty = true;
167
- // Synchronous path (while batching, or for `immediate` instances). At most one setState may be in flight
168
- // per Cx: if one is already pending (stateUpdateDepth != 0) skip issuing another. Re-entrant setStates
169
- // nested deep -- e.g. a large initial render that writes to the store while it renders -- are what trip
170
- // React's "Maximum update depth exceeded"; coalescing them into a single in-flight update avoids that.
171
- // The pending update's completion callback re-reads the store and renders again if it changed
172
- // (see onStateUpdateCompleted), so no change is dropped.
213
+ if (this.flags.preparing || !this.mounted) this.flags.dirty = true;
214
+ // Synchronous path: while batching (incl. batchUpdatesAndNotify, which page-breaking relies on) or for
215
+ // `immediate` instances.
173
216
  else if (this.props.immediate || isBatchingUpdates()) {
174
- if (this.stateUpdateDepth == 0) {
175
- this.stateUpdateDepth = 1;
176
- notifyBatchedUpdateStarting();
177
- this.setState({ data: data }, this.onStateUpdateCompleted);
217
+ // Every notification is reported to batchUpdatesAndNotify subscribers up front and completed only
218
+ // once the setState that renders it commits, so notify callbacks never fire before the DOM reflects
219
+ // the change. Each notification always gets its own setState; only its timing escalates as the
220
+ // burst grows: synchronous at first, then from a microtask (lets React's commit finish so its
221
+ // global nested-update counter resets instead of tripping "Maximum update depth exceeded"), and
222
+ // finally from a timeout (lets the event loop turn, so a store that never converges degrades to a
223
+ // responsive page instead of a frozen tab).
224
+ let seq = notifyBatchedUpdateStarting();
225
+ this.owedNotifications.add(seq);
226
+ outstandingNotifications++;
227
+ // Count render rounds rather than notifications: only the first notification an instance
228
+ // receives per render round bumps the shared depth (the flag clears on render). This tracks
229
+ // React's nested-commit count -- the thing the escalation must stay under -- so a round that
230
+ // writes many values doesn't burn through the budget in one go.
231
+ if (!this.burstRoundCounted) {
232
+ this.burstRoundCounted = true;
233
+ ++syncBurstRounds;
234
+ if (process.env.NODE_ENV !== "production" && syncBurstRounds === microtaskBurstLimit + 1)
235
+ console.error(
236
+ "Cx: store updates are not converging after " +
237
+ microtaskBurstLimit +
238
+ " 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.",
239
+ );
178
240
  }
241
+ if (syncBurstRounds <= syncBurstLimit) this.issueStateUpdate(seq);
242
+ else if (syncBurstRounds <= microtaskBurstLimit) queueMicrotask(() => this.issueStateUpdate(seq));
243
+ else setTimeout(() => this.issueStateUpdate(seq), 0);
179
244
  } else {
180
245
  // standard mode: coalesce sequential store commands into a single deferred update
181
246
  this.scheduleStateUpdate();
182
247
  }
183
248
  }
184
249
 
250
+ // Render the latest store data and report the notification completed once the commit is done, so
251
+ // batchUpdatesAndNotify resolves only when the DOM reflects the store.
252
+ issueStateUpdate(seq: number): void {
253
+ // skip notifications no longer owed -- unmount may have already released them
254
+ if (!this.owedNotifications.has(seq)) return;
255
+ this.setState({ data: this.store.getData() }, () => this.completeNotification(seq));
256
+ }
257
+
258
+ completeNotification(seq: number): void {
259
+ // skip notifications no longer owed -- unmount may have already released them
260
+ if (!this.owedNotifications.delete(seq)) return;
261
+ outstandingNotifications--;
262
+ notifyBatchedUpdateCompleted(seq);
263
+ // everything settled -- the next burst starts fresh, and so does React's nested-update counter
264
+ // (the commit that settled the last notification ends without scheduling further synchronous work)
265
+ if (outstandingNotifications === 0) syncBurstRounds = 0;
266
+ }
267
+
185
268
  scheduleStateUpdate() {
186
269
  if (!this.pendingUpdateTimer) {
187
- notifyBatchedUpdateStarting();
270
+ let seq = notifyBatchedUpdateStarting();
271
+ this.owedNotifications.add(seq);
272
+ outstandingNotifications++;
188
273
  this.pendingUpdateTimer = setTimeout(() => {
189
274
  delete this.pendingUpdateTimer;
190
275
  // read fresh data at fire time so the coalesced update renders the latest store state
191
- this.setState({ data: this.store.getData() }, notifyBatchedUpdateCompleted);
276
+ this.setState({ data: this.store.getData() }, () => this.completeNotification(seq));
192
277
  }, 0);
193
278
  }
194
279
  }
195
280
 
196
- // Completion callback for the synchronous setState above. If the store changed while that update was
197
- // rendering -- i.e. the render itself wrote to the store, as happens throughout a large initial render --
198
- // render once more with the latest data, keeping this callback armed; otherwise the burst has settled, so
199
- // clear the in-flight flag and report completion. Re-arming coalesces until the store reaches a fixpoint;
200
- // a genuinely non-converging update loop is still caught by React's own max-update-depth guard.
201
- onStateUpdateCompleted() {
202
- let latestData = this.store.getData();
203
- notifyBatchedUpdateCompleted();
204
- if (this.state.data === latestData) {
205
- this.stateUpdateDepth = 0;
206
- } else {
207
- this.stateUpdateDepth++;
208
- if (this.stateUpdateDepth < 20 && isBatchingUpdates())
209
- this.setState({ data: latestData }, this.onStateUpdateCompleted);
210
- else this.scheduleStateUpdate();
211
- }
212
- }
213
-
214
281
  waitForIdle(): void {
215
282
  if (!this.props.deferredUntilIdle) return;
216
283
 
@@ -219,7 +286,7 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
219
286
  let token = ++this.deferCounter;
220
287
  this.unsubscribeIdleRequest = onIdleCallback(
221
288
  () => {
222
- this.setState({ deferToken: token }, this.onStateUpdateCompleted);
289
+ this.setState({ deferToken: token });
223
290
  },
224
291
  {
225
292
  timeout: this.props.idleTimeout || 30000,
@@ -228,6 +295,9 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
228
295
  }
229
296
 
230
297
  componentWillUnmount(): void {
298
+ // Release notifications that will never render so a waiting batchUpdatesAndNotify can settle instead
299
+ // of waiting out its fallback timeout.
300
+ for (let seq of this.owedNotifications) this.completeNotification(seq);
231
301
  if (this.pendingUpdateTimer) clearTimeout(this.pendingUpdateTimer);
232
302
  if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
233
303
  if (this.unsubscribe) this.unsubscribe();
@@ -249,9 +319,22 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
249
319
  );
250
320
  }
251
321
 
252
- componentDidCatchHandler(error: Error, info: any): void {
322
+ // Render null for the failed subtree while the error is dispatched to componentDidCatch below --
323
+ // returning a state update here is what React expects of an error boundary (rendering the broken
324
+ // children again would just rethrow).
325
+ static getDerivedStateFromError(): Partial<CxState> {
326
+ return { error: true };
327
+ }
328
+
329
+ componentDidCatch(error: Error, info: any): void {
253
330
  this.flags.preparing = false;
254
- this.props.onError!(error, this.getInstance(), info);
331
+ // without an onError callback this instance is not an error boundary -- rethrow so the error
332
+ // reaches the nearest ancestor boundary, matching the behavior before getDerivedStateFromError
333
+ // was introduced
334
+ if (!this.props.onError) throw error;
335
+ this.props.onError(error, this.getInstance(), info);
336
+ // the callback had a chance to repair the state (e.g. replace the failing content) -- resume rendering
337
+ this.setState({ error: false });
255
338
  }
256
339
  }
257
340
 
@@ -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/Rescope.ts CHANGED
@@ -1,50 +1,50 @@
1
- import { Widget } from "./Widget";
2
- import { PureContainerBase, PureContainerConfig } from "./PureContainer";
3
- import { Binding } from "../data/Binding";
4
- import { ZoomIntoPropertyView } from "../data/ZoomIntoPropertyView";
5
- import { StructuredInstanceDataAccessor } from "./StructuredInstanceDataAccessor";
6
- import { isObject } from "../util/isObject";
7
- import { StructuredProp } from "./Prop";
8
- import { AccessorChain } from "../data/createAccessorModelProxy";
9
-
10
- export interface RescopeConfig extends PureContainerConfig {
11
- bind: string | AccessorChain<any>;
12
- rootName?: string | AccessorChain<any>;
13
- rootAlias?: string | AccessorChain<any>;
14
- data?: StructuredProp;
15
- }
16
-
17
- export class Rescope extends PureContainerBase<RescopeConfig> {
18
- declare bind: string;
19
- declare binding: any;
20
- declare rootAlias?: string;
21
- declare rootName: string;
22
- declare data?: any;
23
-
24
- init() {
25
- this.binding = Binding.get(this.bind);
26
- if (this.rootAlias) this.rootName = this.rootAlias;
27
- super.init();
28
- }
29
-
30
- initInstance(context: any, instance: any) {
31
- instance.store = new ZoomIntoPropertyView({
32
- store: instance.parentStore,
33
- binding: this.binding,
34
- rootName: this.rootName,
35
- nestedData: isObject(this.data)
36
- ? new StructuredInstanceDataAccessor({ instance, data: this.data, useParentStore: true })
37
- : undefined,
38
- });
39
- super.initInstance(context, instance);
40
- }
41
-
42
- applyParentStore(instance: any) {
43
- instance.store.setStore(instance.parentStore);
44
- }
45
- }
46
-
47
- Rescope.prototype.bind = "$page";
48
- Rescope.prototype.rootName = "$root";
49
-
50
- Widget.alias("rescope", Rescope);
1
+ import { Widget } from "./Widget";
2
+ import { PureContainerBase, PureContainerConfig } from "./PureContainer";
3
+ import { Binding } from "../data/Binding";
4
+ import { ZoomIntoPropertyView } from "../data/ZoomIntoPropertyView";
5
+ import { StructuredInstanceDataAccessor } from "./StructuredInstanceDataAccessor";
6
+ import { isObject } from "../util/isObject";
7
+ import { StructuredProp } from "./Prop";
8
+ import { AccessorChain } from "../data/createAccessorModelProxy";
9
+
10
+ export interface RescopeConfig extends PureContainerConfig {
11
+ bind: string | AccessorChain<any>;
12
+ rootName?: string | AccessorChain<any>;
13
+ rootAlias?: string | AccessorChain<any>;
14
+ data?: StructuredProp;
15
+ }
16
+
17
+ export class Rescope extends PureContainerBase<RescopeConfig> {
18
+ declare bind: string;
19
+ declare binding: any;
20
+ declare rootAlias?: string;
21
+ declare rootName: string;
22
+ declare data?: any;
23
+
24
+ init() {
25
+ this.binding = Binding.get(this.bind);
26
+ if (this.rootAlias) this.rootName = this.rootAlias;
27
+ super.init();
28
+ }
29
+
30
+ initInstance(context: any, instance: any) {
31
+ instance.store = new ZoomIntoPropertyView({
32
+ store: instance.parentStore,
33
+ binding: this.binding,
34
+ rootName: this.rootName,
35
+ nestedData: isObject(this.data)
36
+ ? new StructuredInstanceDataAccessor({ instance, data: this.data, useParentStore: true })
37
+ : undefined,
38
+ });
39
+ super.initInstance(context, instance);
40
+ }
41
+
42
+ applyParentStore(instance: any) {
43
+ instance.store.setStore(instance.parentStore);
44
+ }
45
+ }
46
+
47
+ Rescope.prototype.bind = "$page";
48
+ Rescope.prototype.rootName = "$root";
49
+
50
+ Widget.alias("rescope", Rescope);