react-state-basis 0.6.5 → 0.6.7
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/README.md +2 -2
- package/dist/{chunk-EJXGN76H.mjs → chunk-KIXM6YRX.mjs} +307 -212
- package/dist/chunk-KIXM6YRX.mjs.map +1 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +306 -211
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/integrations/zustand.js +301 -210
- package/dist/integrations/zustand.js.map +1 -1
- package/dist/integrations/zustand.mjs +1 -1
- package/package.json +1 -1
- package/dist/chunk-EJXGN76H.mjs.map +0 -1
package/dist/index.js
CHANGED
|
@@ -64,33 +64,92 @@ module.exports = __toCommonJS(index_exports);
|
|
|
64
64
|
var React = __toESM(require("react"));
|
|
65
65
|
var import_react = require("react");
|
|
66
66
|
|
|
67
|
+
// src/core/constants.ts
|
|
68
|
+
var WINDOW_SIZE = 50;
|
|
69
|
+
var PAIR_RARITY_TARGET = 0.01;
|
|
70
|
+
var RELATIVE_OVERLAP_FLOOR = 0.65;
|
|
71
|
+
var LOOP_THRESHOLD = 150;
|
|
72
|
+
var VOLATILITY_THRESHOLD = 25;
|
|
73
|
+
var INSTANCE_SEP = "##";
|
|
74
|
+
|
|
67
75
|
// src/core/math.ts
|
|
68
|
-
var
|
|
76
|
+
var choose = (n, k) => {
|
|
77
|
+
if (k < 0 || k > n) return 0;
|
|
78
|
+
k = Math.min(k, n - k);
|
|
79
|
+
let result = 1;
|
|
80
|
+
for (let i = 0; i < k; i++) {
|
|
81
|
+
result = result * (n - i) / (i + 1);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
};
|
|
85
|
+
var hypergeomPMF = (k, n1, m1, N) => {
|
|
86
|
+
const total = choose(N, n1);
|
|
87
|
+
if (total === 0) return 0;
|
|
88
|
+
return choose(m1, k) * choose(N - m1, n1 - k) / total;
|
|
89
|
+
};
|
|
90
|
+
var hypergeomUpperTailP = (kStart, n1, m1, N) => {
|
|
91
|
+
const kMax = Math.min(n1, m1);
|
|
92
|
+
let p = 0;
|
|
93
|
+
for (let k = Math.max(0, kStart); k <= kMax; k++) {
|
|
94
|
+
p += hypergeomPMF(k, n1, m1, N);
|
|
95
|
+
}
|
|
96
|
+
return p;
|
|
97
|
+
};
|
|
98
|
+
var minOverlapForRarity = (n1, m1, N, targetP) => {
|
|
99
|
+
const kMax = Math.min(n1, m1);
|
|
100
|
+
for (let k = 0; k <= kMax; k++) {
|
|
101
|
+
if (hypergeomUpperTailP(k, n1, m1, N) <= targetP) return k;
|
|
102
|
+
}
|
|
103
|
+
return kMax + 1;
|
|
104
|
+
};
|
|
105
|
+
var overlapThresholdCache = /* @__PURE__ */ new Map();
|
|
106
|
+
var getMinOverlap = (densityA, densityB, windowSize) => {
|
|
107
|
+
const lo = Math.min(densityA, densityB) | 0;
|
|
108
|
+
const hi = Math.max(densityA, densityB) | 0;
|
|
109
|
+
const key = `${lo}_${hi}_${windowSize}`;
|
|
110
|
+
let cached = overlapThresholdCache.get(key);
|
|
111
|
+
if (cached === void 0) {
|
|
112
|
+
const rare = minOverlapForRarity(lo, hi, windowSize, PAIR_RARITY_TARGET);
|
|
113
|
+
const floor = Math.ceil(RELATIVE_OVERLAP_FLOOR * lo);
|
|
114
|
+
cached = Math.max(rare, floor);
|
|
115
|
+
overlapThresholdCache.set(key, cached);
|
|
116
|
+
}
|
|
117
|
+
return cached;
|
|
118
|
+
};
|
|
119
|
+
var isSignificantOverlap = (overlap, densityA, densityB, windowSize) => {
|
|
120
|
+
if (densityA < 2 || densityB < 2) return false;
|
|
121
|
+
return overlap >= getMinOverlap(densityA, densityB, windowSize);
|
|
122
|
+
};
|
|
123
|
+
var countOverlapsCircular = (bufferA, headA, bufferB, headB) => {
|
|
69
124
|
const L = bufferA.length;
|
|
70
|
-
|
|
71
|
-
const
|
|
125
|
+
const offSync = ((headB - headA) % L + L) % L;
|
|
126
|
+
const offALeadsB = ((headB - headA + 1) % L + L) % L;
|
|
127
|
+
const offBLeadsA = ((headB - headA - 1) % L + L) % L;
|
|
128
|
+
let kSync = 0;
|
|
129
|
+
let kALeadsB = 0;
|
|
130
|
+
let kBLeadsA = 0;
|
|
131
|
+
let densityA = 0;
|
|
132
|
+
let densityB = 0;
|
|
72
133
|
for (let i = 0; i < L; i++) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
134
|
+
const a = bufferA[i] ? 1 : 0;
|
|
135
|
+
const b = bufferB[i] ? 1 : 0;
|
|
136
|
+
densityA += a;
|
|
137
|
+
densityB += b;
|
|
138
|
+
let iSync = i + offSync;
|
|
139
|
+
if (iSync >= L) iSync -= L;
|
|
140
|
+
let iALeadsB = i + offALeadsB;
|
|
141
|
+
if (iALeadsB >= L) iALeadsB -= L;
|
|
142
|
+
let iBLeadsA = i + offBLeadsA;
|
|
143
|
+
if (iBLeadsA >= L) iBLeadsA -= L;
|
|
144
|
+
if (a && bufferB[iSync]) kSync++;
|
|
145
|
+
if (a && bufferB[iALeadsB]) kALeadsB++;
|
|
146
|
+
if (a && bufferB[iBLeadsA]) kBLeadsA++;
|
|
82
147
|
}
|
|
83
|
-
|
|
84
|
-
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
148
|
+
return { kSync, kALeadsB, kBLeadsA, densityA, densityB };
|
|
85
149
|
};
|
|
86
|
-
var
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
dot += A[i] * B[i];
|
|
90
|
-
magA += A[i] * A[i];
|
|
91
|
-
magB += B[i] * B[i];
|
|
92
|
-
}
|
|
93
|
-
return magA === 0 || magB === 0 ? 0 : dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
150
|
+
var cosineFromOverlap = (overlap, densityA, densityB) => {
|
|
151
|
+
if (densityA <= 0 || densityB <= 0) return 0;
|
|
152
|
+
return overlap / Math.sqrt(densityA * densityB);
|
|
94
153
|
};
|
|
95
154
|
|
|
96
155
|
// src/core/graph.ts
|
|
@@ -149,13 +208,6 @@ var groupEventSources = (nodes, edges) => {
|
|
|
149
208
|
return groups.sort((a, b) => b.edges.length - a.edges.length || b.occurrences - a.occurrences);
|
|
150
209
|
};
|
|
151
210
|
|
|
152
|
-
// src/core/constants.ts
|
|
153
|
-
var WINDOW_SIZE = 50;
|
|
154
|
-
var SIMILARITY_THRESHOLD = 0.88;
|
|
155
|
-
var LOOP_THRESHOLD = 150;
|
|
156
|
-
var VOLATILITY_THRESHOLD = 25;
|
|
157
|
-
var INSTANCE_SEP = "##";
|
|
158
|
-
|
|
159
211
|
// src/core/label.ts
|
|
160
212
|
var stripInstance = (label) => {
|
|
161
213
|
const idx = label.indexOf(INSTANCE_SEP);
|
|
@@ -269,32 +321,22 @@ var LAST_LOG_TIMES = /* @__PURE__ */ new Map();
|
|
|
269
321
|
var LOG_COOLDOWN = 3e3;
|
|
270
322
|
var THEME = {
|
|
271
323
|
identity: "#6C5CE7",
|
|
272
|
-
// Purple (Brand)
|
|
273
324
|
problem: "#D63031",
|
|
274
|
-
// Red (Bugs)
|
|
275
325
|
solution: "#FBC531",
|
|
276
|
-
// Yellow (Fixes)
|
|
277
326
|
context: "#0984E3",
|
|
278
|
-
// Blue (Locations)
|
|
279
327
|
muted: "#9AA0A6",
|
|
280
|
-
// Gray (Metadata)
|
|
281
328
|
border: "#2E2E35",
|
|
282
329
|
success: "#00b894"
|
|
283
|
-
// Green (Good Score)
|
|
284
330
|
};
|
|
285
331
|
var STYLES = {
|
|
286
|
-
// Structure
|
|
287
332
|
basis: `background: ${THEME.identity}; color: white; font-weight: bold; padding: 2px 6px; border-radius: 3px;`,
|
|
288
333
|
headerIdentity: `background: ${THEME.identity}; color: white; font-weight: bold; padding: 4px 8px; border-radius: 4px;`,
|
|
289
334
|
headerProblem: `background: ${THEME.problem}; color: white; font-weight: bold; padding: 4px 8px; border-radius: 4px;`,
|
|
290
335
|
version: `background: #a29bfe; color: #2d3436; padding: 2px 6px; border-radius: 3px; margin-left: -4px;`,
|
|
291
|
-
// Actions
|
|
292
336
|
actionLabel: `color: ${THEME.solution}; font-weight: bold;`,
|
|
293
337
|
actionPill: `color: ${THEME.solution}; font-weight: bold; border: 1px solid ${THEME.solution}; padding: 0 4px; border-radius: 3px;`,
|
|
294
|
-
// Context
|
|
295
338
|
impactLabel: `color: ${THEME.context}; font-weight: bold;`,
|
|
296
339
|
location: `color: ${THEME.context}; font-family: monospace; font-weight: bold;`,
|
|
297
|
-
// Text
|
|
298
340
|
subText: `color: ${THEME.muted}; font-size: 11px;`,
|
|
299
341
|
bold: "font-weight: bold;",
|
|
300
342
|
label: "background: #dfe6e9; color: #2d3436; padding: 0 4px; border-radius: 3px; font-family: monospace; font-weight: bold; border: 1px solid #b2bec3;"
|
|
@@ -309,97 +351,95 @@ var shouldLog = (key) => {
|
|
|
309
351
|
return false;
|
|
310
352
|
};
|
|
311
353
|
var isBooleanLike = (name) => /^(is|has|can|should|did|will|show|hide)(?=[A-Z_])/.test(name);
|
|
354
|
+
var displayName = (raw) => {
|
|
355
|
+
const { name } = parseLabel(raw);
|
|
356
|
+
return name.replace(/:\d+$/, "");
|
|
357
|
+
};
|
|
358
|
+
var areSyncSignificant = (metaA, metaB) => {
|
|
359
|
+
const { kSync, densityA, densityB } = countOverlapsCircular(
|
|
360
|
+
metaA.buffer,
|
|
361
|
+
metaA.head,
|
|
362
|
+
metaB.buffer,
|
|
363
|
+
metaB.head
|
|
364
|
+
);
|
|
365
|
+
return isSignificantOverlap(kSync, densityA, densityB, metaA.buffer.length);
|
|
366
|
+
};
|
|
312
367
|
var getSuggestedFix = (issue, info) => {
|
|
313
368
|
if (issue.label.includes("Global Event")) {
|
|
314
|
-
return `
|
|
369
|
+
return `One interaction is updating state in several places. If that is really one transition, put it in one %cstore / reducer%c. If it is intentional, ignore this.`;
|
|
315
370
|
}
|
|
316
371
|
const violations = issue.violations || [];
|
|
317
372
|
const leaks = violations.filter((v) => v.type === "causal_leak");
|
|
318
373
|
const mirrors = violations.filter((v) => v.type === "context_mirror");
|
|
319
374
|
const duplicates = violations.filter((v) => v.type === "duplicate_state");
|
|
320
375
|
if (mirrors.length > 0) {
|
|
321
|
-
return `
|
|
376
|
+
return `A local hook is only tracking context or a store. If it is not a draft, delete it and read the %ccontext / store%c in render.`;
|
|
322
377
|
}
|
|
323
378
|
if (leaks.length > 0) {
|
|
324
|
-
const targetName =
|
|
379
|
+
const targetName = displayName(leaks[0].target);
|
|
325
380
|
if (issue.label.includes("effect")) {
|
|
326
|
-
return `
|
|
381
|
+
return `An effect is calling setState on ${targetName}, which paints again. If you can compute ${targetName} while rendering, drop the %ceffect%c.`;
|
|
327
382
|
}
|
|
328
|
-
return
|
|
383
|
+
return `${info.name} updates, then ${targetName} updates on the next frame. If they are one fact, write them in the same %csetState%c.`;
|
|
329
384
|
}
|
|
330
385
|
if (duplicates.length > 0) {
|
|
331
386
|
if (isBooleanLike(info.name)) {
|
|
332
|
-
return `
|
|
387
|
+
return `Several flags move together. One %cstatus%c value avoids impossible combinations.`;
|
|
333
388
|
}
|
|
334
|
-
return `
|
|
389
|
+
return `These hooks move together. If one is just the other in another shape, compute it while %crendering%c.`;
|
|
335
390
|
}
|
|
336
391
|
if (issue.metric === "density") {
|
|
337
|
-
return `
|
|
392
|
+
return `This hook updates faster than a frame. %cDebounce%c it or keep it in a ref if the UI does not need every pulse.`;
|
|
338
393
|
}
|
|
339
|
-
return `
|
|
394
|
+
return `Inspect ${info.name} and what updates with it.`;
|
|
340
395
|
};
|
|
341
|
-
var displayHealthReport = (history2,
|
|
396
|
+
var displayHealthReport = (history2, violationMap) => {
|
|
342
397
|
if (!isWeb) return;
|
|
343
398
|
const entries = Array.from(history2.entries());
|
|
344
399
|
if (entries.length === 0) return;
|
|
345
400
|
const topIssues = identifyTopIssues(instance.graph, history2, instance.redundantLabels, violationMap);
|
|
346
|
-
console.group(`%c
|
|
401
|
+
console.group(`%c BASIS | report `, STYLES.headerIdentity);
|
|
347
402
|
if (topIssues.length > 0) {
|
|
348
|
-
console.log(
|
|
349
|
-
`%c\u{1F3AF} REFACTOR PRIORITIES %c(PRIME MOVERS)`,
|
|
350
|
-
`font-weight: bold; color: ${THEME.identity}; margin-top: 10px;`,
|
|
351
|
-
`font-weight: normal; color: ${THEME.muted}; font-style: italic;`
|
|
352
|
-
);
|
|
403
|
+
console.log(`%cStart here`, `font-weight: bold; color: ${THEME.identity}; margin-top: 10px;`);
|
|
353
404
|
topIssues.forEach((issue, idx) => {
|
|
354
405
|
const info = parseLabel(issue.label);
|
|
355
|
-
const icon = issue.metric === "influence" ? "\
|
|
406
|
+
const icon = issue.metric === "influence" ? "\u2192" : "\u2022";
|
|
356
407
|
const pColor = idx === 0 ? THEME.problem : idx === 1 ? THEME.solution : THEME.identity;
|
|
357
|
-
let displayName = info.name;
|
|
358
|
-
let displayFile = info.file;
|
|
359
|
-
if (issue.label.includes("Global Event")) {
|
|
360
|
-
displayName = info.name;
|
|
361
|
-
displayFile = info.file;
|
|
362
|
-
}
|
|
363
408
|
console.group(
|
|
364
|
-
` %c${idx + 1}%c ${icon} ${displayName} %c(${
|
|
409
|
+
` %c${idx + 1}%c ${icon} ${displayName(issue.label)} %c(${info.file})`,
|
|
365
410
|
`background: ${pColor}; color: ${idx === 1 ? "black" : "white"}; border-radius: 50%; padding: 0 5px;`,
|
|
366
411
|
"font-family: monospace; font-weight: 700;",
|
|
367
|
-
`color: ${THEME.muted}; font-size: 10px; font-weight: normal
|
|
412
|
+
`color: ${THEME.muted}; font-size: 10px; font-weight: normal;`
|
|
368
413
|
);
|
|
369
|
-
console.log(`%c${issue.reason}`, `color: ${THEME.muted}
|
|
414
|
+
console.log(`%c${issue.reason}`, `color: ${THEME.muted};`);
|
|
370
415
|
if (issue.violations.length > 0) {
|
|
371
416
|
const byFile = /* @__PURE__ */ new Map();
|
|
372
417
|
issue.violations.forEach((v) => {
|
|
373
418
|
if (issue.label.includes("Global Event") && v.type === "context_mirror") return;
|
|
374
419
|
const { file, name } = parseLabel(v.target);
|
|
375
420
|
if (!byFile.has(file)) byFile.set(file, []);
|
|
376
|
-
byFile.get(file).push(name);
|
|
421
|
+
byFile.get(file).push(name.replace(/:\d+$/, ""));
|
|
377
422
|
});
|
|
378
423
|
const impactParts = [];
|
|
379
424
|
byFile.forEach((vars, file) => {
|
|
380
|
-
|
|
381
|
-
impactParts.push(`${file} (${varList})`);
|
|
425
|
+
impactParts.push(`${file} (${vars.join(", ")})`);
|
|
382
426
|
});
|
|
383
427
|
if (impactParts.length > 0) {
|
|
384
|
-
console.log(`%
|
|
428
|
+
console.log(`%cAlso updates: %c${impactParts.join(" \xB7 ")}`, STYLES.impactLabel, "");
|
|
385
429
|
}
|
|
386
430
|
}
|
|
387
431
|
const fix = getSuggestedFix(issue, info);
|
|
388
432
|
const fixParts = fix.split("%c");
|
|
389
433
|
if (fixParts.length === 3) {
|
|
390
434
|
console.log(
|
|
391
|
-
`%
|
|
435
|
+
`%cTry: %c${fixParts[0]}%c${fixParts[1]}%c${fixParts[2]}`,
|
|
392
436
|
STYLES.actionLabel,
|
|
393
437
|
"",
|
|
394
438
|
STYLES.actionPill,
|
|
395
439
|
""
|
|
396
440
|
);
|
|
397
441
|
} else {
|
|
398
|
-
console.log(
|
|
399
|
-
`%cSolution: %c${fix}`,
|
|
400
|
-
STYLES.actionLabel,
|
|
401
|
-
""
|
|
402
|
-
);
|
|
442
|
+
console.log(`%cTry: %c${fix}`, STYLES.actionLabel, "");
|
|
403
443
|
}
|
|
404
444
|
console.groupEnd();
|
|
405
445
|
});
|
|
@@ -414,145 +454,179 @@ var displayHealthReport = (history2, threshold, violationMap) => {
|
|
|
414
454
|
processed.add(labelA);
|
|
415
455
|
entries.forEach(([labelB, metaB]) => {
|
|
416
456
|
if (labelA === labelB || processed.has(labelB)) return;
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
457
|
+
if (!areSyncSignificant(metaA, metaB)) return;
|
|
458
|
+
if (metaA.role === "context" /* CONTEXT */ && metaB.role === "context" /* CONTEXT */) return;
|
|
459
|
+
currentCluster.push(labelB);
|
|
460
|
+
processed.add(labelB);
|
|
422
461
|
});
|
|
423
462
|
if (currentCluster.length > 1) clusters.push(currentCluster);
|
|
424
463
|
else independentCount++;
|
|
425
464
|
});
|
|
426
465
|
const totalVars = entries.length;
|
|
427
|
-
const redundancyScore = (independentCount + clusters.length) / totalVars * 100;
|
|
428
|
-
let internalEdges = 0;
|
|
429
|
-
instance.graph.forEach((targets, source) => {
|
|
430
|
-
if (source.startsWith("Event_Tick_")) return;
|
|
431
|
-
internalEdges += targets.size;
|
|
432
|
-
});
|
|
433
|
-
const causalPenalty = internalEdges / totalVars * 100;
|
|
434
|
-
let healthScore = redundancyScore - causalPenalty;
|
|
435
|
-
if (healthScore < 0) healthScore = 0;
|
|
436
|
-
const scoreColor = healthScore > 85 ? THEME.success : THEME.problem;
|
|
437
466
|
console.log(
|
|
438
|
-
`%
|
|
439
|
-
STYLES.
|
|
440
|
-
`color: ${scoreColor}; font-weight: bold;`
|
|
467
|
+
`%c${independentCount + clusters.length} of ${totalVars} instrumented hooks look independent in this window.`,
|
|
468
|
+
STYLES.subText
|
|
441
469
|
);
|
|
442
|
-
console.log(`%cSources of Truth: ${independentCount + clusters.length}/${totalVars} | Causal Leaks: ${internalEdges}`, STYLES.subText);
|
|
443
470
|
if (clusters.length > 0) {
|
|
444
|
-
console.log(
|
|
471
|
+
console.log(
|
|
472
|
+
`%c${clusters.length} group${clusters.length === 1 ? "" : "s"} that keep updating together:`,
|
|
473
|
+
`font-weight: bold; color: ${THEME.problem}; margin-top: 10px;`
|
|
474
|
+
);
|
|
445
475
|
clusters.forEach((cluster, idx) => {
|
|
446
476
|
const clusterMetas = cluster.map((l) => ({
|
|
447
477
|
label: l,
|
|
448
478
|
meta: history2.get(l),
|
|
449
|
-
name:
|
|
479
|
+
name: displayName(l)
|
|
450
480
|
}));
|
|
451
481
|
const hasCtx = clusterMetas.some(
|
|
452
482
|
(c) => c.meta.role === "context" /* CONTEXT */ || c.meta.role === "store" /* STORE */
|
|
453
483
|
);
|
|
454
|
-
const names = clusterMetas.map((c) =>
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
484
|
+
const names = clusterMetas.map((c) => c.name).join(", ");
|
|
485
|
+
console.group(
|
|
486
|
+
` %c${idx + 1}%c ${names}`,
|
|
487
|
+
`background: ${THEME.problem}; color: white; border-radius: 50%; padding: 0 5px;`,
|
|
488
|
+
"font-family: monospace; font-weight: bold;"
|
|
489
|
+
);
|
|
459
490
|
if (hasCtx) {
|
|
460
491
|
const hasStore = clusterMetas.some((c) => c.meta.role === "store" /* STORE */);
|
|
461
|
-
const sourceType = hasStore ? "
|
|
462
|
-
console.log(
|
|
463
|
-
console.log(
|
|
492
|
+
const sourceType = hasStore ? "a store" : "context";
|
|
493
|
+
console.log(`A local hook is only following ${sourceType}.`);
|
|
494
|
+
console.log(
|
|
495
|
+
`%cTry:%c Read ${sourceType} in render if the local value is not a draft.`,
|
|
496
|
+
STYLES.actionLabel,
|
|
497
|
+
""
|
|
498
|
+
);
|
|
464
499
|
} else {
|
|
465
|
-
const boolKeywords = [
|
|
500
|
+
const boolKeywords = [
|
|
501
|
+
"is",
|
|
502
|
+
"has",
|
|
503
|
+
"can",
|
|
504
|
+
"should",
|
|
505
|
+
"loading",
|
|
506
|
+
"success",
|
|
507
|
+
"error",
|
|
508
|
+
"active",
|
|
509
|
+
"enabled",
|
|
510
|
+
"open",
|
|
511
|
+
"visible"
|
|
512
|
+
];
|
|
466
513
|
const boolCount = clusterMetas.filter(
|
|
467
514
|
(c) => boolKeywords.some((kw) => c.name.toLowerCase().startsWith(kw))
|
|
468
515
|
).length;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
console.log(
|
|
472
|
-
|
|
516
|
+
if (cluster.length > 2 && boolCount / cluster.length > 0.5) {
|
|
517
|
+
console.log(`These flags move together.`);
|
|
518
|
+
console.log(
|
|
519
|
+
`%cTry:%c One %cstatus%c instead of several booleans.`,
|
|
520
|
+
STYLES.actionLabel,
|
|
521
|
+
"",
|
|
522
|
+
STYLES.actionPill,
|
|
523
|
+
""
|
|
524
|
+
);
|
|
473
525
|
} else if (cluster.length > 2) {
|
|
474
|
-
console.log(
|
|
475
|
-
console.log(
|
|
526
|
+
console.log(`These hooks move on the same frames. Often the same click or fetch.`);
|
|
527
|
+
console.log(
|
|
528
|
+
`%cTry:%c Leave it if that is intentional. Otherwise one %creducer%c.`,
|
|
529
|
+
STYLES.actionLabel,
|
|
530
|
+
"",
|
|
531
|
+
STYLES.actionPill,
|
|
532
|
+
""
|
|
533
|
+
);
|
|
476
534
|
} else {
|
|
477
|
-
console.log(
|
|
478
|
-
console.log(
|
|
535
|
+
console.log(`These two hooks keep updating in the same frame.`);
|
|
536
|
+
console.log(
|
|
537
|
+
`%cTry:%c If one is derived, compute it while %crendering%c.`,
|
|
538
|
+
STYLES.actionLabel,
|
|
539
|
+
"",
|
|
540
|
+
STYLES.actionPill,
|
|
541
|
+
""
|
|
542
|
+
);
|
|
479
543
|
}
|
|
480
544
|
}
|
|
481
545
|
console.groupEnd();
|
|
482
546
|
});
|
|
483
547
|
} else {
|
|
484
|
-
console.log(
|
|
548
|
+
console.log(
|
|
549
|
+
"%cNo hooks were updating in lockstep in this window.",
|
|
550
|
+
`color: ${THEME.success}; font-weight: bold;`
|
|
551
|
+
);
|
|
485
552
|
}
|
|
486
553
|
console.groupEnd();
|
|
487
554
|
};
|
|
488
|
-
var displayRedundancyAlert = (labelA, metaA, labelB, metaB,
|
|
555
|
+
var displayRedundancyAlert = (labelA, metaA, labelB, metaB, overlap) => {
|
|
489
556
|
if (!isWeb || !shouldLog(`redundant-${labelA}-${labelB}`)) return;
|
|
490
557
|
const infoA = parseLabel(labelA);
|
|
491
|
-
const
|
|
558
|
+
const nameA = displayName(labelA);
|
|
559
|
+
const nameB = displayName(labelB);
|
|
492
560
|
const isContextMirror = metaA.role === "local" /* LOCAL */ && metaB.role === "context" /* CONTEXT */ || metaB.role === "local" /* LOCAL */ && metaA.role === "context" /* CONTEXT */;
|
|
493
561
|
const isStoreMirror = metaA.role === "local" /* LOCAL */ && metaB.role === "store" /* STORE */ || metaB.role === "local" /* LOCAL */ && metaA.role === "store" /* STORE */;
|
|
494
|
-
const alertType = isContextMirror ? "
|
|
495
|
-
|
|
496
|
-
console.
|
|
497
|
-
console.log(`%
|
|
562
|
+
const alertType = isContextMirror ? "local state follows context" : isStoreMirror ? "local state follows a store" : "hooks moving together";
|
|
563
|
+
const times = overlap.kSync === 1 ? "time" : "times";
|
|
564
|
+
console.group(`%c BASIS | ${alertType} `, STYLES.headerProblem);
|
|
565
|
+
console.log(`%c${infoA.file}`, STYLES.location);
|
|
566
|
+
console.log(
|
|
567
|
+
`%c${nameA}%c and %c${nameB}%c updated in the same frame ${overlap.kSync} ${times}.`,
|
|
568
|
+
STYLES.label,
|
|
569
|
+
"",
|
|
570
|
+
STYLES.label,
|
|
571
|
+
""
|
|
572
|
+
);
|
|
498
573
|
if (isContextMirror || isStoreMirror) {
|
|
499
|
-
const sourceType = isStoreMirror ? "
|
|
574
|
+
const sourceType = isStoreMirror ? "store" : "context";
|
|
575
|
+
console.log(
|
|
576
|
+
`%cTry:%c If this is not a draft, delete the local hook and read the %c${sourceType}%c in render.`,
|
|
577
|
+
STYLES.bold,
|
|
578
|
+
"",
|
|
579
|
+
STYLES.actionPill,
|
|
580
|
+
""
|
|
581
|
+
);
|
|
582
|
+
} else if (isBooleanLike(nameA) || isBooleanLike(nameB)) {
|
|
500
583
|
console.log(
|
|
501
|
-
`%
|
|
584
|
+
`%cTry:%c One %cstatus%c instead of several flags.`,
|
|
502
585
|
STYLES.bold,
|
|
503
586
|
"",
|
|
504
587
|
STYLES.actionPill,
|
|
505
588
|
""
|
|
506
589
|
);
|
|
507
590
|
} else {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
);
|
|
518
|
-
} else {
|
|
519
|
-
console.log(
|
|
520
|
-
`%cFix:%c Redundant State detected. Derive %c${infoB.name}%c from %c${infoA.name}%c during render, or use %cuseMemo%c.`,
|
|
521
|
-
STYLES.bold,
|
|
522
|
-
"",
|
|
523
|
-
STYLES.label,
|
|
524
|
-
"",
|
|
525
|
-
STYLES.label,
|
|
526
|
-
"",
|
|
527
|
-
STYLES.actionPill,
|
|
528
|
-
""
|
|
529
|
-
);
|
|
530
|
-
}
|
|
591
|
+
console.log(
|
|
592
|
+
`%cTry:%c If %c${nameB}%c is just %c${nameA}%c in another shape, compute it while rendering.`,
|
|
593
|
+
STYLES.bold,
|
|
594
|
+
"",
|
|
595
|
+
STYLES.label,
|
|
596
|
+
"",
|
|
597
|
+
STYLES.label,
|
|
598
|
+
""
|
|
599
|
+
);
|
|
531
600
|
}
|
|
532
601
|
console.groupEnd();
|
|
533
602
|
};
|
|
534
|
-
var displayCausalHint = (targetLabel,
|
|
603
|
+
var displayCausalHint = (targetLabel, _targetMeta, sourceLabel, sourceMeta) => {
|
|
535
604
|
if (!isWeb || !shouldLog(`causal-${sourceLabel}-${targetLabel}`)) return;
|
|
536
605
|
const target = parseLabel(targetLabel);
|
|
537
|
-
const
|
|
538
|
-
const
|
|
606
|
+
const sourceName = displayName(sourceLabel);
|
|
607
|
+
const targetName = displayName(targetLabel);
|
|
608
|
+
const headerType = sourceMeta.role === "context" /* CONTEXT */ ? "extra render from context" : sourceMeta.role === "store" /* STORE */ ? "extra render from a store" : "extra render";
|
|
539
609
|
const isEffect = sourceLabel.includes("effect") || sourceLabel.includes("useLayoutEffect");
|
|
540
|
-
console.groupCollapsed(`%c
|
|
541
|
-
console.log(`%c
|
|
542
|
-
console.log(
|
|
610
|
+
console.groupCollapsed(`%c BASIS | ${headerType} `, STYLES.headerProblem);
|
|
611
|
+
console.log(`%c${target.file}`, STYLES.location);
|
|
612
|
+
console.log(
|
|
613
|
+
`%c${sourceName}%c updates %c${targetName}%c on the next frame.`,
|
|
614
|
+
STYLES.label,
|
|
615
|
+
"",
|
|
616
|
+
STYLES.label,
|
|
617
|
+
""
|
|
618
|
+
);
|
|
543
619
|
if (isEffect) {
|
|
544
620
|
console.log(
|
|
545
|
-
`%
|
|
621
|
+
`%cTry:%c If %c${targetName}%c can be computed while rendering, drop the extra setState.`,
|
|
546
622
|
STYLES.bold,
|
|
547
623
|
"",
|
|
548
624
|
STYLES.label,
|
|
549
|
-
"",
|
|
550
|
-
STYLES.actionPill,
|
|
551
625
|
""
|
|
552
626
|
);
|
|
553
627
|
} else {
|
|
554
628
|
console.log(
|
|
555
|
-
`%
|
|
629
|
+
`%cTry:%c Write %c${targetName}%c in the same update as %c${sourceName}%c if they are one fact.`,
|
|
556
630
|
STYLES.bold,
|
|
557
631
|
"",
|
|
558
632
|
STYLES.label,
|
|
@@ -585,7 +659,7 @@ var displayGraphReport = (graph) => {
|
|
|
585
659
|
if (!isWeb) return;
|
|
586
660
|
if (graph.nodes.length === 0) {
|
|
587
661
|
console.log(
|
|
588
|
-
`%c
|
|
662
|
+
`%c BASIS | update graph %c(nothing recorded yet)`,
|
|
589
663
|
STYLES.headerIdentity,
|
|
590
664
|
`color: ${THEME.muted}; font-style: italic;`
|
|
591
665
|
);
|
|
@@ -604,17 +678,22 @@ var displayGraphReport = (graph) => {
|
|
|
604
678
|
occurrences: g.occurrences
|
|
605
679
|
}));
|
|
606
680
|
const groupedSourceIds = new Set(graph.eventGroups.flatMap((g) => g.sourceIds));
|
|
607
|
-
const nonEventGroups = Array.from(outgoing.keys()).filter((id) => !groupedSourceIds.has(id)).map((id) => ({
|
|
681
|
+
const nonEventGroups = Array.from(outgoing.keys()).filter((id) => !groupedSourceIds.has(id)).map((id) => ({
|
|
682
|
+
sourceIds: [id],
|
|
683
|
+
sourceNode: nodeById.get(id),
|
|
684
|
+
edges: outgoing.get(id),
|
|
685
|
+
occurrences: 1
|
|
686
|
+
}));
|
|
608
687
|
const groups = [...eventGroups, ...nonEventGroups].sort(
|
|
609
688
|
(a, b) => b.edges.length - a.edges.length || b.occurrences - a.occurrences
|
|
610
689
|
);
|
|
611
690
|
console.group(
|
|
612
|
-
`%c
|
|
691
|
+
`%c BASIS | update graph %c${graph.nodes.length} nodes \xB7 ${graph.edges.length} edges \xB7 ${groups.length} sources \xB7 last ${graph.bufferWindowSize} frames`,
|
|
613
692
|
STYLES.headerIdentity,
|
|
614
|
-
`color: ${THEME.muted}; font-weight: normal
|
|
693
|
+
`color: ${THEME.muted}; font-weight: normal;`
|
|
615
694
|
);
|
|
616
695
|
console.log(
|
|
617
|
-
`%cparent \u2192 child =
|
|
696
|
+
`%cparent \u2192 child = what we saw cause an update. (\xD7N) = times in this window. Repeat clicks with the same targets are grouped.`,
|
|
618
697
|
STYLES.subText
|
|
619
698
|
);
|
|
620
699
|
groups.forEach((group) => {
|
|
@@ -622,12 +701,12 @@ var displayGraphReport = (graph) => {
|
|
|
622
701
|
const isCtx = group.sourceNode?.role === "context" /* CONTEXT */;
|
|
623
702
|
const isFx = group.sourceNode?.role === "effect";
|
|
624
703
|
const isUnknown = group.sourceNode?.role === "unknown";
|
|
625
|
-
const icon = isEvent ? "\
|
|
704
|
+
const icon = isEvent ? "\u2022" : isCtx ? "ctx" : isFx ? "fx" : isUnknown ? "?" : "\u2022";
|
|
626
705
|
const color = isEvent ? THEME.solution : isCtx ? THEME.context : THEME.identity;
|
|
627
706
|
const fanout = group.edges.length;
|
|
628
707
|
const hits = group.occurrences;
|
|
629
708
|
const hitLabel = hits > 1 ? ` \xB7 \xD7${hits}` : "";
|
|
630
|
-
const title = isEvent ? `
|
|
709
|
+
const title = isEvent ? `click / event \xB7 ${fanout} update${fanout === 1 ? "" : "s"}${hitLabel}` : formatNode(group.sourceNode, group.sourceIds[0]);
|
|
631
710
|
console.groupCollapsed(
|
|
632
711
|
`%c${icon} %c${title}`,
|
|
633
712
|
`color: ${color};`,
|
|
@@ -639,16 +718,16 @@ var displayGraphReport = (graph) => {
|
|
|
639
718
|
const weight = edge.weight > 1 ? ` (\xD7${edge.weight})` : "";
|
|
640
719
|
if (target?.redundant) {
|
|
641
720
|
console.log(
|
|
642
|
-
`%c ${label}%c${weight} %
|
|
721
|
+
`%c ${label}%c${weight} %cmoving with another hook`,
|
|
643
722
|
`color: ${THEME.muted}; font-family: monospace;`,
|
|
644
|
-
`color: ${THEME.muted}
|
|
723
|
+
`color: ${THEME.muted};`,
|
|
645
724
|
`color: ${THEME.problem}; font-weight: bold;`
|
|
646
725
|
);
|
|
647
726
|
} else {
|
|
648
727
|
console.log(
|
|
649
728
|
`%c ${label}%c${weight}`,
|
|
650
729
|
`color: ${THEME.muted}; font-family: monospace;`,
|
|
651
|
-
`color: ${THEME.muted}
|
|
730
|
+
`color: ${THEME.muted};`
|
|
652
731
|
);
|
|
653
732
|
}
|
|
654
733
|
});
|
|
@@ -656,23 +735,29 @@ var displayGraphReport = (graph) => {
|
|
|
656
735
|
});
|
|
657
736
|
console.groupEnd();
|
|
658
737
|
};
|
|
659
|
-
var displayViolentBreaker = (label, count,
|
|
738
|
+
var displayViolentBreaker = (label, count, _threshold) => {
|
|
660
739
|
if (!isWeb) return;
|
|
661
|
-
const
|
|
662
|
-
console.group(`%c
|
|
663
|
-
console.error(
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
console.log(
|
|
740
|
+
const name = displayName(label);
|
|
741
|
+
console.group(`%c BASIS | loop guard `, STYLES.headerProblem);
|
|
742
|
+
console.error(
|
|
743
|
+
`${name} updated ${count} times in one second. Basis stopped recording this path so the tab stays usable.`
|
|
744
|
+
);
|
|
745
|
+
console.log(
|
|
746
|
+
`%cReact may still error on its own. Fix the effect that writes a value it also lists as a dependency.`,
|
|
747
|
+
`color: ${THEME.muted};`
|
|
748
|
+
);
|
|
667
749
|
console.groupEnd();
|
|
668
750
|
};
|
|
669
751
|
var displayBootLog = (windowSize) => {
|
|
670
752
|
if (!isWeb) return;
|
|
671
|
-
console.log(
|
|
753
|
+
console.log(
|
|
754
|
+
`%cBasis%c watching updates (${windowSize}-frame window)`,
|
|
755
|
+
STYLES.basis,
|
|
756
|
+
`color: ${THEME.muted}; margin-left: 8px;`
|
|
757
|
+
);
|
|
672
758
|
};
|
|
673
759
|
|
|
674
760
|
// src/core/analysis.ts
|
|
675
|
-
var CAUSAL_MARGIN = 0.05;
|
|
676
761
|
var isEventDriven = (label, graph) => {
|
|
677
762
|
for (const [parent, targets] of graph.entries()) {
|
|
678
763
|
if (parent.startsWith("Event_Tick_") && targets.has(label)) {
|
|
@@ -682,29 +767,40 @@ var isEventDriven = (label, graph) => {
|
|
|
682
767
|
return false;
|
|
683
768
|
};
|
|
684
769
|
var calculateAllSimilarities = (entryA, entryB) => {
|
|
685
|
-
const
|
|
686
|
-
entryA.meta.buffer,
|
|
687
|
-
entryA.meta.head,
|
|
688
|
-
entryB.meta.buffer,
|
|
689
|
-
entryB.meta.head,
|
|
690
|
-
0
|
|
691
|
-
);
|
|
692
|
-
const bA = calculateSimilarityCircular(
|
|
770
|
+
const { kSync, kALeadsB, kBLeadsA, densityA, densityB } = countOverlapsCircular(
|
|
693
771
|
entryA.meta.buffer,
|
|
694
772
|
entryA.meta.head,
|
|
695
773
|
entryB.meta.buffer,
|
|
696
|
-
entryB.meta.head
|
|
697
|
-
1
|
|
774
|
+
entryB.meta.head
|
|
698
775
|
);
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
);
|
|
706
|
-
|
|
776
|
+
const sync = cosineFromOverlap(kSync, densityA, densityB);
|
|
777
|
+
const bA = cosineFromOverlap(kALeadsB, densityA, densityB);
|
|
778
|
+
const aB = cosineFromOverlap(kBLeadsA, densityA, densityB);
|
|
779
|
+
const max = Math.max(sync, bA, aB);
|
|
780
|
+
const windowSize = entryA.meta.buffer.length;
|
|
781
|
+
const significantSync = isSignificantOverlap(kSync, densityA, densityB, windowSize);
|
|
782
|
+
const kLead = Math.max(kALeadsB, kBLeadsA);
|
|
783
|
+
const significantLead = isSignificantOverlap(kLead, densityA, densityB, windowSize) && kLead >= kSync + 1;
|
|
784
|
+
return {
|
|
785
|
+
sync,
|
|
786
|
+
bA,
|
|
787
|
+
aB,
|
|
788
|
+
max,
|
|
789
|
+
kSync,
|
|
790
|
+
kALeadsB,
|
|
791
|
+
kBLeadsA,
|
|
792
|
+
densityA,
|
|
793
|
+
densityB,
|
|
794
|
+
significantSync,
|
|
795
|
+
significantLead
|
|
796
|
+
};
|
|
707
797
|
};
|
|
798
|
+
var overlapFrom = (s) => ({
|
|
799
|
+
kSync: s.kSync,
|
|
800
|
+
densityA: s.densityA,
|
|
801
|
+
densityB: s.densityB,
|
|
802
|
+
cosine: s.sync
|
|
803
|
+
});
|
|
708
804
|
var shouldSkipComparison = (entryA, entryB, dirtyLabels2) => {
|
|
709
805
|
if (entryA.label === entryB.label) return true;
|
|
710
806
|
if (isSameField(entryA.label, entryB.label)) return true;
|
|
@@ -728,52 +824,51 @@ var detectRedundancy = (entryA, entryB, similarities, redundantSet, violationMap
|
|
|
728
824
|
const roleA = entryA.meta.role;
|
|
729
825
|
const roleB = entryB.meta.role;
|
|
730
826
|
if (isGlobalSource(roleA) && isGlobalSource(roleB)) return;
|
|
731
|
-
if (
|
|
827
|
+
if (similarities.densityA < 2 || similarities.densityB < 2) return;
|
|
828
|
+
const overlap = overlapFrom(similarities);
|
|
732
829
|
if (roleA === "local" /* LOCAL */ && isGlobalSource(roleB)) {
|
|
733
830
|
redundantSet.add(entryA.label);
|
|
734
|
-
pushViolation(violationMap, entryB.label, { type: "context_mirror", target: entryA.label,
|
|
735
|
-
displayRedundancyAlert(entryA.label, entryA.meta, entryB.label, entryB.meta,
|
|
831
|
+
pushViolation(violationMap, entryB.label, { type: "context_mirror", target: entryA.label, overlap });
|
|
832
|
+
displayRedundancyAlert(entryA.label, entryA.meta, entryB.label, entryB.meta, overlap);
|
|
736
833
|
} else if (isGlobalSource(roleA) && roleB === "local" /* LOCAL */) {
|
|
737
834
|
redundantSet.add(entryB.label);
|
|
738
|
-
pushViolation(violationMap, entryA.label, { type: "context_mirror", target: entryB.label,
|
|
739
|
-
displayRedundancyAlert(entryB.label, entryB.meta, entryA.label, entryA.meta,
|
|
835
|
+
pushViolation(violationMap, entryA.label, { type: "context_mirror", target: entryB.label, overlap });
|
|
836
|
+
displayRedundancyAlert(entryB.label, entryB.meta, entryA.label, entryA.meta, overlap);
|
|
740
837
|
} else if (roleA === "local" /* LOCAL */ && roleB === "local" /* LOCAL */) {
|
|
741
838
|
redundantSet.add(entryA.label);
|
|
742
839
|
redundantSet.add(entryB.label);
|
|
743
|
-
pushViolation(violationMap, entryA.label, { type: "duplicate_state", target: entryB.label,
|
|
744
|
-
pushViolation(violationMap, entryB.label, { type: "duplicate_state", target: entryA.label,
|
|
745
|
-
displayRedundancyAlert(entryA.label, entryA.meta, entryB.label, entryB.meta,
|
|
840
|
+
pushViolation(violationMap, entryA.label, { type: "duplicate_state", target: entryB.label, overlap });
|
|
841
|
+
pushViolation(violationMap, entryB.label, { type: "duplicate_state", target: entryA.label, overlap });
|
|
842
|
+
displayRedundancyAlert(entryA.label, entryA.meta, entryB.label, entryB.meta, overlap);
|
|
746
843
|
}
|
|
747
844
|
};
|
|
748
845
|
var detectCausalLeak = (entryA, entryB, similarities, violationMap, graph) => {
|
|
749
846
|
if (entryA.isVolatile || entryB.isVolatile) return;
|
|
750
|
-
if (similarities.max - similarities.sync < CAUSAL_MARGIN) return;
|
|
751
847
|
const addLeak = (source, target) => {
|
|
752
848
|
if (isEventDriven(target, graph)) return;
|
|
753
|
-
|
|
754
|
-
violationMap.set(source, []);
|
|
755
|
-
}
|
|
756
|
-
violationMap.get(source).push({ type: "causal_leak", target });
|
|
849
|
+
pushViolation(violationMap, source, { type: "causal_leak", target });
|
|
757
850
|
const sourceEntry = source === entryA.label ? entryA : entryB;
|
|
758
851
|
const targetEntry = source === entryA.label ? entryB : entryA;
|
|
759
852
|
displayCausalHint(target, targetEntry.meta, source, sourceEntry.meta);
|
|
760
853
|
};
|
|
761
|
-
if (similarities.
|
|
854
|
+
if (similarities.kALeadsB >= similarities.kBLeadsA) {
|
|
762
855
|
addLeak(entryA.label, entryB.label);
|
|
763
|
-
} else
|
|
856
|
+
} else {
|
|
764
857
|
addLeak(entryB.label, entryA.label);
|
|
765
858
|
}
|
|
766
859
|
};
|
|
767
860
|
var detectSubspaceOverlap = (dirtyEntries, allEntries, redundantSet, dirtyLabels2, graph) => {
|
|
768
|
-
let compCount = 0;
|
|
769
861
|
const violationMap = /* @__PURE__ */ new Map();
|
|
862
|
+
let compCount = 0;
|
|
770
863
|
for (const entryA of dirtyEntries) {
|
|
771
864
|
for (const entryB of allEntries) {
|
|
772
865
|
if (shouldSkipComparison(entryA, entryB, dirtyLabels2)) continue;
|
|
773
866
|
compCount++;
|
|
774
867
|
const similarities = calculateAllSimilarities(entryA, entryB);
|
|
775
|
-
if (similarities.
|
|
868
|
+
if (similarities.significantSync) {
|
|
776
869
|
detectRedundancy(entryA, entryB, similarities, redundantSet, violationMap);
|
|
870
|
+
}
|
|
871
|
+
if (similarities.significantLead) {
|
|
777
872
|
detectCausalLeak(entryA, entryB, similarities, violationMap, graph);
|
|
778
873
|
}
|
|
779
874
|
}
|
|
@@ -1032,9 +1127,9 @@ var beginEffectTracking = (l) => {
|
|
|
1032
1127
|
var endEffectTracking = () => {
|
|
1033
1128
|
instance.currentEffectSource = null;
|
|
1034
1129
|
};
|
|
1035
|
-
var printBasisHealthReport = (
|
|
1130
|
+
var printBasisHealthReport = () => {
|
|
1036
1131
|
if (!instance.config.debug) return;
|
|
1037
|
-
displayHealthReport(instance.history,
|
|
1132
|
+
displayHealthReport(instance.history, instance.violationMap);
|
|
1038
1133
|
};
|
|
1039
1134
|
var getBasisMetrics = () => ({
|
|
1040
1135
|
engine: "v0.6.x",
|