@johnhenry/math 0.0.0
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 +165 -0
- package/dist/Bivector4.d.ts +52 -0
- package/dist/Bivector4.d.ts.map +1 -0
- package/dist/Bivector4.js +105 -0
- package/dist/Bivector4.js.map +1 -0
- package/dist/CellGraph.d.ts +259 -0
- package/dist/CellGraph.d.ts.map +1 -0
- package/dist/CellGraph.js +637 -0
- package/dist/CellGraph.js.map +1 -0
- package/dist/Clifford4Internal.d.ts +35 -0
- package/dist/Clifford4Internal.d.ts.map +1 -0
- package/dist/Clifford4Internal.js +86 -0
- package/dist/Clifford4Internal.js.map +1 -0
- package/dist/Combinatorics.d.ts +61 -0
- package/dist/Combinatorics.d.ts.map +1 -0
- package/dist/Combinatorics.js +186 -0
- package/dist/Combinatorics.js.map +1 -0
- package/dist/CombinatoricsGenerators.d.ts +44 -0
- package/dist/CombinatoricsGenerators.d.ts.map +1 -0
- package/dist/CombinatoricsGenerators.js +148 -0
- package/dist/CombinatoricsGenerators.js.map +1 -0
- package/dist/ComplexMath.d.ts +46 -0
- package/dist/ComplexMath.d.ts.map +1 -0
- package/dist/ComplexMath.js +98 -0
- package/dist/ComplexMath.js.map +1 -0
- package/dist/ComplexNumber.d.ts +174 -0
- package/dist/ComplexNumber.d.ts.map +1 -0
- package/dist/ComplexNumber.js +443 -0
- package/dist/ComplexNumber.js.map +1 -0
- package/dist/Cycle.d.ts +42 -0
- package/dist/Cycle.d.ts.map +1 -0
- package/dist/Cycle.js +120 -0
- package/dist/Cycle.js.map +1 -0
- package/dist/Decimal.d.ts +45 -0
- package/dist/Decimal.d.ts.map +1 -0
- package/dist/Decimal.js +135 -0
- package/dist/Decimal.js.map +1 -0
- package/dist/Distributions.d.ts +55 -0
- package/dist/Distributions.d.ts.map +1 -0
- package/dist/Distributions.js +222 -0
- package/dist/Distributions.js.map +1 -0
- package/dist/DualNumber.d.ts +42 -0
- package/dist/DualNumber.d.ts.map +1 -0
- package/dist/DualNumber.js +91 -0
- package/dist/DualNumber.js.map +1 -0
- package/dist/Environment.d.ts +44 -0
- package/dist/Environment.d.ts.map +1 -0
- package/dist/Environment.js +129 -0
- package/dist/Environment.js.map +1 -0
- package/dist/Expression.d.ts +19 -0
- package/dist/Expression.d.ts.map +1 -0
- package/dist/Expression.js +43 -0
- package/dist/Expression.js.map +1 -0
- package/dist/FFT.d.ts +21 -0
- package/dist/FFT.d.ts.map +1 -0
- package/dist/FFT.js +136 -0
- package/dist/FFT.js.map +1 -0
- package/dist/GeometricAlgebra4.d.ts +73 -0
- package/dist/GeometricAlgebra4.d.ts.map +1 -0
- package/dist/GeometricAlgebra4.js +115 -0
- package/dist/GeometricAlgebra4.js.map +1 -0
- package/dist/Geometry.d.ts +41 -0
- package/dist/Geometry.d.ts.map +1 -0
- package/dist/Geometry.js +167 -0
- package/dist/Geometry.js.map +1 -0
- package/dist/Graph.d.ts +72 -0
- package/dist/Graph.d.ts.map +1 -0
- package/dist/Graph.js +337 -0
- package/dist/Graph.js.map +1 -0
- package/dist/Graph3DUtils.d.ts +80 -0
- package/dist/Graph3DUtils.d.ts.map +1 -0
- package/dist/Graph3DUtils.js +156 -0
- package/dist/Graph3DUtils.js.map +1 -0
- package/dist/GraphUtils.d.ts +67 -0
- package/dist/GraphUtils.d.ts.map +1 -0
- package/dist/GraphUtils.js +72 -0
- package/dist/GraphUtils.js.map +1 -0
- package/dist/GroupTheory.d.ts +73 -0
- package/dist/GroupTheory.d.ts.map +1 -0
- package/dist/GroupTheory.js +194 -0
- package/dist/GroupTheory.js.map +1 -0
- package/dist/IntUtils.d.ts +33 -0
- package/dist/IntUtils.d.ts.map +1 -0
- package/dist/IntUtils.js +185 -0
- package/dist/IntUtils.js.map +1 -0
- package/dist/Interval.d.ts +44 -0
- package/dist/Interval.d.ts.map +1 -0
- package/dist/Interval.js +165 -0
- package/dist/Interval.js.map +1 -0
- package/dist/Lattice.d.ts +48 -0
- package/dist/Lattice.d.ts.map +1 -0
- package/dist/Lattice.js +65 -0
- package/dist/Lattice.js.map +1 -0
- package/dist/Logic.d.ts +22 -0
- package/dist/Logic.d.ts.map +1 -0
- package/dist/Logic.js +34 -0
- package/dist/Logic.js.map +1 -0
- package/dist/MatrixMath.d.ts +71 -0
- package/dist/MatrixMath.d.ts.map +1 -0
- package/dist/MatrixMath.js +302 -0
- package/dist/MatrixMath.js.map +1 -0
- package/dist/NumberTheory.d.ts +49 -0
- package/dist/NumberTheory.d.ts.map +1 -0
- package/dist/NumberTheory.js +230 -0
- package/dist/NumberTheory.js.map +1 -0
- package/dist/Numerical.d.ts +85 -0
- package/dist/Numerical.d.ts.map +1 -0
- package/dist/Numerical.js +343 -0
- package/dist/Numerical.js.map +1 -0
- package/dist/Permutation.d.ts +43 -0
- package/dist/Permutation.d.ts.map +1 -0
- package/dist/Permutation.js +142 -0
- package/dist/Permutation.js.map +1 -0
- package/dist/Polygon.d.ts +64 -0
- package/dist/Polygon.d.ts.map +1 -0
- package/dist/Polygon.js +221 -0
- package/dist/Polygon.js.map +1 -0
- package/dist/PolynomialRing.d.ts +74 -0
- package/dist/PolynomialRing.d.ts.map +1 -0
- package/dist/PolynomialRing.js +231 -0
- package/dist/PolynomialRing.js.map +1 -0
- package/dist/Quaternion.d.ts +37 -0
- package/dist/Quaternion.d.ts.map +1 -0
- package/dist/Quaternion.js +106 -0
- package/dist/Quaternion.js.map +1 -0
- package/dist/Rational.d.ts +34 -0
- package/dist/Rational.d.ts.map +1 -0
- package/dist/Rational.js +136 -0
- package/dist/Rational.js.map +1 -0
- package/dist/RealMath.d.ts +112 -0
- package/dist/RealMath.d.ts.map +1 -0
- package/dist/RealMath.js +262 -0
- package/dist/RealMath.js.map +1 -0
- package/dist/Rotor4.d.ts +180 -0
- package/dist/Rotor4.d.ts.map +1 -0
- package/dist/Rotor4.js +388 -0
- package/dist/Rotor4.js.map +1 -0
- package/dist/SpecialFunctions.d.ts +32 -0
- package/dist/SpecialFunctions.d.ts.map +1 -0
- package/dist/SpecialFunctions.js +161 -0
- package/dist/SpecialFunctions.js.map +1 -0
- package/dist/SpecialOperator.d.ts +21 -0
- package/dist/SpecialOperator.d.ts.map +1 -0
- package/dist/SpecialOperator.js +26 -0
- package/dist/SpecialOperator.js.map +1 -0
- package/dist/Statistics.d.ts +50 -0
- package/dist/Statistics.d.ts.map +1 -0
- package/dist/Statistics.js +131 -0
- package/dist/Statistics.js.map +1 -0
- package/dist/StringEvaluator.d.ts +48 -0
- package/dist/StringEvaluator.d.ts.map +1 -0
- package/dist/StringEvaluator.js +190 -0
- package/dist/StringEvaluator.js.map +1 -0
- package/dist/Structure.d.ts +130 -0
- package/dist/Structure.d.ts.map +1 -0
- package/dist/Structure.js +394 -0
- package/dist/Structure.js.map +1 -0
- package/dist/Symbolic.d.ts +501 -0
- package/dist/Symbolic.d.ts.map +1 -0
- package/dist/Symbolic.js +4077 -0
- package/dist/Symbolic.js.map +1 -0
- package/dist/Type.d.ts +31 -0
- package/dist/Type.d.ts.map +1 -0
- package/dist/Type.js +47 -0
- package/dist/Type.js.map +1 -0
- package/dist/Utilities.d.ts +27 -0
- package/dist/Utilities.d.ts.map +1 -0
- package/dist/Utilities.js +47 -0
- package/dist/Utilities.js.map +1 -0
- package/dist/Vec4.d.ts +39 -0
- package/dist/Vec4.d.ts.map +1 -0
- package/dist/Vec4.js +82 -0
- package/dist/Vec4.js.map +1 -0
- package/dist/Vector.d.ts +83 -0
- package/dist/Vector.d.ts.map +1 -0
- package/dist/Vector.js +173 -0
- package/dist/Vector.js.map +1 -0
- package/dist/VectorCalculus.d.ts +42 -0
- package/dist/VectorCalculus.d.ts.map +1 -0
- package/dist/VectorCalculus.js +82 -0
- package/dist/VectorCalculus.js.map +1 -0
- package/dist/VectorUtils.d.ts +172 -0
- package/dist/VectorUtils.d.ts.map +1 -0
- package/dist/VectorUtils.js +706 -0
- package/dist/VectorUtils.js.map +1 -0
- package/dist/differentiation-rules.d.ts +21 -0
- package/dist/differentiation-rules.d.ts.map +1 -0
- package/dist/differentiation-rules.js +52 -0
- package/dist/differentiation-rules.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
- package/src/Bivector4.ts +136 -0
- package/src/CellGraph.ts +669 -0
- package/src/Clifford4Internal.ts +90 -0
- package/src/Combinatorics.ts +183 -0
- package/src/CombinatoricsGenerators.ts +144 -0
- package/src/ComplexMath.ts +162 -0
- package/src/ComplexNumber.ts +491 -0
- package/src/Cycle.ts +126 -0
- package/src/Decimal.ts +145 -0
- package/src/Distributions.ts +269 -0
- package/src/DualNumber.ts +105 -0
- package/src/Environment.ts +127 -0
- package/src/Expression.ts +48 -0
- package/src/FFT.ts +146 -0
- package/src/GeometricAlgebra4.ts +157 -0
- package/src/Geometry.ts +173 -0
- package/src/Graph.ts +337 -0
- package/src/Graph3DUtils.ts +269 -0
- package/src/GraphUtils.ts +159 -0
- package/src/GroupTheory.ts +214 -0
- package/src/IntUtils.ts +200 -0
- package/src/Interval.ts +171 -0
- package/src/Lattice.ts +87 -0
- package/src/Logic.ts +38 -0
- package/src/MatrixMath.ts +326 -0
- package/src/NumberTheory.ts +228 -0
- package/src/Numerical.ts +390 -0
- package/src/Permutation.ts +148 -0
- package/src/Polygon.ts +229 -0
- package/src/PolynomialRing.ts +242 -0
- package/src/Quaternion.ts +125 -0
- package/src/Rational.ts +148 -0
- package/src/RealMath.ts +346 -0
- package/src/Rotor4.ts +440 -0
- package/src/SpecialFunctions.ts +152 -0
- package/src/SpecialOperator.ts +28 -0
- package/src/Statistics.ts +170 -0
- package/src/StringEvaluator.ts +208 -0
- package/src/Structure.ts +481 -0
- package/src/Symbolic.ts +4313 -0
- package/src/Type.ts +48 -0
- package/src/Utilities.ts +49 -0
- package/src/Vec4.ts +98 -0
- package/src/Vector.ts +189 -0
- package/src/VectorCalculus.ts +87 -0
- package/src/VectorUtils.ts +797 -0
- package/src/differentiation-rules.ts +73 -0
- package/src/index.ts +140 -0
package/src/CellGraph.ts
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CellGraph — a reactive, pull-based dependency graph. Cells hold typed
|
|
3
|
+
* values (numbers, points, curves, whatever) rather than flattened
|
|
4
|
+
* primitives; a derived cell's `compute` reads other cells via `graph.get`,
|
|
5
|
+
* and the graph records the resulting edges automatically (no manual tag
|
|
6
|
+
* declarations, unlike mcp-query's cache).
|
|
7
|
+
*
|
|
8
|
+
* Writes (`set`) mark transitive dependents dirty and bump their version
|
|
9
|
+
* counters immediately — cheap, no recompute. Recomputation happens lazily,
|
|
10
|
+
* the next time a dirty cell is `get`, and a structural-equality check skips
|
|
11
|
+
* the version bump (and further propagation) when a recompute produces a
|
|
12
|
+
* value that's deep-equal to what was already cached, so unaffected
|
|
13
|
+
* downstream consumers don't re-render.
|
|
14
|
+
*
|
|
15
|
+
* Promoted here from johnhenry/mallory (originally `src/lib/cell-
|
|
16
|
+
* graph.ts`, powering every panel there via `useCell`/`useSyncExternalStore`)
|
|
17
|
+
* once johnhenry/math-grapher became a second consumer of a frozen
|
|
18
|
+
* vendored copy — two consumers is this monorepo's own extract-to-a-package
|
|
19
|
+
* trigger. See johnhenry/math#56. UI-framework-agnostic and dependency-
|
|
20
|
+
* free by design: nothing here references React or any host application.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type Listener = () => void;
|
|
24
|
+
type ComputeFn<T> = () => T;
|
|
25
|
+
|
|
26
|
+
interface CellRecord<T = unknown> {
|
|
27
|
+
value: T | undefined;
|
|
28
|
+
hasValue: boolean;
|
|
29
|
+
version: number;
|
|
30
|
+
dirty: boolean;
|
|
31
|
+
compute?: ComputeFn<T>;
|
|
32
|
+
dependencies: Set<string>;
|
|
33
|
+
dependents: Set<string>;
|
|
34
|
+
auxiliary: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Set when `compute` most recently threw, so a failing (possibly
|
|
37
|
+
* expensive) compute doesn't get re-run on every subsequent `get()` --
|
|
38
|
+
* see {@link CellGraph.get} and {@link CellGraph.recomputeAndEmit}.
|
|
39
|
+
* Cleared by anything that also clears `dirty`'s stale-value equivalent:
|
|
40
|
+
* a fresh `set()`/successful recompute.
|
|
41
|
+
*/
|
|
42
|
+
hasError: boolean;
|
|
43
|
+
error?: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type CellRole = "free" | "dependent" | "unknown";
|
|
47
|
+
|
|
48
|
+
export class CircularDependencyError extends Error {
|
|
49
|
+
constructor(path: string[]) {
|
|
50
|
+
super(`Circular dependency detected in cell graph: ${path.join(" -> ")}`);
|
|
51
|
+
this.name = "CircularDependencyError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class CellGraph {
|
|
56
|
+
private cells = new Map<string, CellRecord>();
|
|
57
|
+
private listeners = new Map<string, Set<Listener>>();
|
|
58
|
+
private globalListeners = new Set<Listener>();
|
|
59
|
+
private writeListeners = new Set<(id: string, value: unknown) => void>();
|
|
60
|
+
private stack: string[] = [];
|
|
61
|
+
private emitting = new Set<string>();
|
|
62
|
+
/**
|
|
63
|
+
* Nesting depth of the current "logical write" -- incremented around the
|
|
64
|
+
* body of every top-level mutating entry point ({@link set}, {@link
|
|
65
|
+
* define}, {@link delete}) and decremented in a `finally` when it returns.
|
|
66
|
+
* A single `set()` on a root of a diamond-shaped dependency graph cascades
|
|
67
|
+
* through `propagateDirty` and calls `emit()` once per cell it reaches
|
|
68
|
+
* (root, then each newly-dirtied dependent) -- all of those belong to the
|
|
69
|
+
* SAME logical write and must produce exactly one `subscribeAll`
|
|
70
|
+
* notification, not one per cell. While this is above zero, a request to
|
|
71
|
+
* notify global listeners (see {@link scheduleGlobalNotify}) is deferred
|
|
72
|
+
* instead of firing immediately; it only actually fires once depth
|
|
73
|
+
* unwinds back to zero, i.e. once the outermost `set`/`define`/`delete`
|
|
74
|
+
* call (including any cells it transitively dirtied) has fully finished.
|
|
75
|
+
*/
|
|
76
|
+
private globalNotifyDepth = 0;
|
|
77
|
+
/** Set by {@link scheduleGlobalNotify} while `globalNotifyDepth > 0`, so the deferred notification isn't lost once depth returns to zero. */
|
|
78
|
+
private globalNotifyPending = false;
|
|
79
|
+
/**
|
|
80
|
+
* Guards the global-listener-invocation loop itself against being
|
|
81
|
+
* re-entered while it's already running -- e.g. a global listener that
|
|
82
|
+
* synchronously calls `set()` on a DIFFERENT cell from inside its own
|
|
83
|
+
* callback. Unlike the shared boolean this replaces, a reentrant request
|
|
84
|
+
* that arrives while this is `true` is NOT dropped: it re-arms {@link
|
|
85
|
+
* globalNotifyPending}, which is drained (fired again) right after the
|
|
86
|
+
* in-progress loop finishes -- see {@link flushGlobalNotify}. This is what
|
|
87
|
+
* keeps a reentrant write to a different cell from being silently
|
|
88
|
+
* swallowed (issue #234), while still bounding recursion depth to one
|
|
89
|
+
* extra level per reentrant write, the same way `emitting` bounds it for
|
|
90
|
+
* same-id reentrancy on local listeners.
|
|
91
|
+
*/
|
|
92
|
+
private notifyingGlobalListeners = false;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Write a raw source-of-truth value (e.g. a slider drag or text input).
|
|
96
|
+
* Structurally, a cell written via `set` (no `compute` fn) is what makes
|
|
97
|
+
* it "free" -- see {@link role}.
|
|
98
|
+
*
|
|
99
|
+
* @param options.auxiliary Marks this cell hidden-by-default in an
|
|
100
|
+
* Algebra-view-style listing (see {@link list}) -- e.g. an internal
|
|
101
|
+
* sampling parameter that isn't itself meaningful to show the user,
|
|
102
|
+
* distinct from a top-level named object. Only applied when the cell
|
|
103
|
+
* doesn't already exist, or is transitioning from a compute-backed
|
|
104
|
+
* (dependent) cell to a free one; an existing free cell's auxiliary flag
|
|
105
|
+
* is left as originally set.
|
|
106
|
+
*/
|
|
107
|
+
set<T>(id: string, value: T, options?: { auxiliary?: boolean }): void {
|
|
108
|
+
this.runBatched(() => {
|
|
109
|
+
const cell = this.ensure<T>(id);
|
|
110
|
+
const wasCompute = cell.compute !== undefined;
|
|
111
|
+
cell.compute = undefined;
|
|
112
|
+
if (wasCompute) {
|
|
113
|
+
// This cell is transitioning from dependent (had a compute fn) to
|
|
114
|
+
// free -- detach its recorded dependency edges the same way
|
|
115
|
+
// recomputeAndEmit() and delete() already do, so former upstream
|
|
116
|
+
// cells stop spuriously dirtying/emitting a cell they no longer feed
|
|
117
|
+
// into.
|
|
118
|
+
for (const depId of cell.dependencies) this.cells.get(depId)?.dependents.delete(id);
|
|
119
|
+
cell.dependencies = new Set();
|
|
120
|
+
}
|
|
121
|
+
if (options?.auxiliary !== undefined && (wasCompute || !cell.hasValue)) cell.auxiliary = options.auxiliary;
|
|
122
|
+
cell.hasError = false;
|
|
123
|
+
cell.error = undefined;
|
|
124
|
+
const unchanged = cell.hasValue && structuralEqual(cell.value, value);
|
|
125
|
+
cell.dirty = false;
|
|
126
|
+
if (unchanged) return;
|
|
127
|
+
// Only replace the cached reference on a real change, so a write that's
|
|
128
|
+
// structurally equal to the old value never disturbs downstream identity.
|
|
129
|
+
cell.value = value;
|
|
130
|
+
cell.hasValue = true;
|
|
131
|
+
cell.version++;
|
|
132
|
+
this.emitWrite(id, value);
|
|
133
|
+
this.emit(id);
|
|
134
|
+
this.propagateDirty(id);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Subscribe to raw `set()` writes -- id plus the new value -- across
|
|
140
|
+
* every cell in the graph (issue #47's live-collaboration groundwork).
|
|
141
|
+
* Deliberately narrower than `subscribeAll`: it fires ONLY for an actual
|
|
142
|
+
* `set()` call with a real (structurally-different) value, never for a
|
|
143
|
+
* `define()`-driven recompute cascade. That's the exact shape a
|
|
144
|
+
* broadcast-to-other-peers use case needs -- each peer re-derives its
|
|
145
|
+
* own dependent cells locally once a base write arrives over the wire
|
|
146
|
+
* and gets applied via `set()` there too, so only the raw user-initiated
|
|
147
|
+
* writes need to cross the network, not every recomputed cell downstream
|
|
148
|
+
* of them.
|
|
149
|
+
*/
|
|
150
|
+
subscribeWrites(fn: (id: string, value: unknown) => void): () => void {
|
|
151
|
+
this.writeListeners.add(fn);
|
|
152
|
+
return () => this.writeListeners.delete(fn);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private emitWrite(id: string, value: unknown): void {
|
|
156
|
+
for (const fn of this.writeListeners) fn(id, value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Define (or redefine) a derived cell computed from other cells.
|
|
161
|
+
* Structurally, a cell written via `define` (has a `compute` fn) is what
|
|
162
|
+
* makes it "dependent" -- see {@link role}.
|
|
163
|
+
*
|
|
164
|
+
* @param options.auxiliary See {@link set}'s equivalent option; applied
|
|
165
|
+
* the same way (only on first definition, or a transition from free to
|
|
166
|
+
* dependent).
|
|
167
|
+
*/
|
|
168
|
+
define<T>(id: string, compute: ComputeFn<T>, options?: { auxiliary?: boolean }): void {
|
|
169
|
+
this.runBatched(() => this.defineImpl(id, compute, options));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private defineImpl<T>(id: string, compute: ComputeFn<T>, options?: { auxiliary?: boolean }): void {
|
|
173
|
+
const cell = this.ensure<T>(id);
|
|
174
|
+
const wasFree = cell.compute === undefined && cell.hasValue;
|
|
175
|
+
const isRedefine = cell.hasValue;
|
|
176
|
+
if (options?.auxiliary !== undefined && (wasFree || cell.compute === undefined)) cell.auxiliary = options.auxiliary;
|
|
177
|
+
cell.compute = compute;
|
|
178
|
+
cell.dirty = true;
|
|
179
|
+
cell.hasError = false;
|
|
180
|
+
cell.error = undefined;
|
|
181
|
+
// A cell being defined for the first time (no prior value) is
|
|
182
|
+
// guaranteed to change once computed -- hasValue flips false -> true
|
|
183
|
+
// regardless of what the compute returns -- so it's safe to eagerly
|
|
184
|
+
// bump its version and notify its own subscribers now, same as before,
|
|
185
|
+
// with no need to actually run the compute early.
|
|
186
|
+
//
|
|
187
|
+
// A cell that already has a value is being *redefined*: unlike set(),
|
|
188
|
+
// define() doesn't have the new value in hand to structuralEqual-check
|
|
189
|
+
// synchronously -- only a function. The fix for #15 is NOT "defer the
|
|
190
|
+
// notification to some future get()": nothing guarantees a future
|
|
191
|
+
// get() ever happens. useSyncExternalStore's subscribe callback (see
|
|
192
|
+
// use-cell.ts) is what schedules a component's next render in the
|
|
193
|
+
// first place, and that callback only fires from emit() -- so an
|
|
194
|
+
// un-emitted genuine change would sit invisible until something
|
|
195
|
+
// UNRELATED happens to re-render the same subscribed component. That's
|
|
196
|
+
// a missed update, strictly worse than the unwanted-churn bug #15 set
|
|
197
|
+
// out to fix. Instead, resolve it the same way set() does: eagerly,
|
|
198
|
+
// synchronously, right here -- via recomputeAndEmit, which already
|
|
199
|
+
// implements exactly the "recompute, structuralEqual-gate the version
|
|
200
|
+
// bump/emit" logic this needs (and already detaches/rebuilds
|
|
201
|
+
// `dependencies` correctly as part of that). A throwing new compute is
|
|
202
|
+
// swallowed here (recomputeAndEmit already cached it on the cell via
|
|
203
|
+
// #14's hasError/error fields before rethrowing) so define() keeps its
|
|
204
|
+
// pre-existing never-throws-synchronously contract; the cached error
|
|
205
|
+
// surfaces through the normal get()-throws path on the next read.
|
|
206
|
+
if (isRedefine) {
|
|
207
|
+
try {
|
|
208
|
+
this.recomputeAndEmit(id, cell);
|
|
209
|
+
} catch {
|
|
210
|
+
// Cached on the cell already; surfaced via get(), not here.
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
cell.version++;
|
|
214
|
+
this.emit(id);
|
|
215
|
+
}
|
|
216
|
+
this.propagateDirty(id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Whether `id` is "free" (writable directly via `set`, no `compute` fn --
|
|
221
|
+
* e.g. a slider or text input), "dependent" (computed via `define` from
|
|
222
|
+
* other cells), or "unknown" (`id` has never been `set`/`define`d, only
|
|
223
|
+
* read, or doesn't exist at all).
|
|
224
|
+
*/
|
|
225
|
+
role(id: string): CellRole {
|
|
226
|
+
const cell = this.cells.get(id);
|
|
227
|
+
if (!cell?.hasValue) return "unknown";
|
|
228
|
+
return cell.compute ? "dependent" : "free";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Whether `id` was marked `auxiliary` (hidden-by-default in an Algebra-view-style listing) -- see {@link set}/{@link define}. */
|
|
232
|
+
isAuxiliary(id: string): boolean {
|
|
233
|
+
return this.cells.get(id)?.auxiliary ?? false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every cell currently in the graph, with its role and auxiliary flag --
|
|
238
|
+
* the basis for an Algebra-view-style listing (GeoGebra's free/dependent/
|
|
239
|
+
* auxiliary object model). Includes cells with no value yet (role
|
|
240
|
+
* "unknown") since a caller may still want to know such an id exists
|
|
241
|
+
* (e.g. was read but never written).
|
|
242
|
+
*/
|
|
243
|
+
list(): Array<{ id: string; role: CellRole; auxiliary: boolean; hasValue: boolean }> {
|
|
244
|
+
return [...this.cells.entries()].map(([id, cell]) => ({
|
|
245
|
+
id,
|
|
246
|
+
role: this.role(id),
|
|
247
|
+
auxiliary: cell.auxiliary,
|
|
248
|
+
hasValue: cell.hasValue,
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Read a cell's current value, recomputing if stale. Auto-tracks dependency edges. */
|
|
253
|
+
get<T>(id: string): T {
|
|
254
|
+
const cell = this.ensure<T>(id);
|
|
255
|
+
|
|
256
|
+
// The cell currently being computed (if any) reads `id` — record the edge.
|
|
257
|
+
const caller = this.stack.at(-1);
|
|
258
|
+
if (caller !== undefined) {
|
|
259
|
+
this.cells.get(caller)?.dependencies.add(id);
|
|
260
|
+
cell.dependents.add(caller);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (cell.dirty && cell.compute) {
|
|
264
|
+
if (this.stack.includes(id)) throw new CircularDependencyError([...this.stack, id]);
|
|
265
|
+
|
|
266
|
+
this.recomputeAndEmit(id, cell);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// A cached compute failure (see recomputeAndEmit's catch) is rethrown
|
|
270
|
+
// here on every subsequent get() -- without re-running the compute --
|
|
271
|
+
// until the same conditions that would invalidate a cached value (a
|
|
272
|
+
// fresh set()/define(), or a real upstream change marking this cell
|
|
273
|
+
// dirty again) clear it.
|
|
274
|
+
if (cell.hasError) throw cell.error;
|
|
275
|
+
|
|
276
|
+
return cell.value as T;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private recomputeAndEmit<T>(id: string, cell: CellRecord<T>): void {
|
|
280
|
+
// Dependencies may differ between evaluations (e.g. a conditional
|
|
281
|
+
// expression) — detach from the old set before recomputing fresh.
|
|
282
|
+
for (const depId of cell.dependencies) this.cells.get(depId)?.dependents.delete(id);
|
|
283
|
+
cell.dependencies = new Set();
|
|
284
|
+
|
|
285
|
+
this.stack.push(id);
|
|
286
|
+
let next: T;
|
|
287
|
+
try {
|
|
288
|
+
next = cell.compute!() as T;
|
|
289
|
+
} catch (err) {
|
|
290
|
+
// Cache the failure so a subsequent get() (another render pass, a
|
|
291
|
+
// sibling compute reading this cell again, etc.) rethrows the same
|
|
292
|
+
// cached error instead of re-running a compute that's going to fail
|
|
293
|
+
// identically. `dirty` is still cleared here: this recompute attempt
|
|
294
|
+
// did happen and did resolve (to a failure), and staying dirty would
|
|
295
|
+
// otherwise force every future get() back through the compute anyway.
|
|
296
|
+
cell.dirty = false;
|
|
297
|
+
cell.hasError = true;
|
|
298
|
+
cell.error = err;
|
|
299
|
+
throw err;
|
|
300
|
+
} finally {
|
|
301
|
+
this.stack.pop();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
cell.dirty = false;
|
|
305
|
+
cell.hasError = false;
|
|
306
|
+
cell.error = undefined;
|
|
307
|
+
|
|
308
|
+
const unchanged = cell.hasValue && structuralEqual(cell.value, next);
|
|
309
|
+
|
|
310
|
+
if (!unchanged) {
|
|
311
|
+
// Reassign only on a real change, preserving the old reference on a
|
|
312
|
+
// no-op recompute — this is what lets a downstream Object.is check
|
|
313
|
+
// (e.g. React's useSyncExternalStore, or React.memo) bail out.
|
|
314
|
+
cell.value = next;
|
|
315
|
+
cell.hasValue = true;
|
|
316
|
+
cell.version++;
|
|
317
|
+
this.emit(id);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The value useSyncExternalStore observes for this cell. */
|
|
322
|
+
getVersion(id: string): number {
|
|
323
|
+
return this.cells.get(id)?.version ?? 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Subscribe to changes on one cell. Returns an unsubscribe function. */
|
|
327
|
+
subscribe(id: string, fn: Listener): () => void {
|
|
328
|
+
let set = this.listeners.get(id);
|
|
329
|
+
if (!set) {
|
|
330
|
+
set = new Set();
|
|
331
|
+
this.listeners.set(id, set);
|
|
332
|
+
}
|
|
333
|
+
set.add(fn);
|
|
334
|
+
this.ensure(id);
|
|
335
|
+
return () => {
|
|
336
|
+
set.delete(fn);
|
|
337
|
+
if (set.size !== 0) return;
|
|
338
|
+
// Last subscriber for this id gone -- drop the now-empty Set instead
|
|
339
|
+
// of leaving it behind forever (an id that's subscribed-to-then-
|
|
340
|
+
// unsubscribed-from many times over a session, e.g. a dynamically
|
|
341
|
+
// created/destroyed notebook row, would otherwise accumulate one dead
|
|
342
|
+
// empty Set per id in `listeners`).
|
|
343
|
+
this.listeners.delete(id);
|
|
344
|
+
// If this cell was never actually given a value or a compute (i.e.
|
|
345
|
+
// the only reason it exists in `cells` at all is the ensure() call a
|
|
346
|
+
// few lines up) and nothing else in the graph references it (no
|
|
347
|
+
// dependents, no recorded dependencies), there's nothing left to
|
|
348
|
+
// justify keeping that phantom record around either -- clean it up
|
|
349
|
+
// too. A genuinely "live" cell (has a real value, is define()d, or
|
|
350
|
+
// participates in the dependency graph as someone's dependency/
|
|
351
|
+
// dependent) is untouched here; only explicit delete() removes those,
|
|
352
|
+
// per its own documented semantics -- this is deliberately narrower
|
|
353
|
+
// than delete() and never fires the "former dependents" notification
|
|
354
|
+
// delete() does, since there's no real removal of live state here.
|
|
355
|
+
const cell = this.cells.get(id);
|
|
356
|
+
if (
|
|
357
|
+
cell &&
|
|
358
|
+
!cell.hasValue &&
|
|
359
|
+
cell.compute === undefined &&
|
|
360
|
+
cell.dependents.size === 0 &&
|
|
361
|
+
cell.dependencies.size === 0
|
|
362
|
+
) {
|
|
363
|
+
this.cells.delete(id);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Subscribe to changes on any cell (e.g. to drive a canvas render loop). */
|
|
369
|
+
subscribeAll(fn: Listener): () => void {
|
|
370
|
+
this.globalListeners.add(fn);
|
|
371
|
+
return () => this.globalListeners.delete(fn);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Subscribe to changes on a fixed, known set of specific cells -- e.g.
|
|
376
|
+
* every cell a serialized URL fragment reads. Narrower than
|
|
377
|
+
* `subscribeAll`: `fn` fires only for a write on one of `ids`, not for
|
|
378
|
+
* literally any cell in the graph (issue #235 -- several panels' URL-sync
|
|
379
|
+
* effects used `subscribeAll` for exactly this "a handful of specific
|
|
380
|
+
* cells" shape, so they also fired on unrelated high-frequency writes
|
|
381
|
+
* elsewhere in the same graph -- an RAF-driven playback clock, a live
|
|
382
|
+
* drag preview, a per-epoch training metric -- that the listener's own
|
|
383
|
+
* output never actually depended on). Just sugar over one `subscribe`
|
|
384
|
+
* call per id; not appropriate when the relevant id set is itself dynamic
|
|
385
|
+
* (e.g. "every currently-visible row's cells", where the row list can
|
|
386
|
+
* grow/shrink) -- that shape still needs `subscribeAll` (ideally
|
|
387
|
+
* debounced) or per-id `subscribe` calls re-issued as the id set changes.
|
|
388
|
+
*/
|
|
389
|
+
subscribeMany(ids: string[], fn: Listener): () => void {
|
|
390
|
+
const unsubscribes = ids.map((id) => this.subscribe(id, fn));
|
|
391
|
+
return () => {
|
|
392
|
+
for (const unsubscribe of unsubscribes) unsubscribe();
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
has(id: string): boolean {
|
|
397
|
+
return this.cells.has(id);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether `id` has a real value yet, as opposed to merely existing as an
|
|
402
|
+
* empty record (`has()` returns true for a cell the instant anything reads
|
|
403
|
+
* it via `get`, even before it's ever been `set` or `define`d -- not a
|
|
404
|
+
* reliable "should I seed this?" check from a post-render effect that runs
|
|
405
|
+
* after a sibling compute has already read-and-thus-created it).
|
|
406
|
+
*/
|
|
407
|
+
hasValue(id: string): boolean {
|
|
408
|
+
return this.cells.get(id)?.hasValue ?? false;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Remove a cell entirely. Former *dependents* are marked dirty and
|
|
413
|
+
* notified (same as a `set()` would), so a compute that read the deleted
|
|
414
|
+
* cell re-runs and can fall back to whatever "this cell doesn't exist"
|
|
415
|
+
* means for it -- without this, a dependent kept its stale cached value
|
|
416
|
+
* forever, AND (because its dependency edges only rebuild during a
|
|
417
|
+
* recompute that never came) writes to its other dependencies stopped
|
|
418
|
+
* reaching it too. The notification happens *after* the cell is gone, so
|
|
419
|
+
* the reentrant recompute a listener may trigger sees the post-delete
|
|
420
|
+
* world: a `get()` on the deleted id re-creates an empty record
|
|
421
|
+
* (`hasValue: false`), exactly the "never existed" semantics callers
|
|
422
|
+
* like ExpressionRow's params compute already handle.
|
|
423
|
+
*/
|
|
424
|
+
delete(id: string): void {
|
|
425
|
+
this.runBatched(() => {
|
|
426
|
+
const cell = this.cells.get(id);
|
|
427
|
+
if (!cell) return;
|
|
428
|
+
const formerDependents = [...cell.dependents];
|
|
429
|
+
for (const depId of cell.dependencies) this.cells.get(depId)?.dependents.delete(id);
|
|
430
|
+
for (const depId of cell.dependents) this.cells.get(depId)?.dependencies.delete(id);
|
|
431
|
+
this.cells.delete(id);
|
|
432
|
+
this.listeners.delete(id);
|
|
433
|
+
for (const depId of formerDependents) {
|
|
434
|
+
const dep = this.cells.get(depId);
|
|
435
|
+
if (!dep || dep.dirty) continue;
|
|
436
|
+
dep.dirty = true;
|
|
437
|
+
this.emit(depId);
|
|
438
|
+
this.propagateDirty(depId);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private ensure<T>(id: string): CellRecord<T> {
|
|
444
|
+
let cell = this.cells.get(id) as CellRecord<T> | undefined;
|
|
445
|
+
if (!cell) {
|
|
446
|
+
cell = {
|
|
447
|
+
value: undefined,
|
|
448
|
+
hasValue: false,
|
|
449
|
+
version: 0,
|
|
450
|
+
dirty: true,
|
|
451
|
+
dependencies: new Set(),
|
|
452
|
+
dependents: new Set(),
|
|
453
|
+
auxiliary: false,
|
|
454
|
+
hasError: false,
|
|
455
|
+
error: undefined,
|
|
456
|
+
};
|
|
457
|
+
this.cells.set(id, cell);
|
|
458
|
+
}
|
|
459
|
+
return cell;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private propagateDirty(id: string): void {
|
|
463
|
+
const cell = this.cells.get(id);
|
|
464
|
+
if (!cell) return;
|
|
465
|
+
// Snapshot before iterating: `emit` below can synchronously trigger a
|
|
466
|
+
// nested recompute (via useSyncExternalStore's listener) that detaches
|
|
467
|
+
// and re-adds an entry to this very `dependents` set. Iterating the live
|
|
468
|
+
// Set would then revisit that re-added entry within the same pass,
|
|
469
|
+
// looping forever between cells that share this dependency.
|
|
470
|
+
for (const depId of [...cell.dependents]) {
|
|
471
|
+
const dep = this.cells.get(depId);
|
|
472
|
+
if (!dep || dep.dirty) continue; // already dirty -> already propagated past this point
|
|
473
|
+
dep.dirty = true;
|
|
474
|
+
// Notify listeners so a subscriber (e.g. useSyncExternalStore) re-reads
|
|
475
|
+
// via get(), which lazily recomputes. No version bump here — that only
|
|
476
|
+
// happens in get()/set() once a recompute confirms a real value change,
|
|
477
|
+
// which is what lets an unaffected downstream branch skip its redraw.
|
|
478
|
+
this.emit(depId);
|
|
479
|
+
this.propagateDirty(depId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Notify `id`'s listeners that it may have changed. Guarded against
|
|
485
|
+
* reentrancy per-id: a `useSyncExternalStore` listener synchronously
|
|
486
|
+
* calls `getSnapshot` (React's own tearing check) as soon as it's
|
|
487
|
+
* notified, which re-enters `get()` and, on a real recompute, calls back
|
|
488
|
+
* into `emit(id)` for the very same id before this call has returned.
|
|
489
|
+
* Without the guard that nested call re-invokes every listener again
|
|
490
|
+
* (including the one currently on the stack), which re-triggers the same
|
|
491
|
+
* reentrant read, forever -- an unbounded synchronous storm that pins the
|
|
492
|
+
* CPU and eventually OOMs the JS heap. It's always safe to drop the
|
|
493
|
+
* nested notification: any consumer notified mid-flight still reads the
|
|
494
|
+
* freshest value the next time it calls `get()`, so no update is lost by
|
|
495
|
+
* collapsing repeat notifications for the same id into one.
|
|
496
|
+
*
|
|
497
|
+
* The global (`subscribeAll`) notification is requested here but not
|
|
498
|
+
* fired directly -- see {@link scheduleGlobalNotify}: a single logical
|
|
499
|
+
* write (e.g. one `set()` on the root of a diamond-shaped dependency
|
|
500
|
+
* graph) calls `emit()` once per cell it transitively dirties, but must
|
|
501
|
+
* still only produce ONE `subscribeAll` notification (issue #234).
|
|
502
|
+
*/
|
|
503
|
+
private emit(id: string): void {
|
|
504
|
+
if (this.emitting.has(id)) return;
|
|
505
|
+
this.emitting.add(id);
|
|
506
|
+
try {
|
|
507
|
+
for (const fn of this.listeners.get(id) ?? []) fn();
|
|
508
|
+
this.scheduleGlobalNotify();
|
|
509
|
+
} finally {
|
|
510
|
+
this.emitting.delete(id);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Run `fn` as one "logical write" for the purposes of global-listener
|
|
516
|
+
* batching -- see {@link globalNotifyDepth}. Reentrant calls (e.g. a
|
|
517
|
+
* `subscribe()` listener triggered mid-cascade that synchronously calls
|
|
518
|
+
* `get()`, which recomputes and dirties further cells) nest transparently:
|
|
519
|
+
* only the outermost call's `finally` can bring the depth back to zero and
|
|
520
|
+
* trigger the deferred flush.
|
|
521
|
+
*/
|
|
522
|
+
private runBatched<T>(fn: () => T): T {
|
|
523
|
+
this.globalNotifyDepth++;
|
|
524
|
+
try {
|
|
525
|
+
return fn();
|
|
526
|
+
} finally {
|
|
527
|
+
this.globalNotifyDepth--;
|
|
528
|
+
if (this.globalNotifyDepth === 0 && this.globalNotifyPending) this.flushGlobalNotify();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Run `fn`, a sequence of otherwise-independent `set`/`define`/`delete`
|
|
534
|
+
* calls, as a SINGLE logical write for `subscribeAll` purposes (see
|
|
535
|
+
* {@link runBatched}/{@link scheduleGlobalNotify}). Without this, a
|
|
536
|
+
* multi-step mutation like a clear-then-replay (#374/#375) fires a
|
|
537
|
+
* `subscribeAll` notification after each individual call, so a listener
|
|
538
|
+
* that reads back across multiple cells (e.g. a canvas redraw walking an
|
|
539
|
+
* object-list cell and dereferencing each entry's point cells) can
|
|
540
|
+
* observe a transient state mid-sequence -- e.g. an id still listed in
|
|
541
|
+
* one cell whose corresponding point cell has already been deleted by an
|
|
542
|
+
* earlier call in the same `fn`. Wrapping the whole sequence here defers
|
|
543
|
+
* every `subscribeAll` notification until `fn` fully returns, so
|
|
544
|
+
* listeners only ever see the state before or after, never in between.
|
|
545
|
+
*/
|
|
546
|
+
transaction<T>(fn: () => T): T {
|
|
547
|
+
return this.runBatched(fn);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Request a `subscribeAll` notification. While a logical write is still
|
|
552
|
+
* in progress ({@link globalNotifyDepth} > 0), the request is only
|
|
553
|
+
* recorded ({@link globalNotifyPending}) -- {@link runBatched} flushes it
|
|
554
|
+
* once that write (including everything it cascaded into) fully unwinds.
|
|
555
|
+
* Outside of any write (e.g. a standalone `get()` that lazily recomputes a
|
|
556
|
+
* dirty cell with no `set`/`define`/`delete` on the stack), there's no
|
|
557
|
+
* outer call to flush later, so it fires immediately.
|
|
558
|
+
*/
|
|
559
|
+
private scheduleGlobalNotify(): void {
|
|
560
|
+
this.globalNotifyPending = true;
|
|
561
|
+
if (this.globalNotifyDepth === 0) this.flushGlobalNotify();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Actually invoke every `subscribeAll` listener, once, for the batch of
|
|
566
|
+
* cell changes accumulated since the last flush.
|
|
567
|
+
*
|
|
568
|
+
* Guarded against reentering the listener-invocation loop itself --
|
|
569
|
+
* `notifyingGlobalListeners`, scoped to "is a global-listener loop
|
|
570
|
+
* currently on the stack" rather than per-cell-id, because by this point
|
|
571
|
+
* the notification is no longer about any single cell; it's "this batch
|
|
572
|
+
* of writes changed something." A listener that synchronously starts a
|
|
573
|
+
* NEW logical write (e.g. `set()` on a different cell) re-arms {@link
|
|
574
|
+
* globalNotifyPending} instead of recursing into a second, nested
|
|
575
|
+
* invocation of every listener -- that pending flag is drained in the
|
|
576
|
+
* `while` loop below once the in-progress loop returns, so that second
|
|
577
|
+
* write's notification still fires (once), it's just deferred to right
|
|
578
|
+
* after the first finishes rather than dropped. This is what fixes issue
|
|
579
|
+
* #234's bug 2: the previous shared `notifyingGlobal` boolean guard
|
|
580
|
+
* skipped (silently discarded) exactly this case instead of deferring it.
|
|
581
|
+
*/
|
|
582
|
+
private flushGlobalNotify(): void {
|
|
583
|
+
if (this.notifyingGlobalListeners) return; // already mid-loop; that loop's own drain (below) will pick this up
|
|
584
|
+
this.notifyingGlobalListeners = true;
|
|
585
|
+
try {
|
|
586
|
+
while (this.globalNotifyPending) {
|
|
587
|
+
this.globalNotifyPending = false;
|
|
588
|
+
for (const fn of this.globalListeners) fn();
|
|
589
|
+
}
|
|
590
|
+
} finally {
|
|
591
|
+
this.notifyingGlobalListeners = false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Deep structural equality, used to skip redraws when a recompute is a no-op. */
|
|
597
|
+
export function structuralEqual(a: unknown, b: unknown): boolean {
|
|
598
|
+
return structuralEqualInner(a, b, []);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// `seen` is the stack of (a, b) pairs currently being compared by an
|
|
602
|
+
// enclosing call on the stack -- used only as a cycle guard, see below.
|
|
603
|
+
function structuralEqualInner(a: unknown, b: unknown, seen: Array<[object, object]>): boolean {
|
|
604
|
+
if (Object.is(a, b)) return true;
|
|
605
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
606
|
+
|
|
607
|
+
// Cycle guard: if this exact (a, b) pair is already being compared by an
|
|
608
|
+
// enclosing (ancestor) call on the stack, it's a cycle -- stop recursing
|
|
609
|
+
// and treat it as equal here rather than looping forever. Any genuine
|
|
610
|
+
// difference elsewhere in the structure is still found by the other,
|
|
611
|
+
// non-cyclic branches of the comparison; this only prevents the recursion
|
|
612
|
+
// itself from being unbounded.
|
|
613
|
+
for (const [sa, sb] of seen) {
|
|
614
|
+
if (sa === a && sb === b) return true;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Two objects of genuinely different types (Date vs plain object, Map vs
|
|
618
|
+
// Set, etc.) are never equal, regardless of their own-key shape below.
|
|
619
|
+
const aTag = Object.prototype.toString.call(a);
|
|
620
|
+
const bTag = Object.prototype.toString.call(b);
|
|
621
|
+
if (aTag !== bTag) return false;
|
|
622
|
+
|
|
623
|
+
seen = [...seen, [a, b]];
|
|
624
|
+
|
|
625
|
+
if (a instanceof Date) return a.getTime() === (b as Date).getTime();
|
|
626
|
+
|
|
627
|
+
if (a instanceof Map) {
|
|
628
|
+
const bm = b as Map<unknown, unknown>;
|
|
629
|
+
if (a.size !== bm.size) return false;
|
|
630
|
+
for (const [k, v] of a) {
|
|
631
|
+
if (!bm.has(k) || !structuralEqualInner(v, bm.get(k), seen)) return false;
|
|
632
|
+
}
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (a instanceof Set) {
|
|
637
|
+
// Set membership is by identity/SameValueZero, matching the Set's own
|
|
638
|
+
// semantics (not a structural comparison of elements) -- two elements
|
|
639
|
+
// that are merely structurally-equal-but-distinct objects are genuinely
|
|
640
|
+
// different members of a Set.
|
|
641
|
+
const bs = b as Set<unknown>;
|
|
642
|
+
if (a.size !== bs.size) return false;
|
|
643
|
+
for (const v of a) if (!bs.has(v)) return false;
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (Array.isArray(a)) {
|
|
648
|
+
const ba = b as unknown[];
|
|
649
|
+
if (a.length !== ba.length) return false;
|
|
650
|
+
return a.every((v, i) => structuralEqualInner(v, ba[i], seen));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Any other exotic built-in (RegExp, DOM nodes, a real Path2D, etc.) that
|
|
654
|
+
// isn't a plain object falls through to here -- treat it as *not* equal
|
|
655
|
+
// by default rather than comparing (typically zero) own enumerable keys,
|
|
656
|
+
// which would otherwise silently treat any two distinct instances as
|
|
657
|
+
// identical. Safe default: this only means an extra recompute, never a
|
|
658
|
+
// missed update.
|
|
659
|
+
if (aTag !== "[object Object]") return false;
|
|
660
|
+
|
|
661
|
+
const aKeys = Object.keys(a as object);
|
|
662
|
+
const bKeys = Object.keys(b as object);
|
|
663
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
664
|
+
return aKeys.every(
|
|
665
|
+
(k) =>
|
|
666
|
+
Object.hasOwn(b, k) &&
|
|
667
|
+
structuralEqualInner((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k], seen),
|
|
668
|
+
);
|
|
669
|
+
}
|