@cardenelabs/cdl 0.5.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/LICENSE +21 -0
- package/README.md +343 -0
- package/SPEC.md +374 -0
- package/dist/index.cjs +28085 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3415 -0
- package/dist/index.d.ts +3415 -0
- package/dist/index.js +27962 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +23043 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +2 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +23041 -0
- package/dist/react.js.map +1 -0
- package/dist/render-C78lIXeC.d.cts +2449 -0
- package/dist/render-C78lIXeC.d.ts +2449 -0
- package/examples/quick-start.md +88 -0
- package/package.json +79 -0
- package/src/anim/core/easing.ts +77 -0
- package/src/anim/core/timeline.ts +335 -0
- package/src/anim/core/types.ts +42 -0
- package/src/anim/index.ts +20 -0
- package/src/anim/react/index.ts +3 -0
- package/src/anim/react/useReducedMotion.ts +27 -0
- package/src/anim/react/useTimeline.ts +48 -0
- package/src/assert-never.ts +16 -0
- package/src/author-intent-verify.ts +587 -0
- package/src/builder.ts +3740 -0
- package/src/compile.ts +12 -0
- package/src/dom-verify-core.ts +121 -0
- package/src/dom-verify-types.ts +21 -0
- package/src/dom-verify.ts +948 -0
- package/src/event-handler/index.ts +184 -0
- package/src/formula/ast.ts +46 -0
- package/src/formula/evaluator.ts +163 -0
- package/src/formula/formula-computeds.ts +83 -0
- package/src/formula/index.ts +5 -0
- package/src/formula/parser.ts +399 -0
- package/src/index.ts +284 -0
- package/src/input-signals.ts +74 -0
- package/src/kinds/actor.tsx +79 -0
- package/src/kinds/card.tsx +61 -0
- package/src/kinds/chart-bar.tsx +121 -0
- package/src/kinds/chart-line.tsx +163 -0
- package/src/kinds/chart-pie.tsx +107 -0
- package/src/kinds/compact-title.ts +164 -0
- package/src/kinds/dyn-shape.tsx +394 -0
- package/src/kinds/event.tsx +70 -0
- package/src/kinds/function.tsx +53 -0
- package/src/kinds/funnel.tsx +122 -0
- package/src/kinds/gantt.tsx +193 -0
- package/src/kinds/generic.tsx +368 -0
- package/src/kinds/mind-map.tsx +367 -0
- package/src/kinds/mind-radial.tsx +209 -0
- package/src/kinds/node-tone.ts +18 -0
- package/src/kinds/quadrant.tsx +164 -0
- package/src/kinds/row-align.ts +179 -0
- package/src/kinds/shape-basement.tsx +683 -0
- package/src/kinds/shape-blockchain.tsx +750 -0
- package/src/kinds/shape-commerce.tsx +553 -0
- package/src/kinds/shape-finance.tsx +706 -0
- package/src/kinds/shape-hardware.tsx +862 -0
- package/src/kinds/shape-people.tsx +439 -0
- package/src/kinds/shape-region.tsx +383 -0
- package/src/kinds/shape-software.tsx +859 -0
- package/src/kinds/storage.tsx +180 -0
- package/src/kinds/text-width.ts +73 -0
- package/src/kinds/tree.tsx +275 -0
- package/src/kinds/user-journey.tsx +240 -0
- package/src/label-text.ts +71 -0
- package/src/layout/clearance-constants.ts +47 -0
- package/src/layout/collisions.ts +2078 -0
- package/src/layout/edges.ts +2498 -0
- package/src/layout/footer-shape.ts +97 -0
- package/src/layout/geometry.ts +135 -0
- package/src/layout/label-shift.ts +28 -0
- package/src/layout/lanes.ts +328 -0
- package/src/layout/nodes.ts +190 -0
- package/src/layout/predict-bbox.ts +76 -0
- package/src/layout/px-projection.ts +176 -0
- package/src/layout/spec.ts +872 -0
- package/src/layout/text-width.ts +133 -0
- package/src/layout/tokens.ts +181 -0
- package/src/layout/viewbox.ts +47 -0
- package/src/layout-with-validation.ts +175 -0
- package/src/layout.ts +381 -0
- package/src/presets.ts +2108 -0
- package/src/reactive/batch.ts +48 -0
- package/src/reactive/computed.ts +85 -0
- package/src/reactive/effect.ts +145 -0
- package/src/reactive/index.ts +6 -0
- package/src/reactive/internal.ts +109 -0
- package/src/reactive/signal.ts +113 -0
- package/src/render/edges.tsx +318 -0
- package/src/render/header.tsx +146 -0
- package/src/render/interactive-panel.tsx +9620 -0
- package/src/render/nodes.tsx +319 -0
- package/src/render/stage.tsx +377 -0
- package/src/render/tone.ts +64 -0
- package/src/render/utils.ts +238 -0
- package/src/render.tsx +221 -0
- package/src/scroll-trigger/index.ts +109 -0
- package/src/scroll-trigger/progress.ts +49 -0
- package/src/thumbnail.tsx +114 -0
- package/src/types.ts +2371 -0
- package/src/validate.ts +268 -0
- package/src/visual-validate.ts +4381 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
enterBatch,
|
|
3
|
+
exitBatch,
|
|
4
|
+
setCurrentSubscriber,
|
|
5
|
+
} from "./internal";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 複数の signal set を 1 tick に集約する。 batch 内では effect の実行が deferred queue に蓄積、
|
|
9
|
+
* batch 終了時にまとめて flush される (同じ effect が dep 変化で複数回 trigger されても 1 回に集約)。
|
|
10
|
+
*
|
|
11
|
+
* nested batch は最外側の batch 終了時にのみ flush、 途中の内側 batch では実行しない。
|
|
12
|
+
*/
|
|
13
|
+
export function batch<T>(fn: () => T): T {
|
|
14
|
+
enterBatch();
|
|
15
|
+
let bodyError: unknown;
|
|
16
|
+
let bodyThrew = false;
|
|
17
|
+
let result!: T;
|
|
18
|
+
try {
|
|
19
|
+
result = fn();
|
|
20
|
+
} catch (e) {
|
|
21
|
+
bodyError = e;
|
|
22
|
+
bodyThrew = true;
|
|
23
|
+
}
|
|
24
|
+
// exitBatch は flushEffects を trigger、 effect が throw する可能性あり。
|
|
25
|
+
// body 側で先に error が発生した場合、 flush 側 error で上書きすると root cause の body error が失われる。
|
|
26
|
+
// body error 優先で throw、 flush error は body error があれば silently drop する。
|
|
27
|
+
try {
|
|
28
|
+
exitBatch();
|
|
29
|
+
} catch (flushError) {
|
|
30
|
+
if (bodyThrew) throw bodyError;
|
|
31
|
+
throw flushError;
|
|
32
|
+
}
|
|
33
|
+
if (bodyThrew) throw bodyError;
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* fn 内の signal / computed read を dependency 追跡から除外する。 effect 内で
|
|
39
|
+
* 「値は見たいが再実行 trigger にしたくない」 用途 (initial state だけ読みたい 等)。
|
|
40
|
+
*/
|
|
41
|
+
export function untrack<T>(fn: () => T): T {
|
|
42
|
+
const prev = setCurrentSubscriber(null);
|
|
43
|
+
try {
|
|
44
|
+
return fn();
|
|
45
|
+
} finally {
|
|
46
|
+
setCurrentSubscriber(prev);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
currentSubscriber,
|
|
3
|
+
type Dependency,
|
|
4
|
+
setCurrentSubscriber,
|
|
5
|
+
type Subscriber,
|
|
6
|
+
unsubscribeFromDeps,
|
|
7
|
+
} from "./internal";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* dependency 変化時に自動で再計算される derived value。 lazy evaluation で、
|
|
11
|
+
* `.value` が read されるまでは fn を評価しない。 memoize して dep が変化しない限り再計算しない。
|
|
12
|
+
*
|
|
13
|
+
* computed は subscriber (dep 変化を受け取る) かつ dependency (下流の computed / effect の dep になる)
|
|
14
|
+
* の両側面を持つ = graph の中間 node。
|
|
15
|
+
*/
|
|
16
|
+
export interface Computed<T> {
|
|
17
|
+
get value(): T;
|
|
18
|
+
peek(): T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class ComputedImpl<T> implements Computed<T>, Subscriber, Dependency {
|
|
22
|
+
private _value!: T;
|
|
23
|
+
private dirty = true;
|
|
24
|
+
deps = new Set<Dependency>();
|
|
25
|
+
private subscribers = new Set<Subscriber>();
|
|
26
|
+
private initialized = false;
|
|
27
|
+
|
|
28
|
+
constructor(private fn: () => T) {}
|
|
29
|
+
|
|
30
|
+
notify(): void {
|
|
31
|
+
this.dirty = true;
|
|
32
|
+
// 下流の subscribers (effect / 別 computed) に propagate。
|
|
33
|
+
// 過去実装で `if (this.dirty) return` していたが、 recompute() が throw して dirty 状態に
|
|
34
|
+
// 残った時、 以降の dep 変化で notify が来ても即 return して downstream に伝わらず、
|
|
35
|
+
// effect が「computed を再読むと復旧値になっているのに、 呼ばれない」 という stuck 状態を招いた。
|
|
36
|
+
// effect 側は scheduleEffect の Set dedupe で重複 execute を防ぐので、 常に propagate で問題ない。
|
|
37
|
+
const subs = Array.from(this.subscribers);
|
|
38
|
+
for (const sub of subs) {
|
|
39
|
+
sub.notify();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get value(): T {
|
|
44
|
+
if (currentSubscriber) {
|
|
45
|
+
this.addSubscriber(currentSubscriber);
|
|
46
|
+
currentSubscriber.deps.add(this);
|
|
47
|
+
}
|
|
48
|
+
if (this.dirty || !this.initialized) {
|
|
49
|
+
this.recompute();
|
|
50
|
+
}
|
|
51
|
+
return this._value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
peek(): T {
|
|
55
|
+
if (this.dirty || !this.initialized) {
|
|
56
|
+
this.recompute();
|
|
57
|
+
}
|
|
58
|
+
return this._value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private recompute(): void {
|
|
62
|
+
// 古い deps を解除、 新規 deps を fn 実行中に収集
|
|
63
|
+
unsubscribeFromDeps(this);
|
|
64
|
+
const prev = setCurrentSubscriber(this);
|
|
65
|
+
try {
|
|
66
|
+
this._value = this.fn();
|
|
67
|
+
this.initialized = true;
|
|
68
|
+
this.dirty = false;
|
|
69
|
+
} finally {
|
|
70
|
+
setCurrentSubscriber(prev);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
addSubscriber(sub: Subscriber): void {
|
|
75
|
+
this.subscribers.add(sub);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
removeSubscriber(sub: Subscriber): void {
|
|
79
|
+
this.subscribers.delete(sub);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function computed<T>(fn: () => T): Computed<T> {
|
|
84
|
+
return new ComputedImpl(fn);
|
|
85
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Dependency,
|
|
3
|
+
enterBatch,
|
|
4
|
+
exitBatch,
|
|
5
|
+
pendingEffects,
|
|
6
|
+
type Runnable,
|
|
7
|
+
scheduleEffect,
|
|
8
|
+
setCurrentSubscriber,
|
|
9
|
+
type Subscriber,
|
|
10
|
+
unsubscribeFromDeps,
|
|
11
|
+
} from "./internal";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* side-effect を dependency 変化に紐付ける primitive。 effect 生成時に即実行 (eager)、
|
|
15
|
+
* fn 内で読んだ signal / computed が dep として自動登録される。 dep が変化すると再実行 queue に入り、
|
|
16
|
+
* batch 外なら即実行、 batch 中なら batch 終了時に flush される。
|
|
17
|
+
*
|
|
18
|
+
* fn の戻り値 (cleanup function) は、 次の再実行前 or dispose 時に呼ばれる (DOM listener 解除等)。
|
|
19
|
+
* effect 関数の戻り値 = dispose function、 呼び出すと以降の再実行を止めて cleanup 実行。
|
|
20
|
+
*/
|
|
21
|
+
class EffectImpl implements Subscriber, Runnable {
|
|
22
|
+
deps = new Set<Dependency>();
|
|
23
|
+
private cleanupFn: (() => void) | void | undefined;
|
|
24
|
+
private disposed = false;
|
|
25
|
+
private initialized = false;
|
|
26
|
+
|
|
27
|
+
constructor(private fn: () => void | (() => void)) {
|
|
28
|
+
// 初回 execute() が throw した場合、 fn が既に signal を read して dep 登録している可能性あり。
|
|
29
|
+
// dispose せずに throw だけ返すと、 呼出側は disposer を受け取れず (constructor が throw で return しない)、
|
|
30
|
+
// 以降の signal 変化で orphaned effect が実行され続ける。 例外前に必ず dispose して dep 解除する。
|
|
31
|
+
try {
|
|
32
|
+
this.execute();
|
|
33
|
+
} catch (e) {
|
|
34
|
+
this.dispose();
|
|
35
|
+
throw e;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
notify(): void {
|
|
40
|
+
if (this.disposed) return;
|
|
41
|
+
scheduleEffect(this);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
execute(): void {
|
|
45
|
+
if (this.disposed) return;
|
|
46
|
+
// execute 全体を implicit batch で包む。 fn 内で signal set が起こると、
|
|
47
|
+
// signal setter 側の batch (batchDepth=2) から exit した時点で batchDepth が 0 に落ちて
|
|
48
|
+
// flushEffects が発火、 現 execute() のスタックが残っている状態で再度 execute() が呼ばれる
|
|
49
|
+
// re-entrancy を招く (nested run の cleanupFn が outer で上書きされて次回 stale)。
|
|
50
|
+
// execute 全体を batch で包むことで、 execute 完了までは flush が deferred される。
|
|
51
|
+
enterBatch();
|
|
52
|
+
let caughtError: unknown;
|
|
53
|
+
let hasError = false;
|
|
54
|
+
let cleanupThrew = false;
|
|
55
|
+
try {
|
|
56
|
+
try {
|
|
57
|
+
// 先に古い deps を解除してから cleanup を呼ぶ (順序重要)。
|
|
58
|
+
// cleanup が前回 dep signal に write すると、 その write が同 effect の notify を trigger して
|
|
59
|
+
// 自分自身が再 schedule され、 単発 update で spurious re-run / cleanup 駆動 loop になる。
|
|
60
|
+
// 先に unsubscribe しておけば cleanup 内の write は自分を trigger しない。
|
|
61
|
+
unsubscribeFromDeps(this);
|
|
62
|
+
// cleanup 呼出。 cleanup 内の signal read は「外側 subscriber (別 effect / computed) の dep」 に
|
|
63
|
+
// 混入しないよう currentSubscriber を一時 null 化 (untrack 相当)。
|
|
64
|
+
if (typeof this.cleanupFn === "function") {
|
|
65
|
+
const prevSub = setCurrentSubscriber(null);
|
|
66
|
+
try {
|
|
67
|
+
this.cleanupFn();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
this.cleanupFn = undefined;
|
|
70
|
+
setCurrentSubscriber(prevSub);
|
|
71
|
+
cleanupThrew = true;
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
this.cleanupFn = undefined;
|
|
75
|
+
setCurrentSubscriber(prevSub);
|
|
76
|
+
}
|
|
77
|
+
// 新規 deps を fn 実行中に収集
|
|
78
|
+
const prev = setCurrentSubscriber(this);
|
|
79
|
+
try {
|
|
80
|
+
this.cleanupFn = this.fn();
|
|
81
|
+
} finally {
|
|
82
|
+
setCurrentSubscriber(prev);
|
|
83
|
+
}
|
|
84
|
+
// 初回 execute 成功 = initialized。 subsequent execute の throw で transient recovery path を有効化。
|
|
85
|
+
this.initialized = true;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// fn () / cleanup が throw した場合の処理は 3 case で分岐:
|
|
88
|
+
// (1) 初回 execute (!initialized) の throw = dispose 完全終了 (constructor 側で catch されるが
|
|
89
|
+
// dispose 前に flushEffects が動く可能性、 self-schedule された場合の再実行防止のため synchronous に disposed 化)
|
|
90
|
+
// (2) subsequent execute + cleanup throw = 新規 fn 実行前に既に unsubscribeFromDeps 済で
|
|
91
|
+
// recovery 経路が無い、 dispose 完全終了
|
|
92
|
+
// (3) subsequent execute + fn throw = fn 内で新規 dep 追加済、 dep 変化での再実行 = transient error 復旧 path、
|
|
93
|
+
// dispose せず deps を維持、 self-schedule のみ解除
|
|
94
|
+
caughtError = e;
|
|
95
|
+
hasError = true;
|
|
96
|
+
pendingEffects.delete(this);
|
|
97
|
+
if (!this.initialized || cleanupThrew) {
|
|
98
|
+
this.disposed = true;
|
|
99
|
+
unsubscribeFromDeps(this);
|
|
100
|
+
this.cleanupFn = undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
// exitBatch 側で flushEffects が throw する可能性あり、 body 側 (fn/cleanup) の caughtError を優先。
|
|
105
|
+
try {
|
|
106
|
+
exitBatch();
|
|
107
|
+
} catch (flushError) {
|
|
108
|
+
if (!hasError) {
|
|
109
|
+
caughtError = flushError;
|
|
110
|
+
hasError = true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (hasError) {
|
|
115
|
+
throw caughtError;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
dispose(): void {
|
|
120
|
+
if (this.disposed) return;
|
|
121
|
+
this.disposed = true;
|
|
122
|
+
// dispose も同様、 cleanup 内 read を外側 subscriber から遮断。
|
|
123
|
+
// cleanup が throw した場合でも unsubscribeFromDeps は必ず実行して subscriber set から離脱する
|
|
124
|
+
// (throw で unsubscribe が skip されると、 dead effect が signal.subscribers に残り続けて
|
|
125
|
+
// 以降の signal 変化で notify され続ける orphan retention 問題)。
|
|
126
|
+
try {
|
|
127
|
+
if (typeof this.cleanupFn === "function") {
|
|
128
|
+
const prevSub = setCurrentSubscriber(null);
|
|
129
|
+
try {
|
|
130
|
+
this.cleanupFn();
|
|
131
|
+
} finally {
|
|
132
|
+
this.cleanupFn = undefined;
|
|
133
|
+
setCurrentSubscriber(prevSub);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
unsubscribeFromDeps(this);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function effect(fn: () => void | (() => void)): () => void {
|
|
143
|
+
const e = new EffectImpl(fn);
|
|
144
|
+
return () => e.dispose();
|
|
145
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preact signals 相当の pull-based reactive graph の internal state。
|
|
3
|
+
*
|
|
4
|
+
* 3 primitive (signal / computed / effect) の依存追跡を「read 時に current subscriber を
|
|
5
|
+
* dep として自動記録」 + 「write 時に subscriber を notify」 で構成する。
|
|
6
|
+
*
|
|
7
|
+
* batch は notify を deferred queue に蓄積して、 top-level batch 終了時にまとめて実行、
|
|
8
|
+
* 冗長な effect 実行を防ぐ。 untrack は current subscriber を一時的に null にして
|
|
9
|
+
* 「この read だけ dependency として記録しない」 を実現する。
|
|
10
|
+
*
|
|
11
|
+
* 依存追跡 model = pull-based (computed は lazy、 dirty flag で invalidate、 read 時に再計算)、
|
|
12
|
+
* effect は eager (直ちに実行 + dep 変化時に再実行 queue へ)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface Subscriber {
|
|
16
|
+
/** dep から notify されて自身が invalidate された旨を伝える */
|
|
17
|
+
notify(): void;
|
|
18
|
+
/** dependency を再計算のため clear + 再収集する時に signal / computed 側から呼ぶ */
|
|
19
|
+
deps: Set<Dependency>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Dependency {
|
|
23
|
+
/** subscriber を dep 側に登録 / 解除する */
|
|
24
|
+
addSubscriber(sub: Subscriber): void;
|
|
25
|
+
removeSubscriber(sub: Subscriber): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 現在計算中の subscriber (computed の fn 内 or effect の fn 内で set される) */
|
|
29
|
+
export let currentSubscriber: Subscriber | null = null;
|
|
30
|
+
|
|
31
|
+
export function setCurrentSubscriber(sub: Subscriber | null): Subscriber | null {
|
|
32
|
+
const prev = currentSubscriber;
|
|
33
|
+
currentSubscriber = sub;
|
|
34
|
+
return prev;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* schedule 対象となる Runnable (effect 実装が implement する)。 Set の key に instance を使うことで
|
|
39
|
+
* 同 effect が batch 内に複数回 notify されても 1 回に集約される (dedupe)。
|
|
40
|
+
*/
|
|
41
|
+
export interface Runnable {
|
|
42
|
+
execute(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** batch 中は effect の実行を deferred queue に蓄積 */
|
|
46
|
+
export let batchDepth = 0;
|
|
47
|
+
export const pendingEffects = new Set<Runnable>();
|
|
48
|
+
|
|
49
|
+
export function enterBatch(): void {
|
|
50
|
+
batchDepth++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function exitBatch(): void {
|
|
54
|
+
batchDepth--;
|
|
55
|
+
if (batchDepth === 0) {
|
|
56
|
+
flushEffects();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isInBatch(): boolean {
|
|
61
|
+
return batchDepth > 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function scheduleEffect(runnable: Runnable): void {
|
|
65
|
+
pendingEffects.add(runnable);
|
|
66
|
+
if (batchDepth === 0) {
|
|
67
|
+
flushEffects();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function flushEffects(): void {
|
|
72
|
+
// Set を snapshot せず、 直接 iterator で 1 件ずつ取り出し + delete して execute する。
|
|
73
|
+
// snapshot 化すると、 execute 中に別 effect が signal write で snapshot 内 entry を再 add した場合、
|
|
74
|
+
// 「nested flushEffects で 1 度実行」 + 「outer snapshot loop でもう 1 度実行」 で二重実行になる
|
|
75
|
+
// (effect が execute 内 exitBatch で flushEffects を recursively 呼ぶため)。
|
|
76
|
+
// Set を直接 drain すれば nested flush と outer flush が同じ Set を共有して 1 度のみ実行される。
|
|
77
|
+
// effect 内で throw した場合でも残 effect を落とさず全て execute する
|
|
78
|
+
// (最初の error を保持して flush 終わりに throw、 以降の effect は run 済み)。
|
|
79
|
+
let firstError: unknown;
|
|
80
|
+
let hasError = false;
|
|
81
|
+
while (pendingEffects.size > 0) {
|
|
82
|
+
const iterator = pendingEffects.values();
|
|
83
|
+
const first = iterator.next();
|
|
84
|
+
if (first.done) break;
|
|
85
|
+
const runnable = first.value;
|
|
86
|
+
pendingEffects.delete(runnable);
|
|
87
|
+
try {
|
|
88
|
+
runnable.execute();
|
|
89
|
+
} catch (e) {
|
|
90
|
+
if (!hasError) {
|
|
91
|
+
firstError = e;
|
|
92
|
+
hasError = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (hasError) {
|
|
97
|
+
throw firstError;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* subscriber の dep set から自身を切り離す (effect dispose / computed の deps 再収集時)。
|
|
103
|
+
*/
|
|
104
|
+
export function unsubscribeFromDeps(sub: Subscriber): void {
|
|
105
|
+
for (const dep of sub.deps) {
|
|
106
|
+
dep.removeSubscriber(sub);
|
|
107
|
+
}
|
|
108
|
+
sub.deps.clear();
|
|
109
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
currentSubscriber,
|
|
3
|
+
type Dependency,
|
|
4
|
+
enterBatch,
|
|
5
|
+
exitBatch,
|
|
6
|
+
setCurrentSubscriber,
|
|
7
|
+
type Subscriber,
|
|
8
|
+
} from "./internal";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* mutable な reactive value。 `.value` getter / setter で読み書き、
|
|
12
|
+
* 読み取り時に current subscriber (computed / effect) を dep として自動登録する。
|
|
13
|
+
*
|
|
14
|
+
* `.peek()` は依存追跡なしで現 value を読み取る (effect 内で「値は見たいが再実行 trigger にしたくない」 用途)。
|
|
15
|
+
* `.subscribe(fn)` は effect 経路を通さず手動で notification listener を登録する
|
|
16
|
+
* (framework 統合 / 外部 stream との bridge 用途、 dispose function を返す)。
|
|
17
|
+
*/
|
|
18
|
+
export interface Signal<T> {
|
|
19
|
+
get value(): T;
|
|
20
|
+
set value(v: T);
|
|
21
|
+
peek(): T;
|
|
22
|
+
subscribe(fn: (v: T) => void): () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class SignalImpl<T> implements Signal<T>, Dependency {
|
|
26
|
+
private _value: T;
|
|
27
|
+
private subscribers = new Set<Subscriber>();
|
|
28
|
+
private manualListeners = new Set<(v: T) => void>();
|
|
29
|
+
|
|
30
|
+
constructor(initial: T) {
|
|
31
|
+
this._value = initial;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get value(): T {
|
|
35
|
+
if (currentSubscriber) {
|
|
36
|
+
this.addSubscriber(currentSubscriber);
|
|
37
|
+
currentSubscriber.deps.add(this);
|
|
38
|
+
}
|
|
39
|
+
return this._value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
set value(next: T) {
|
|
43
|
+
if (Object.is(next, this._value)) return;
|
|
44
|
+
this._value = next;
|
|
45
|
+
// notify cascade 全体を implicit batch で包む。 batch なしで書換えると、
|
|
46
|
+
// 1 signal set の subscriber 走査中に effect が即 flush されて、 別 subscriber の
|
|
47
|
+
// dirty marking が終わっていない状態で computed を read してしまう。
|
|
48
|
+
// 実例 = b = computed(a+1) / c = computed(a*2) / effect(b + c) で a=1→10 の 1 write が、
|
|
49
|
+
// b は更新 / c は未更新 の中間 state で effect 実行され「13」 の偽値を emit する。
|
|
50
|
+
// batch でくるむと、 全 subscriber の dirty marking + effect scheduling が終わってから 1 度だけ flush される。
|
|
51
|
+
enterBatch();
|
|
52
|
+
try {
|
|
53
|
+
// subscribers を snapshot、 notify 中に set が起こると subscribers に mutate が起きる可能性
|
|
54
|
+
const subs = Array.from(this.subscribers);
|
|
55
|
+
for (const sub of subs) {
|
|
56
|
+
sub.notify();
|
|
57
|
+
}
|
|
58
|
+
// 手動 listener は last (subscriber notify で effect が実行される場合の順序性維持)。
|
|
59
|
+
// listener 内の signal / computed read が「set した側の subscriber」 に dep として
|
|
60
|
+
// 混入するのを防ぐため untrack 経路で invoke する (currentSubscriber を一時 null 化)。
|
|
61
|
+
// manualListeners も snapshot して iteration。 subscribe callback がその場で
|
|
62
|
+
// 別 listener を add した場合、 live iteration だと new listener を「初回 subscribe callback で 1 回」
|
|
63
|
+
// + 「本 iteration で notify として 1 回」 の 2 度呼びしてしまう。
|
|
64
|
+
const listenerSnapshot = Array.from(this.manualListeners);
|
|
65
|
+
const prevSub = setCurrentSubscriber(null);
|
|
66
|
+
try {
|
|
67
|
+
for (const listener of listenerSnapshot) {
|
|
68
|
+
listener(next);
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
setCurrentSubscriber(prevSub);
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
exitBatch();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
peek(): T {
|
|
79
|
+
return this._value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
subscribe(fn: (v: T) => void): () => void {
|
|
83
|
+
this.manualListeners.add(fn);
|
|
84
|
+
// 初回 immediately 実行 (subscribe 時点の値を渡す)。
|
|
85
|
+
// callback が throw した場合、 listener 未登録の状態に巻き戻して再 throw する
|
|
86
|
+
// (登録されっぱなしだと以降の set() で毎回 throw する orphan listener になる)。
|
|
87
|
+
// callback 内の read も untrack して subscribe 呼出側の subscriber に dep 混入させない。
|
|
88
|
+
const prevSub = setCurrentSubscriber(null);
|
|
89
|
+
try {
|
|
90
|
+
fn(this._value);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
this.manualListeners.delete(fn);
|
|
93
|
+
throw e;
|
|
94
|
+
} finally {
|
|
95
|
+
setCurrentSubscriber(prevSub);
|
|
96
|
+
}
|
|
97
|
+
return () => {
|
|
98
|
+
this.manualListeners.delete(fn);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
addSubscriber(sub: Subscriber): void {
|
|
103
|
+
this.subscribers.add(sub);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
removeSubscriber(sub: Subscriber): void {
|
|
107
|
+
this.subscribers.delete(sub);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function signal<T>(initial: T): Signal<T> {
|
|
112
|
+
return new SignalImpl(initial);
|
|
113
|
+
}
|