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/build/ui/Cx.d.ts +5 -1
- package/build/ui/Cx.d.ts.map +1 -1
- package/build/ui/Cx.js +102 -34
- package/build/ui/batchUpdates.d.ts +1 -0
- package/build/ui/batchUpdates.d.ts.map +1 -1
- package/build/ui/batchUpdates.js +6 -0
- package/dist/manifest.js +716 -707
- package/dist/ui.js +118 -42
- package/package.json +1 -1
- package/src/ui/Cx.spec.tsx +58 -0
- package/src/ui/Cx.tsx +492 -416
- package/src/ui/Restate.tsx +217 -217
- package/src/ui/batchUpdates.ts +7 -0
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
|
-
//
|
|
2111
|
-
|
|
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
|
|
2199
|
-
//
|
|
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 (
|
|
2206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
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
|
-
|
|
2284
|
+
this.onStateUpdateCompleted,
|
|
2227
2285
|
);
|
|
2228
|
-
|
|
2229
|
-
}
|
|
2286
|
+
});
|
|
2230
2287
|
}
|
|
2231
2288
|
}
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
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
|
-
|
|
2239
|
-
|
|
2240
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/ui/Cx.spec.tsx
CHANGED
|
@@ -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
|
});
|