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/src/ui/Cx.tsx CHANGED
@@ -1,492 +1,505 @@
1
- /** @jsxImportSource react */
2
-
3
- import { Widget, VDOM, getContent } from "./Widget";
4
- import { Instance } from "./Instance";
5
- import { RenderingContext } from "./RenderingContext";
6
- import { debug, appDataFlag } from "../util/Debug";
7
- import { Timing, now, appLoopFlag, vdomRenderFlag } from "../util/Timing";
8
- import {
9
- isBatchingUpdates,
10
- notifyBatchedUpdateStarting,
11
- notifyBatchedUpdateCompleted,
12
- hasBatchedUpdateSubscribers,
13
- } from "./batchUpdates";
14
- import { shallowEquals } from "../util/shallowEquals";
15
- import { PureContainer } from "./PureContainer";
16
- import { onIdleCallback } from "../util/onIdleCallback";
17
- import { getCurrentCulture, pushCulture, popCulture, CultureInfo, ResolvedCultureInfo } from "./Culture";
18
- import { View } from "../data/View";
19
- import { Config } from "./Prop";
20
-
21
- // On by default. Cx coalesces re-entrant synchronous updates and, once a burst grows deep, yields to a
22
- // microtask so React's global per-root nested-update counter resets before continuing -- preventing
23
- // "Maximum update depth exceeded" on large renders that write to the store while they render (e.g. a
24
- // several-hundred-page report). For updates that settle in a single render -- virtually all of them --
25
- // this is equivalent to the previous behavior; it only diverges under a deep re-entrant render burst.
26
- // If you suspect it causes trouble, opt out at app startup with disableSyncUpdateCoalescing() -- and
27
- // please report the issue so it can be fixed.
28
- let coalesceSyncUpdates = true;
29
-
30
- // Consecutive commit-phase re-render rounds allowed before Cx yields to a microtask. Kept under React's
31
- // ~50 nested-update limit (which is global to the root, not per component); the default trades a little
32
- // initial-render time for a safety margin. Override via enableSyncUpdateCoalescing(limit) if needed.
33
- let syncBurstLimit = 35;
34
-
35
- export function enableSyncUpdateCoalescing(limit?: number): void {
36
- coalesceSyncUpdates = true;
37
- if (limit != null) syncBurstLimit = limit;
38
- }
39
- export function disableSyncUpdateCoalescing(): void {
40
- coalesceSyncUpdates = false;
41
- }
42
-
43
- // Module-global because React's nested-update limit is global to the root, not per component. A large
44
- // initial render can re-render the root Cx and every detached page Restate hundreds of times in one
45
- // synchronous burst; once the burst grows past syncBurstLimit we yield so React's commit finishes
46
- // without a synchronously-scheduled follow-up (which resets its counter) before we render again.
47
- let activeSyncUpdates = 0; // Cx instances with a synchronous setState in flight
48
- let syncBurstRounds = 0; // commit-phase re-render rounds issued since the last yield / burst start
49
-
50
- export interface CxProps {
51
- widget?: Config;
52
- items?: Config;
53
- store?: View;
54
- instance?: Instance;
55
- parentInstance?: Instance;
56
- subscribe?: boolean;
57
- immediate?: boolean;
58
- deferredUntilIdle?: boolean;
59
- idleTimeout?: number;
60
- options?: any;
61
- onError?: (error: Error, instance: Instance, info: any) => void;
62
- params?: any;
63
- contentFactory?: (props: { children: any }) => any;
64
- cultureInfo?: ResolvedCultureInfo;
65
- }
66
-
67
- export interface CxState {
68
- deferToken: number;
69
- data?: any;
70
- }
71
-
72
- export class Cx extends VDOM.Component<CxProps, CxState> {
73
- widget: Widget;
74
- store: View;
75
- parentInstance?: Instance;
76
- instance?: Instance;
77
- flags: { preparing?: boolean; dirty?: boolean; rendering?: boolean };
78
- renderCount: number;
79
- unsubscribe?: () => void;
80
- componentDidCatch?: (error: Error, info: any) => void;
81
- forceUpdateCallback: () => void;
82
- deferCounter: number;
83
- pendingUpdateTimer?: NodeJS.Timeout;
84
- unsubscribeIdleRequest?: () => void;
85
- // true while a coalesced synchronous setState is in flight for this Cx (re-entrancy guard for update());
86
- // only used when coalesceSyncUpdates is enabled
87
- stateUpdateInFlight: boolean = false;
88
-
89
- constructor(props: CxProps) {
90
- super(props);
91
-
92
- if (props.instance) {
93
- this.widget = (props.instance as any).widget;
94
- this.store = (props.instance as any).store;
95
- } else {
96
- this.widget = PureContainer.create({ items: props.widget || props.items });
97
-
98
- if (props.parentInstance) {
99
- this.parentInstance = props.parentInstance;
100
- this.store = props.store || (this.parentInstance as any).store;
101
- } else {
102
- this.parentInstance = new Instance(this.widget, "0", undefined, props.store);
103
- this.store = props.store!;
104
- }
105
-
106
- if (!this.store) throw new Error("Cx component requires a store.");
107
- }
108
-
109
- this.state = {
110
- deferToken: 0,
111
- data: props.subscribe ? this.store.getData() : null,
112
- };
113
-
114
- if (props.subscribe) {
115
- this.unsubscribe = this.store.subscribe(this.update.bind(this));
116
- }
117
-
118
- this.onStateUpdateCompleted = this.onStateUpdateCompleted.bind(this);
119
-
120
- this.flags = {};
121
- this.renderCount = 0;
122
-
123
- if (props.onError) this.componentDidCatch = this.componentDidCatchHandler.bind(this);
124
-
125
- this.forceUpdateCallback = this.forceUpdate.bind(this);
126
-
127
- this.deferCounter = 0;
128
- this.waitForIdle();
129
- }
130
-
131
- UNSAFE_componentWillReceiveProps(props: CxProps): void {
132
- let newStore = props.instance
133
- ? (props.instance as any).store
134
- : props.store
135
- ? props.store
136
- : (props.parentInstance as any).store;
137
-
138
- if (newStore != this.store) {
139
- this.store = newStore;
140
- if (this.unsubscribe) this.unsubscribe();
141
- if (props.subscribe) this.unsubscribe = this.store.subscribe(this.update.bind(this));
142
- }
143
-
144
- if (props.subscribe) {
145
- let data = this.store.getData();
146
- if (data !== this.state.data) {
147
- this.waitForIdle();
148
- this.setState({ data });
149
- }
150
- }
151
- }
152
-
153
- getInstance(): Instance {
154
- if (this.props.instance) return this.props.instance;
155
-
156
- if (this.instance && this.instance.widget === this.widget) {
157
- if (this.instance.parentStore != this.store) this.instance.setParentStore(this.store);
158
- return this.instance;
159
- }
160
-
161
- if (this.widget && this.parentInstance)
162
- return (this.instance = this.parentInstance.getDetachedChild(this.widget, "0", this.store));
163
-
164
- throw new Error("Could not resolve a widget instance in the Cx component.");
165
- }
166
-
167
- render() {
168
- if (this.props.deferredUntilIdle && this.state.deferToken < this.deferCounter) return null;
169
-
170
- let cultureInfo = this.props.cultureInfo ?? getCurrentCulture();
171
-
172
- return (
173
- <CxContext
174
- instance={this.getInstance()}
175
- flags={this.flags}
176
- options={this.props.options}
177
- buster={++this.renderCount}
178
- contentFactory={this.props.contentFactory}
179
- forceUpdate={this.forceUpdateCallback}
180
- cultureInfo={cultureInfo}
181
- />
182
- );
183
- }
184
-
185
- componentDidMount(): void {
186
- this.componentDidUpdate();
187
-
188
- if (this.props.options && this.props.options.onPipeUpdate)
189
- this.props.options.onPipeUpdate(this.update.bind(this));
190
- }
191
-
192
- componentDidUpdate(): void {
193
- if (this.flags.dirty) {
194
- this.update();
195
- }
196
- }
197
-
198
- update(): void {
199
- let data = this.store.getData();
200
- debug(appDataFlag, data);
201
- if (this.flags.preparing) this.flags.dirty = true;
202
- // Synchronous path: while batching (incl. batchUpdatesAndNotify, which page-breaking relies on) or for
203
- // `immediate` instances.
204
- else if (this.props.immediate || isBatchingUpdates()) {
205
- if (!coalesceSyncUpdates) {
206
- // Opt-out path (disableSyncUpdateCoalescing()): the original behavior -- render synchronously
207
- // for every update, no coalescing.
208
- notifyBatchedUpdateStarting();
209
- this.setState({ data: data }, notifyBatchedUpdateCompleted);
210
- return;
211
- }
212
- // Coalescing enabled: at most one setState may be in flight per Cx. Re-entrant update() calls (the
213
- // render itself writing to the store, as happens throughout a large initial render) are skipped --
214
- // the in-flight round re-reads the store on completion (see onStateUpdateCompleted), so nothing is
215
- // dropped. Nesting these setStates deep is what trips React's "Maximum update depth exceeded".
216
- if (this.stateUpdateInFlight) return;
217
- if (activeSyncUpdates === 0) syncBurstRounds = 0; // fresh burst -> reset the shared round counter
218
- activeSyncUpdates++;
219
- this.stateUpdateInFlight = true;
220
- notifyBatchedUpdateStarting();
221
- this.issueSyncSetState();
222
- } else {
223
- // standard mode: coalesce sequential store commands into a single deferred update
224
- this.scheduleStateUpdate();
225
- }
226
- }
227
-
228
- // Issue the next synchronous render round (coalescing path). Once a burst grows past SYNC_BURST_LIMIT we
229
- // render from a microtask instead, so React's commit finishes without a synchronously-scheduled follow-up
230
- // and its global nested-update counter resets before we continue. The yield is suppressed while a
231
- // batchUpdatesAndNotify is in flight: page-breaking convergence is shallow, so staying fully synchronous
232
- // keeps its notify callback firing right after the change commits (and the counter never gets near 50).
233
- issueSyncSetState(): void {
234
- if (hasBatchedUpdateSubscribers() || ++syncBurstRounds <= syncBurstLimit) {
235
- this.setState({ data: this.store.getData() }, this.onStateUpdateCompleted);
236
- } else {
237
- queueMicrotask(() => {
238
- // The event loop has turned, so React's global nested-update counter has reset; realign ours.
239
- // Resetting here (not before scheduling) keeps the counter high through the rest of the current
240
- // commit, so any other Cx re-arming in the same commit also yields instead of extending the chain.
241
- syncBurstRounds = 0;
242
- if (this.stateUpdateInFlight)
243
- this.setState({ data: this.store.getData() }, this.onStateUpdateCompleted);
244
- });
245
- }
246
- }
247
-
248
- scheduleStateUpdate() {
249
- if (!this.pendingUpdateTimer) {
250
- notifyBatchedUpdateStarting();
251
- this.pendingUpdateTimer = setTimeout(() => {
252
- delete this.pendingUpdateTimer;
253
- // read fresh data at fire time so the coalesced update renders the latest store state
254
- this.setState({ data: this.store.getData() }, notifyBatchedUpdateCompleted);
255
- }, 0);
256
- }
257
- }
258
-
259
- // Completion callback for the coalescing path's setState. React runs it after the commit, so the DOM
260
- // already reflects this render. If the render wrote to the store, run another round -- keeping the
261
- // batched-update accounting balanced (open the next round before closing this one) so `finished` never
262
- // catches `pending` mid-convergence and batchUpdatesAndNotify resolves only at the store fixpoint.
263
- // Otherwise the burst has settled: clear the in-flight flag and report completion.
264
- onStateUpdateCompleted() {
265
- if (this.state.data === this.store.getData()) {
266
- // Converged: the store didn't change while this round rendered, so the DOM is up to date.
267
- this.stateUpdateInFlight = false;
268
- activeSyncUpdates--;
269
- notifyBatchedUpdateCompleted();
270
- return;
271
- }
272
- notifyBatchedUpdateStarting(); // open the next round
273
- notifyBatchedUpdateCompleted(); // close this one
274
- this.issueSyncSetState();
275
- }
276
-
277
- waitForIdle(): void {
278
- if (!this.props.deferredUntilIdle) return;
279
-
280
- if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
281
-
282
- let token = ++this.deferCounter;
283
- this.unsubscribeIdleRequest = onIdleCallback(
284
- () => {
285
- this.setState({ deferToken: token });
286
- },
287
- {
288
- timeout: this.props.idleTimeout || 30000,
289
- },
290
- );
291
- }
292
-
293
- componentWillUnmount(): void {
294
- if (this.stateUpdateInFlight) {
295
- // Release the open pending round so a waiting batchUpdatesAndNotify can settle instead of waiting
296
- // out its fallback timeout, and keep the shared in-flight refcount balanced.
297
- this.stateUpdateInFlight = false;
298
- activeSyncUpdates--;
299
- notifyBatchedUpdateCompleted();
300
- }
301
- if (this.pendingUpdateTimer) clearTimeout(this.pendingUpdateTimer);
302
- if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
303
- if (this.unsubscribe) this.unsubscribe();
304
- if (this.props.options && this.props.options.onPipeUpdate) this.props.options.onPipeUpdate(null);
305
- }
306
-
307
- shouldComponentUpdate(props: CxProps, state: CxState): boolean {
308
- if (props.deferredUntilIdle && state.deferToken != this.deferCounter) return false;
309
-
310
- return (
311
- state !== this.state ||
312
- !props.params ||
313
- !shallowEquals(props.params, this.props.params) ||
314
- props.instance !== this.props.instance ||
315
- props.widget !== this.props.widget ||
316
- props.store !== this.props.store ||
317
- props.parentInstance !== this.props.parentInstance ||
318
- props.cultureInfo !== this.props.cultureInfo
319
- );
320
- }
321
-
322
- componentDidCatchHandler(error: Error, info: any): void {
323
- this.flags.preparing = false;
324
- this.props.onError!(error, this.getInstance(), info);
325
- }
326
- }
327
-
328
- interface CxContextProps {
329
- instance: Instance;
330
- flags: { preparing?: boolean; dirty?: boolean; rendering?: boolean };
331
- options?: any;
332
- buster: number;
333
- contentFactory?: (props: { children: any }) => any;
334
- forceUpdate: () => void;
335
- cultureInfo?: ResolvedCultureInfo;
336
- }
337
-
338
- class CxContext extends VDOM.Component<CxContextProps, {}> {
339
- renderCount: number;
340
- timings: any;
341
- content: any;
342
- renderingContext?: RenderingContext;
343
-
344
- constructor(props: CxContextProps) {
345
- super(props);
346
- this.renderCount = 0;
347
- this.UNSAFE_componentWillReceiveProps(props);
348
- }
349
-
350
- UNSAFE_componentWillReceiveProps(props: CxContextProps): void {
351
- this.timings = {
352
- start: now(),
353
- };
354
-
355
- let { instance, options, contentFactory } = props;
356
- let count = 0,
357
- visible,
358
- context,
359
- forceContinue;
360
-
361
- //should not be tracked by parents for destroy
362
- if (!(instance as any).detached)
363
- throw new Error("The instance passed to a Cx component should be detached from its parent.");
364
-
365
- if (this.props.instance !== instance && (this.props.instance as any).destroyTracked)
366
- (this.props.instance as any).destroy();
367
-
368
- this.props.flags.preparing = true;
369
-
370
- if (this.props.cultureInfo) pushCulture(this.props.cultureInfo);
371
-
372
- try {
373
- do {
374
- count++;
375
- forceContinue = false;
376
- context = new RenderingContext(options);
377
- (context as any).forceUpdate = this.props.forceUpdate;
378
- this.props.flags.dirty = false;
379
- (instance as any).assignedRenderList = (context as any).getRootRenderList();
380
- visible = (instance as any).scheduleExploreIfVisible(context);
381
- if (visible) {
382
- while (!(context as any).exploreStack.empty()) {
383
- let inst = (context as any).exploreStack.pop();
384
- //console.log("EXPLORE", inst.widget.constructor.name, inst.widget.tag, inst.widget.widgetId);
385
- (inst as any).explore(context);
386
- }
387
- } else if ((instance as any).destroyTracked) {
388
- (instance as any).destroy();
389
- }
390
-
391
- if (this.props.flags.dirty && count <= 3 && Widget.optimizePrepare && now() - this.timings.start < 8) {
392
- forceContinue = true;
393
- continue;
394
- }
395
-
396
- if (visible) {
397
- this.timings.afterExplore = now();
398
-
399
- for (let i = 0; i < (context as any).prepareList.length; i++)
400
- (context as any).prepareList[i].prepare(context);
401
- this.timings.afterPrepare = now();
402
- }
403
- } while (
404
- forceContinue ||
405
- (this.props.flags.dirty && count <= 3 && Widget.optimizePrepare && now() - this.timings.start < 8)
406
- );
407
-
408
- if (visible) {
409
- //walk in reverse order so children get rendered first
410
- let renderList = (context as any).getRootRenderList();
411
- while (renderList) {
412
- for (let i = renderList.data.length - 1; i >= 0; i--) {
413
- renderList.data[i].render(context);
414
- }
415
- renderList = renderList.right;
416
- }
417
-
418
- this.content = getContent((instance as any).vdom);
419
- if (contentFactory) this.content = contentFactory({ children: this.content });
420
- this.timings.afterRender = now();
421
- for (let i = 0; i < (context as any).cleanupList.length; i++)
422
- (context as any).cleanupList[i].cleanup(context);
423
- } else {
424
- this.content = null;
425
- this.timings.afterExplore = this.timings.afterPrepare = this.timings.afterRender = now();
426
- }
427
- } finally {
428
- if (this.props.cultureInfo) popCulture(this.props.cultureInfo);
429
- }
430
-
431
- this.timings.beforeVDOMRender = now();
432
- this.props.flags.preparing = false;
433
- this.props.flags.rendering = true;
434
- this.renderingContext = context;
435
- }
436
-
437
- render() {
438
- return this.content;
439
- }
440
-
441
- componentDidMount(): void {
442
- this.componentDidUpdate();
443
- }
444
-
445
- componentDidUpdate(): void {
446
- this.props.flags.rendering = false;
447
- this.timings.afterVDOMRender = now();
448
-
449
- //let {instance} = this.props;
450
- //instance.cleanup(this.renderingContext);
451
-
452
- this.timings.afterCleanup = now();
453
- this.renderCount++;
454
-
455
- if (process.env.NODE_ENV !== "production") {
456
- let { start, beforeVDOMRender, afterVDOMRender, afterPrepare, afterExplore, afterRender, afterCleanup } =
457
- this.timings;
458
-
459
- Timing.log(
460
- vdomRenderFlag,
461
- this.renderCount,
462
- "cx",
463
- (beforeVDOMRender - start + afterCleanup - afterVDOMRender).toFixed(2) + "ms",
464
- "vdom",
465
- (afterVDOMRender - beforeVDOMRender).toFixed(2) + "ms",
466
- );
467
-
468
- Timing.log(
469
- appLoopFlag,
470
- this.renderCount,
471
- (this.renderingContext as any).options.name || "main",
472
- "total",
473
- (afterCleanup - start).toFixed(1) + "ms",
474
- "explore",
475
- (afterExplore - start).toFixed(1) + "ms",
476
- "prepare",
477
- (afterPrepare - afterExplore).toFixed(1),
478
- "render",
479
- (afterRender - afterPrepare).toFixed(1),
480
- "vdom",
481
- (afterVDOMRender - beforeVDOMRender).toFixed(1),
482
- "cleanup",
483
- (afterCleanup - afterVDOMRender).toFixed(1),
484
- );
485
- }
486
- }
487
-
488
- componentWillUnmount(): void {
489
- let { instance } = this.props;
490
- if ((instance as any).destroyTracked) (instance as any).destroy();
491
- }
492
- }
1
+ /** @jsxImportSource react */
2
+
3
+ import { Widget, VDOM, getContent } from "./Widget";
4
+ import { Instance } from "./Instance";
5
+ import { RenderingContext } from "./RenderingContext";
6
+ import { debug, appDataFlag } from "../util/Debug";
7
+ import { Timing, now, appLoopFlag, vdomRenderFlag } from "../util/Timing";
8
+ import { isBatchingUpdates, notifyBatchedUpdateStarting, notifyBatchedUpdateCompleted } from "./batchUpdates";
9
+ import { shallowEquals } from "../util/shallowEquals";
10
+ import { PureContainer } from "./PureContainer";
11
+ import { onIdleCallback } from "../util/onIdleCallback";
12
+ import { getCurrentCulture, pushCulture, popCulture, CultureInfo, ResolvedCultureInfo } from "./Culture";
13
+ import { View } from "../data/View";
14
+ import { Config } from "./Prop";
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
+
51
+ export interface CxProps {
52
+ widget?: Config;
53
+ items?: Config;
54
+ store?: View;
55
+ instance?: Instance;
56
+ parentInstance?: Instance;
57
+ subscribe?: boolean;
58
+ immediate?: boolean;
59
+ deferredUntilIdle?: boolean;
60
+ idleTimeout?: number;
61
+ options?: any;
62
+ onError?: (error: Error, instance: Instance, info: any) => void;
63
+ params?: any;
64
+ contentFactory?: (props: { children: any }) => any;
65
+ cultureInfo?: ResolvedCultureInfo;
66
+ }
67
+
68
+ export interface CxState {
69
+ deferToken: number;
70
+ data?: any;
71
+ error?: boolean;
72
+ }
73
+
74
+ export class Cx extends VDOM.Component<CxProps, CxState> {
75
+ widget: Widget;
76
+ store: View;
77
+ parentInstance?: Instance;
78
+ instance?: Instance;
79
+ flags: { preparing?: boolean; dirty?: boolean; rendering?: boolean };
80
+ renderCount: number;
81
+ unsubscribe?: () => void;
82
+ forceUpdateCallback: () => void;
83
+ deferCounter: number;
84
+ pendingUpdateTimer?: NodeJS.Timeout;
85
+ unsubscribeIdleRequest?: () => void;
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;
94
+
95
+ constructor(props: CxProps) {
96
+ super(props);
97
+
98
+ if (props.instance) {
99
+ this.widget = (props.instance as any).widget;
100
+ this.store = (props.instance as any).store;
101
+ } else {
102
+ this.widget = PureContainer.create({ items: props.widget || props.items });
103
+
104
+ if (props.parentInstance) {
105
+ this.parentInstance = props.parentInstance;
106
+ this.store = props.store || (this.parentInstance as any).store;
107
+ } else {
108
+ this.parentInstance = new Instance(this.widget, "0", undefined, props.store);
109
+ this.store = props.store!;
110
+ }
111
+
112
+ if (!this.store) throw new Error("Cx component requires a store.");
113
+ }
114
+
115
+ this.state = {
116
+ deferToken: 0,
117
+ data: props.subscribe ? this.store.getData() : null,
118
+ };
119
+
120
+ if (props.subscribe) {
121
+ this.unsubscribe = this.store.subscribe(this.update.bind(this));
122
+ }
123
+
124
+ this.flags = {};
125
+ this.renderCount = 0;
126
+
127
+ this.forceUpdateCallback = this.forceUpdate.bind(this);
128
+
129
+ // deferredUntilIdle content stays hidden until the idle callback scheduled on mount bumps the token
130
+ this.deferCounter = props.deferredUntilIdle ? 1 : 0;
131
+ }
132
+
133
+ UNSAFE_componentWillReceiveProps(props: CxProps): void {
134
+ let newStore = props.instance
135
+ ? (props.instance as any).store
136
+ : props.store
137
+ ? props.store
138
+ : (props.parentInstance as any).store;
139
+
140
+ if (newStore != this.store) {
141
+ this.store = newStore;
142
+ if (this.unsubscribe) this.unsubscribe();
143
+ if (props.subscribe) this.unsubscribe = this.store.subscribe(this.update.bind(this));
144
+ }
145
+
146
+ if (props.subscribe) {
147
+ let data = this.store.getData();
148
+ if (data !== this.state.data) {
149
+ this.waitForIdle();
150
+ this.setState({ data });
151
+ }
152
+ }
153
+ }
154
+
155
+ getInstance(): Instance {
156
+ if (this.props.instance) return this.props.instance;
157
+
158
+ if (this.instance && this.instance.widget === this.widget) {
159
+ if (this.instance.parentStore != this.store) this.instance.setParentStore(this.store);
160
+ return this.instance;
161
+ }
162
+
163
+ if (this.widget && this.parentInstance)
164
+ return (this.instance = this.parentInstance.getDetachedChild(this.widget, "0", this.store));
165
+
166
+ throw new Error("Could not resolve a widget instance in the Cx component.");
167
+ }
168
+
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
+
176
+ if (this.props.deferredUntilIdle && this.state.deferToken < this.deferCounter) return null;
177
+
178
+ let cultureInfo = this.props.cultureInfo ?? getCurrentCulture();
179
+
180
+ return (
181
+ <CxContext
182
+ instance={this.getInstance()}
183
+ flags={this.flags}
184
+ options={this.props.options}
185
+ buster={++this.renderCount}
186
+ contentFactory={this.props.contentFactory}
187
+ forceUpdate={this.forceUpdateCallback}
188
+ cultureInfo={cultureInfo}
189
+ />
190
+ );
191
+ }
192
+
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();
198
+ this.componentDidUpdate();
199
+
200
+ if (this.props.options && this.props.options.onPipeUpdate)
201
+ this.props.options.onPipeUpdate(this.update.bind(this));
202
+ }
203
+
204
+ componentDidUpdate(): void {
205
+ if (this.flags.dirty) {
206
+ this.update();
207
+ }
208
+ }
209
+
210
+ update(): void {
211
+ let data = this.store.getData();
212
+ debug(appDataFlag, data);
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.
216
+ else if (this.props.immediate || isBatchingUpdates()) {
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
+ );
240
+ }
241
+ if (syncBurstRounds <= syncBurstLimit) this.issueStateUpdate(seq);
242
+ else if (syncBurstRounds <= microtaskBurstLimit) queueMicrotask(() => this.issueStateUpdate(seq));
243
+ else setTimeout(() => this.issueStateUpdate(seq), 0);
244
+ } else {
245
+ // standard mode: coalesce sequential store commands into a single deferred update
246
+ this.scheduleStateUpdate();
247
+ }
248
+ }
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
+
268
+ scheduleStateUpdate() {
269
+ if (!this.pendingUpdateTimer) {
270
+ let seq = notifyBatchedUpdateStarting();
271
+ this.owedNotifications.add(seq);
272
+ outstandingNotifications++;
273
+ this.pendingUpdateTimer = setTimeout(() => {
274
+ delete this.pendingUpdateTimer;
275
+ // read fresh data at fire time so the coalesced update renders the latest store state
276
+ this.setState({ data: this.store.getData() }, () => this.completeNotification(seq));
277
+ }, 0);
278
+ }
279
+ }
280
+
281
+ waitForIdle(): void {
282
+ if (!this.props.deferredUntilIdle) return;
283
+
284
+ if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
285
+
286
+ let token = ++this.deferCounter;
287
+ this.unsubscribeIdleRequest = onIdleCallback(
288
+ () => {
289
+ this.setState({ deferToken: token });
290
+ },
291
+ {
292
+ timeout: this.props.idleTimeout || 30000,
293
+ },
294
+ );
295
+ }
296
+
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);
301
+ if (this.pendingUpdateTimer) clearTimeout(this.pendingUpdateTimer);
302
+ if (this.unsubscribeIdleRequest) this.unsubscribeIdleRequest();
303
+ if (this.unsubscribe) this.unsubscribe();
304
+ if (this.props.options && this.props.options.onPipeUpdate) this.props.options.onPipeUpdate(null);
305
+ }
306
+
307
+ shouldComponentUpdate(props: CxProps, state: CxState): boolean {
308
+ if (props.deferredUntilIdle && state.deferToken != this.deferCounter) return false;
309
+
310
+ return (
311
+ state !== this.state ||
312
+ !props.params ||
313
+ !shallowEquals(props.params, this.props.params) ||
314
+ props.instance !== this.props.instance ||
315
+ props.widget !== this.props.widget ||
316
+ props.store !== this.props.store ||
317
+ props.parentInstance !== this.props.parentInstance ||
318
+ props.cultureInfo !== this.props.cultureInfo
319
+ );
320
+ }
321
+
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 {
330
+ this.flags.preparing = false;
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 });
338
+ }
339
+ }
340
+
341
+ interface CxContextProps {
342
+ instance: Instance;
343
+ flags: { preparing?: boolean; dirty?: boolean; rendering?: boolean };
344
+ options?: any;
345
+ buster: number;
346
+ contentFactory?: (props: { children: any }) => any;
347
+ forceUpdate: () => void;
348
+ cultureInfo?: ResolvedCultureInfo;
349
+ }
350
+
351
+ class CxContext extends VDOM.Component<CxContextProps, {}> {
352
+ renderCount: number;
353
+ timings: any;
354
+ content: any;
355
+ renderingContext?: RenderingContext;
356
+
357
+ constructor(props: CxContextProps) {
358
+ super(props);
359
+ this.renderCount = 0;
360
+ this.UNSAFE_componentWillReceiveProps(props);
361
+ }
362
+
363
+ UNSAFE_componentWillReceiveProps(props: CxContextProps): void {
364
+ this.timings = {
365
+ start: now(),
366
+ };
367
+
368
+ let { instance, options, contentFactory } = props;
369
+ let count = 0,
370
+ visible,
371
+ context,
372
+ forceContinue;
373
+
374
+ //should not be tracked by parents for destroy
375
+ if (!(instance as any).detached)
376
+ throw new Error("The instance passed to a Cx component should be detached from its parent.");
377
+
378
+ if (this.props.instance !== instance && (this.props.instance as any).destroyTracked)
379
+ (this.props.instance as any).destroy();
380
+
381
+ this.props.flags.preparing = true;
382
+
383
+ if (this.props.cultureInfo) pushCulture(this.props.cultureInfo);
384
+
385
+ try {
386
+ do {
387
+ count++;
388
+ forceContinue = false;
389
+ context = new RenderingContext(options);
390
+ (context as any).forceUpdate = this.props.forceUpdate;
391
+ this.props.flags.dirty = false;
392
+ (instance as any).assignedRenderList = (context as any).getRootRenderList();
393
+ visible = (instance as any).scheduleExploreIfVisible(context);
394
+ if (visible) {
395
+ while (!(context as any).exploreStack.empty()) {
396
+ let inst = (context as any).exploreStack.pop();
397
+ //console.log("EXPLORE", inst.widget.constructor.name, inst.widget.tag, inst.widget.widgetId);
398
+ (inst as any).explore(context);
399
+ }
400
+ } else if ((instance as any).destroyTracked) {
401
+ (instance as any).destroy();
402
+ }
403
+
404
+ if (this.props.flags.dirty && count <= 3 && Widget.optimizePrepare && now() - this.timings.start < 8) {
405
+ forceContinue = true;
406
+ continue;
407
+ }
408
+
409
+ if (visible) {
410
+ this.timings.afterExplore = now();
411
+
412
+ for (let i = 0; i < (context as any).prepareList.length; i++)
413
+ (context as any).prepareList[i].prepare(context);
414
+ this.timings.afterPrepare = now();
415
+ }
416
+ } while (
417
+ forceContinue ||
418
+ (this.props.flags.dirty && count <= 3 && Widget.optimizePrepare && now() - this.timings.start < 8)
419
+ );
420
+
421
+ if (visible) {
422
+ //walk in reverse order so children get rendered first
423
+ let renderList = (context as any).getRootRenderList();
424
+ while (renderList) {
425
+ for (let i = renderList.data.length - 1; i >= 0; i--) {
426
+ renderList.data[i].render(context);
427
+ }
428
+ renderList = renderList.right;
429
+ }
430
+
431
+ this.content = getContent((instance as any).vdom);
432
+ if (contentFactory) this.content = contentFactory({ children: this.content });
433
+ this.timings.afterRender = now();
434
+ for (let i = 0; i < (context as any).cleanupList.length; i++)
435
+ (context as any).cleanupList[i].cleanup(context);
436
+ } else {
437
+ this.content = null;
438
+ this.timings.afterExplore = this.timings.afterPrepare = this.timings.afterRender = now();
439
+ }
440
+ } finally {
441
+ if (this.props.cultureInfo) popCulture(this.props.cultureInfo);
442
+ }
443
+
444
+ this.timings.beforeVDOMRender = now();
445
+ this.props.flags.preparing = false;
446
+ this.props.flags.rendering = true;
447
+ this.renderingContext = context;
448
+ }
449
+
450
+ render() {
451
+ return this.content;
452
+ }
453
+
454
+ componentDidMount(): void {
455
+ this.componentDidUpdate();
456
+ }
457
+
458
+ componentDidUpdate(): void {
459
+ this.props.flags.rendering = false;
460
+ this.timings.afterVDOMRender = now();
461
+
462
+ //let {instance} = this.props;
463
+ //instance.cleanup(this.renderingContext);
464
+
465
+ this.timings.afterCleanup = now();
466
+ this.renderCount++;
467
+
468
+ if (process.env.NODE_ENV !== "production") {
469
+ let { start, beforeVDOMRender, afterVDOMRender, afterPrepare, afterExplore, afterRender, afterCleanup } =
470
+ this.timings;
471
+
472
+ Timing.log(
473
+ vdomRenderFlag,
474
+ this.renderCount,
475
+ "cx",
476
+ (beforeVDOMRender - start + afterCleanup - afterVDOMRender).toFixed(2) + "ms",
477
+ "vdom",
478
+ (afterVDOMRender - beforeVDOMRender).toFixed(2) + "ms",
479
+ );
480
+
481
+ Timing.log(
482
+ appLoopFlag,
483
+ this.renderCount,
484
+ (this.renderingContext as any).options.name || "main",
485
+ "total",
486
+ (afterCleanup - start).toFixed(1) + "ms",
487
+ "explore",
488
+ (afterExplore - start).toFixed(1) + "ms",
489
+ "prepare",
490
+ (afterPrepare - afterExplore).toFixed(1),
491
+ "render",
492
+ (afterRender - afterPrepare).toFixed(1),
493
+ "vdom",
494
+ (afterVDOMRender - beforeVDOMRender).toFixed(1),
495
+ "cleanup",
496
+ (afterCleanup - afterVDOMRender).toFixed(1),
497
+ );
498
+ }
499
+ }
500
+
501
+ componentWillUnmount(): void {
502
+ let { instance } = this.props;
503
+ if ((instance as any).destroyTracked) (instance as any).destroy();
504
+ }
505
+ }