fibrae 0.2.3 → 0.3.1
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/dist/atom-utils.d.ts +52 -0
- package/dist/atom-utils.js +64 -0
- package/dist/atom-utils.js.map +1 -0
- package/dist/cli/build.d.ts +34 -0
- package/dist/cli/build.js +92 -0
- package/dist/cli/build.js.map +1 -0
- package/dist/cli/cli.d.ts +10 -0
- package/dist/cli/cli.js +43 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/cli/config.d.ts +19 -0
- package/dist/cli/config.js +5 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/html.d.ts +19 -0
- package/dist/cli/html.js +95 -0
- package/dist/cli/html.js.map +1 -0
- package/dist/cli/index.d.ts +6 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/vite-plugin.d.ts +9 -0
- package/dist/cli/vite-plugin.js +143 -0
- package/dist/cli/vite-plugin.js.map +1 -0
- package/dist/components.d.ts +28 -30
- package/dist/components.js +35 -53
- package/dist/components.js.map +1 -1
- package/dist/core.js +7 -10
- package/dist/core.js.map +1 -1
- package/dist/dom.d.ts +25 -6
- package/dist/dom.js +161 -27
- package/dist/dom.js.map +1 -1
- package/dist/fiber-boundary.d.ts +39 -0
- package/dist/fiber-boundary.js +151 -0
- package/dist/fiber-boundary.js.map +1 -0
- package/dist/fiber-commit.d.ts +27 -0
- package/dist/fiber-commit.js +247 -0
- package/dist/fiber-commit.js.map +1 -0
- package/dist/fiber-render.d.ts +9 -9
- package/dist/fiber-render.js +165 -958
- package/dist/fiber-render.js.map +1 -1
- package/dist/fiber-tree.d.ts +77 -0
- package/dist/fiber-tree.js +152 -0
- package/dist/fiber-tree.js.map +1 -0
- package/dist/fiber-update.d.ts +46 -0
- package/dist/fiber-update.js +521 -0
- package/dist/fiber-update.js.map +1 -0
- package/dist/h.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/jsx-runtime/index.d.ts +368 -2
- package/dist/live/atom.d.ts +31 -0
- package/dist/live/atom.js +33 -0
- package/dist/live/atom.js.map +1 -0
- package/dist/live/client.d.ts +50 -0
- package/dist/live/client.js +90 -0
- package/dist/live/client.js.map +1 -0
- package/dist/live/codec.d.ts +39 -0
- package/dist/live/codec.js +41 -0
- package/dist/live/codec.js.map +1 -0
- package/dist/live/config.d.ts +13 -0
- package/dist/live/config.js +11 -0
- package/dist/live/config.js.map +1 -0
- package/dist/live/index.d.ts +25 -0
- package/dist/live/index.js +19 -0
- package/dist/live/index.js.map +1 -0
- package/dist/live/server.d.ts +83 -0
- package/dist/live/server.js +106 -0
- package/dist/live/server.js.map +1 -0
- package/dist/live/sse-stream.d.ts +14 -0
- package/dist/live/sse-stream.js +30 -0
- package/dist/live/sse-stream.js.map +1 -0
- package/dist/live/types.d.ts +40 -0
- package/dist/live/types.js +20 -0
- package/dist/live/types.js.map +1 -0
- package/dist/mdx/index.d.ts +125 -0
- package/dist/mdx/index.js +137 -0
- package/dist/mdx/index.js.map +1 -0
- package/dist/mdx/parse.d.ts +42 -0
- package/dist/mdx/parse.js +147 -0
- package/dist/mdx/parse.js.map +1 -0
- package/dist/mdx/render.d.ts +23 -0
- package/dist/mdx/render.js +263 -0
- package/dist/mdx/render.js.map +1 -0
- package/dist/router/Form.d.ts +90 -0
- package/dist/router/Form.js +166 -0
- package/dist/router/Form.js.map +1 -0
- package/dist/router/History.d.ts +4 -9
- package/dist/router/History.js +0 -8
- package/dist/router/History.js.map +1 -1
- package/dist/router/Link.d.ts +27 -28
- package/dist/router/Link.js +50 -119
- package/dist/router/Link.js.map +1 -1
- package/dist/router/Navigator.d.ts +25 -33
- package/dist/router/Navigator.js +41 -149
- package/dist/router/Navigator.js.map +1 -1
- package/dist/router/Route.d.ts +24 -7
- package/dist/router/Route.js +42 -27
- package/dist/router/Route.js.map +1 -1
- package/dist/router/Router.d.ts +27 -19
- package/dist/router/Router.js +112 -120
- package/dist/router/Router.js.map +1 -1
- package/dist/router/RouterBuilder.d.ts +171 -36
- package/dist/router/RouterBuilder.js +101 -39
- package/dist/router/RouterBuilder.js.map +1 -1
- package/dist/router/RouterOutlet.d.ts +1 -18
- package/dist/router/RouterOutlet.js +60 -48
- package/dist/router/RouterOutlet.js.map +1 -1
- package/dist/router/RouterState.d.ts +1 -1
- package/dist/router/index.d.ts +8 -5
- package/dist/router/index.js +6 -3
- package/dist/router/index.js.map +1 -1
- package/dist/router/register.d.ts +37 -0
- package/dist/router/register.js +18 -0
- package/dist/router/register.js.map +1 -0
- package/dist/router/utils.d.ts +36 -0
- package/dist/router/utils.js +48 -0
- package/dist/router/utils.js.map +1 -0
- package/dist/runtime.d.ts +11 -8
- package/dist/runtime.js +20 -2
- package/dist/runtime.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +15 -29
- package/dist/server.js.map +1 -1
- package/dist/shared.d.ts +61 -62
- package/dist/shared.js +51 -13
- package/dist/shared.js.map +1 -1
- package/dist/tracking.d.ts +4 -3
- package/dist/tracking.js +6 -1
- package/dist/tracking.js.map +1 -1
- package/package.json +45 -7
- package/dist/hydration.d.ts +0 -30
- package/dist/hydration.js +0 -355
- package/dist/hydration.js.map +0 -1
- package/dist/render.d.ts +0 -19
- package/dist/render.js +0 -285
- package/dist/render.js.map +0 -1
- package/dist/scope-utils.d.ts +0 -14
- package/dist/scope-utils.js +0 -29
- package/dist/scope-utils.js.map +0 -1
package/dist/fiber-render.js
CHANGED
|
@@ -1,952 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fiber-based rendering
|
|
2
|
+
* Fiber-based rendering — public API and orchestration.
|
|
3
3
|
*
|
|
4
|
-
* This module
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* - Function components have `dom: Option.none()` - no wrapper spans!
|
|
4
|
+
* This module provides:
|
|
5
|
+
* - `renderFiber` — mount a VElement tree to the DOM
|
|
6
|
+
* - `hydrateFiber` — hydrate an existing SSR DOM tree
|
|
7
|
+
* - `renderMailboxConsumer` — batched re-render work loop
|
|
9
8
|
*
|
|
10
|
-
*
|
|
9
|
+
* The actual reconciliation, commit, boundary, and tree logic
|
|
10
|
+
* are split across fiber-tree, fiber-update, fiber-commit, and fiber-boundary.
|
|
11
11
|
*/
|
|
12
12
|
import * as Effect from "effect/Effect";
|
|
13
13
|
import * as Stream from "effect/Stream";
|
|
14
|
-
import * as Scope from "effect/Scope";
|
|
15
14
|
import * as Ref from "effect/Ref";
|
|
16
|
-
import * as Exit from "effect/Exit";
|
|
17
15
|
import * as Option from "effect/Option";
|
|
18
16
|
import * as Deferred from "effect/Deferred";
|
|
19
17
|
import * as Context from "effect/Context";
|
|
20
18
|
import * as FiberRef from "effect/FiberRef";
|
|
21
|
-
import * as Cause from "effect/Cause";
|
|
22
19
|
import { Atom, Registry as AtomRegistry } from "@effect-atom/atom";
|
|
23
|
-
import { ComponentScope,
|
|
24
|
-
import { FibraeRuntime
|
|
25
|
-
import {
|
|
20
|
+
import { ComponentScope, isStream, } from "./shared.js";
|
|
21
|
+
import { FibraeRuntime } from "./runtime.js";
|
|
22
|
+
import { attachEventListeners } from "./dom.js";
|
|
26
23
|
import { normalizeToStream, makeTrackingRegistry } from "./tracking.js";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*
|
|
33
|
-
* Returns a deferred that will be completed with the first emitted value.
|
|
34
|
-
* Subsequent emissions update latestStreamValue and queue re-renders.
|
|
35
|
-
* Errors are forwarded to handleFiberError.
|
|
36
|
-
*
|
|
37
|
-
* @param stream - The component's output stream
|
|
38
|
-
* @param fiberRef - Mutable reference to the current fiber (for re-renders after reconciliation)
|
|
39
|
-
* @param scope - Scope to fork the subscription into
|
|
40
|
-
*
|
|
41
|
-
* @typeParam E - Error type for the deferred (use `never` for die mode, stream error type for fail mode)
|
|
42
|
-
*/
|
|
43
|
-
const subscribeComponentStream = (stream, fiberRef, scope) => Effect.gen(function* () {
|
|
44
|
-
const firstValueDeferred = yield* Deferred.make();
|
|
45
|
-
const subscription = Stream.runForEach(stream, (vElement) => Effect.gen(function* () {
|
|
46
|
-
const done = yield* Deferred.isDone(firstValueDeferred);
|
|
47
|
-
const currentFiber = fiberRef.current;
|
|
48
|
-
if (!done) {
|
|
49
|
-
yield* Deferred.succeed(firstValueDeferred, vElement);
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
// Subsequent emissions - queue re-render
|
|
53
|
-
currentFiber.latestStreamValue = Option.some(vElement);
|
|
54
|
-
yield* queueFiberForRerender(currentFiber);
|
|
55
|
-
}
|
|
56
|
-
})).pipe(Effect.catchAllCause((cause) => Effect.gen(function* () {
|
|
57
|
-
const done = yield* Deferred.isDone(firstValueDeferred);
|
|
58
|
-
const streamError = new StreamError({
|
|
59
|
-
cause: Cause.squash(cause),
|
|
60
|
-
phase: done ? "after-first-emission" : "before-first-emission",
|
|
61
|
-
});
|
|
62
|
-
if (!done) {
|
|
63
|
-
// Fail the deferred with StreamError so awaiting code gets the typed error
|
|
64
|
-
yield* Deferred.fail(firstValueDeferred, streamError);
|
|
65
|
-
}
|
|
66
|
-
const currentFiber = fiberRef.current;
|
|
67
|
-
yield* handleFiberError(currentFiber, streamError);
|
|
68
|
-
})));
|
|
69
|
-
yield* Effect.forkIn(subscription, scope);
|
|
70
|
-
return firstValueDeferred;
|
|
71
|
-
});
|
|
72
|
-
// =============================================================================
|
|
73
|
-
// Fiber Creation Helpers
|
|
74
|
-
// =============================================================================
|
|
75
|
-
const createFiber = (type, props, parent, alternate, effectTag) => ({
|
|
76
|
-
type,
|
|
77
|
-
props,
|
|
78
|
-
dom: Option.none(),
|
|
79
|
-
parent,
|
|
80
|
-
child: Option.none(),
|
|
81
|
-
sibling: Option.none(),
|
|
82
|
-
alternate,
|
|
83
|
-
effectTag,
|
|
84
|
-
componentScope: Option.none(),
|
|
85
|
-
mountedDeferred: Option.none(),
|
|
86
|
-
accessedAtoms: Option.none(),
|
|
87
|
-
latestStreamValue: Option.none(),
|
|
88
|
-
childFirstCommitDeferred: Option.none(),
|
|
89
|
-
fiberRef: Option.none(),
|
|
90
|
-
isMultiEmissionStream: false,
|
|
91
|
-
boundary: Option.none(),
|
|
92
|
-
suspense: Option.none(),
|
|
93
|
-
renderContext: Option.none(),
|
|
94
|
-
isParked: false,
|
|
95
|
-
isUnparking: false,
|
|
96
|
-
});
|
|
97
|
-
/**
|
|
98
|
-
* Check if a fiber's type matches a specific element type string.
|
|
99
|
-
*/
|
|
100
|
-
const fiberTypeIs = (fiber, expected) => fiber.type.pipe(Option.map((t) => t === expected), Option.getOrElse(() => false));
|
|
101
|
-
/**
|
|
102
|
-
* Check if a fiber's type is a function (i.e., a function component).
|
|
103
|
-
*/
|
|
104
|
-
const fiberTypeIsFunction = (fiber) => fiber.type.pipe(Option.map((t) => typeof t === "function"), Option.getOrElse(() => false));
|
|
105
|
-
/**
|
|
106
|
-
* Check if a fiber is a virtual element (no DOM node created).
|
|
107
|
-
* Returns true for root fiber (no type) or FRAGMENT type.
|
|
108
|
-
*/
|
|
109
|
-
const fiberIsVirtualElement = (fiber) => fiber.type.pipe(Option.map((t) => t === "FRAGMENT"), Option.getOrElse(() => true));
|
|
110
|
-
/**
|
|
111
|
-
* Get a fiber's required componentScope or die with a message.
|
|
112
|
-
*/
|
|
113
|
-
const getComponentScopeOrDie = (fiber, msg) => Option.match(fiber.componentScope, {
|
|
114
|
-
onNone: () => Effect.die(msg),
|
|
115
|
-
onSome: (s) => Effect.succeed(s),
|
|
116
|
-
});
|
|
117
|
-
/**
|
|
118
|
-
* Get the full ComponentScope service value (scope + mounted) for a fiber.
|
|
119
|
-
*/
|
|
120
|
-
const getComponentScopeService = (fiber, msg) => Effect.gen(function* () {
|
|
121
|
-
const scope = yield* getComponentScopeOrDie(fiber, msg);
|
|
122
|
-
const mounted = yield* Option.match(fiber.mountedDeferred, {
|
|
123
|
-
onNone: () => Effect.die(`${msg} (missing mountedDeferred)`),
|
|
124
|
-
onSome: (d) => Effect.succeed(d),
|
|
125
|
-
});
|
|
126
|
-
return { scope, mounted };
|
|
127
|
-
});
|
|
128
|
-
// =============================================================================
|
|
129
|
-
// Queue Fiber for Re-render (batched updates)
|
|
130
|
-
// =============================================================================
|
|
131
|
-
const queueFiberForRerender = (fiber) => Effect.gen(function* () {
|
|
132
|
-
const runtime = yield* FibraeRuntime;
|
|
133
|
-
const stateRef = runtime.fiberState;
|
|
134
|
-
const didSchedule = yield* Ref.modify(stateRef, (s) => {
|
|
135
|
-
const alreadyQueued = s.renderQueue.has(fiber);
|
|
136
|
-
const newQueue = alreadyQueued ? s.renderQueue : new Set([...s.renderQueue, fiber]);
|
|
137
|
-
const shouldScheduleNow = !s.batchScheduled;
|
|
138
|
-
const next = {
|
|
139
|
-
...s,
|
|
140
|
-
renderQueue: newQueue,
|
|
141
|
-
batchScheduled: s.batchScheduled || shouldScheduleNow,
|
|
142
|
-
};
|
|
143
|
-
return [shouldScheduleNow, next];
|
|
144
|
-
});
|
|
145
|
-
if (didSchedule) {
|
|
146
|
-
queueMicrotask(() => {
|
|
147
|
-
runForkWithRuntime(runtime)(processBatch());
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
// =============================================================================
|
|
152
|
-
// Process Batch (handles queued re-renders)
|
|
153
|
-
// =============================================================================
|
|
154
|
-
const processBatch = () => Effect.gen(function* () {
|
|
155
|
-
const runtime = yield* FibraeRuntime;
|
|
156
|
-
const stateRef = runtime.fiberState;
|
|
157
|
-
const stateSnapshot = yield* Ref.get(stateRef);
|
|
158
|
-
const batch = Array.from(stateSnapshot.renderQueue);
|
|
159
|
-
// Clear the queue but keep batchScheduled = true to prevent concurrent batches
|
|
160
|
-
yield* Ref.update(stateRef, (s) => ({
|
|
161
|
-
...s,
|
|
162
|
-
renderQueue: new Set(),
|
|
163
|
-
// DO NOT set batchScheduled = false here - keep it true during workLoop
|
|
164
|
-
}));
|
|
165
|
-
if (batch.length === 0) {
|
|
166
|
-
// No work to do - reset batchScheduled
|
|
167
|
-
yield* Ref.update(stateRef, (s) => ({ ...s, batchScheduled: false }));
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
yield* Option.match(stateSnapshot.currentRoot, {
|
|
171
|
-
onNone: () => Effect.void,
|
|
172
|
-
onSome: (currentRoot) => Effect.gen(function* () {
|
|
173
|
-
yield* Ref.update(stateRef, (s) => ({
|
|
174
|
-
...s,
|
|
175
|
-
wipRoot: Option.some(createFiber(currentRoot.type, currentRoot.props, Option.none(), Option.some(currentRoot), Option.none())),
|
|
176
|
-
deletions: [],
|
|
177
|
-
}));
|
|
178
|
-
// Copy dom from currentRoot to wipRoot
|
|
179
|
-
const newState = yield* Ref.get(stateRef);
|
|
180
|
-
Option.match(newState.wipRoot, {
|
|
181
|
-
onNone: () => { },
|
|
182
|
-
onSome: (wip) => {
|
|
183
|
-
wip.dom = currentRoot.dom;
|
|
184
|
-
},
|
|
185
|
-
});
|
|
186
|
-
yield* Ref.update(stateRef, (s) => ({
|
|
187
|
-
...s,
|
|
188
|
-
nextUnitOfWork: s.wipRoot,
|
|
189
|
-
}));
|
|
190
|
-
yield* workLoop(runtime);
|
|
191
|
-
}),
|
|
192
|
-
});
|
|
193
|
-
// After workLoop completes, check if more work was queued during processing
|
|
194
|
-
const afterState = yield* Ref.get(stateRef);
|
|
195
|
-
if (afterState.renderQueue.size > 0) {
|
|
196
|
-
// More work was queued during the batch - process it immediately
|
|
197
|
-
yield* processBatch();
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
// No more work - allow new batches to be scheduled
|
|
201
|
-
yield* Ref.update(stateRef, (s) => ({ ...s, batchScheduled: false }));
|
|
202
|
-
}
|
|
203
|
-
});
|
|
204
|
-
// =============================================================================
|
|
205
|
-
// Fiber Tree Walking Helpers
|
|
206
|
-
// =============================================================================
|
|
207
|
-
/**
|
|
208
|
-
* Walk up the fiber tree from the starting fiber's parent, returning the first
|
|
209
|
-
* ancestor that matches the predicate (excludes the starting fiber itself).
|
|
210
|
-
*/
|
|
211
|
-
const findAncestorExcludingSelf = (fiber, predicate) => {
|
|
212
|
-
let current = fiber.parent;
|
|
213
|
-
while (Option.isSome(current)) {
|
|
214
|
-
if (predicate(current.value))
|
|
215
|
-
return current;
|
|
216
|
-
current = current.value.parent;
|
|
217
|
-
}
|
|
218
|
-
return Option.none();
|
|
219
|
-
};
|
|
220
|
-
/**
|
|
221
|
-
* Link an array of fibers as siblings under a parent fiber.
|
|
222
|
-
* Sets parent.child to the first fiber and chains the rest via sibling pointers.
|
|
223
|
-
*/
|
|
224
|
-
const linkFibersAsSiblings = (fibers, parent) => {
|
|
225
|
-
if (fibers.length === 0) {
|
|
226
|
-
parent.child = Option.none();
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
parent.child = Option.some(fibers[0]);
|
|
230
|
-
for (let i = 1; i < fibers.length; i++) {
|
|
231
|
-
fibers[i - 1].sibling = Option.some(fibers[i]);
|
|
232
|
-
}
|
|
233
|
-
};
|
|
234
|
-
/**
|
|
235
|
-
* Find the nearest ancestor with a DOM node (walks up from parent).
|
|
236
|
-
*/
|
|
237
|
-
const findDomParent = (fiber) => {
|
|
238
|
-
const ancestor = findAncestorExcludingSelf(fiber, (f) => Option.isSome(f.dom));
|
|
239
|
-
return Option.flatMap(ancestor, (f) => f.dom);
|
|
240
|
-
};
|
|
241
|
-
// =============================================================================
|
|
242
|
-
// Error Boundary Support
|
|
243
|
-
// =============================================================================
|
|
244
|
-
/** Find nearest BOUNDARY by walking up the fiber tree */
|
|
245
|
-
const findNearestBoundary = (fiber) => findAncestorExcludingSelf(fiber, (f) => Option.isSome(f.boundary));
|
|
246
|
-
/**
|
|
247
|
-
* Convert a raw cause/error to a ComponentError.
|
|
248
|
-
* If it's already a ComponentError, return as-is.
|
|
249
|
-
* Otherwise, wrap in RenderError as a fallback.
|
|
250
|
-
*/
|
|
251
|
-
const toComponentError = (cause) => {
|
|
252
|
-
// Check if it's already a ComponentError
|
|
253
|
-
if (cause instanceof RenderError ||
|
|
254
|
-
cause instanceof StreamError ||
|
|
255
|
-
cause instanceof EventHandlerError) {
|
|
256
|
-
return cause;
|
|
257
|
-
}
|
|
258
|
-
// Extract the actual error from Cause if needed
|
|
259
|
-
const actualError = Cause.isCause(cause) ? Cause.squash(cause) : cause;
|
|
260
|
-
if (actualError instanceof RenderError ||
|
|
261
|
-
actualError instanceof StreamError ||
|
|
262
|
-
actualError instanceof EventHandlerError) {
|
|
263
|
-
return actualError;
|
|
264
|
-
}
|
|
265
|
-
// Wrap unknown errors as RenderError
|
|
266
|
-
return new RenderError({ cause: actualError });
|
|
267
|
-
};
|
|
268
|
-
/**
|
|
269
|
-
* Handle an error by finding the nearest error boundary and invoking its handler.
|
|
270
|
-
*
|
|
271
|
-
* Finds the nearest BOUNDARY and calls its onError callback to fail the stream.
|
|
272
|
-
* The Stream.catchTags in user code will produce the fallback.
|
|
273
|
-
*/
|
|
274
|
-
const handleFiberError = (fiber, cause) => Effect.gen(function* () {
|
|
275
|
-
const componentError = toComponentError(cause);
|
|
276
|
-
// Find nearest boundary
|
|
277
|
-
const boundaryOpt = findNearestBoundary(fiber);
|
|
278
|
-
return yield* Option.match(boundaryOpt, {
|
|
279
|
-
onNone: () => Effect.gen(function* () {
|
|
280
|
-
yield* Effect.logError("Unhandled error without any error boundary", componentError);
|
|
281
|
-
return Option.none();
|
|
282
|
-
}),
|
|
283
|
-
onSome: (boundary) => Effect.sync(() => {
|
|
284
|
-
// Call onError to fail the stream
|
|
285
|
-
const cfg = Option.getOrThrow(boundary.boundary);
|
|
286
|
-
cfg.onError(componentError);
|
|
287
|
-
cfg.hasError = true;
|
|
288
|
-
// The stream will fail and catchTags will produce fallback
|
|
289
|
-
// which will trigger a re-render via the stream subscription
|
|
290
|
-
return Option.none();
|
|
291
|
-
}),
|
|
292
|
-
});
|
|
293
|
-
});
|
|
294
|
-
// =============================================================================
|
|
295
|
-
// Suspense Support
|
|
296
|
-
// =============================================================================
|
|
297
|
-
/**
|
|
298
|
-
* Find the nearest Suspense boundary by walking up the fiber tree.
|
|
299
|
-
*/
|
|
300
|
-
const findNearestSuspenseBoundary = (fiber) => findAncestorExcludingSelf(fiber, (f) => Option.isSome(f.suspense));
|
|
301
|
-
/**
|
|
302
|
-
* Get the threshold from the nearest Suspense boundary.
|
|
303
|
-
* Returns 0 if no boundary (wait indefinitely).
|
|
304
|
-
*/
|
|
305
|
-
const getSuspenseThreshold = (fiber) => {
|
|
306
|
-
const boundary = findNearestSuspenseBoundary(fiber);
|
|
307
|
-
return boundary.pipe(Option.flatMap((b) => b.suspense), Option.map((cfg) => cfg.threshold), Option.getOrElse(() => 0));
|
|
308
|
-
};
|
|
309
|
-
/**
|
|
310
|
-
* Called when a stream component's threshold expires before first emission.
|
|
311
|
-
* Parks the fiber and switches the boundary to show fallback.
|
|
312
|
-
*/
|
|
313
|
-
const handleFiberSuspension = (fiber) => Effect.gen(function* () {
|
|
314
|
-
const boundaryOpt = findNearestSuspenseBoundary(fiber);
|
|
315
|
-
if (Option.isNone(boundaryOpt)) {
|
|
316
|
-
// No Suspense boundary - just continue waiting
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
const boundary = boundaryOpt.value;
|
|
320
|
-
const config = Option.getOrThrow(boundary.suspense);
|
|
321
|
-
if (config.showingFallback) {
|
|
322
|
-
// Already suspended - first suspension wins
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
// Create deferred for parked fiber completion
|
|
326
|
-
const parkedComplete = yield* Deferred.make();
|
|
327
|
-
// Mark fiber as parked - its scope should not be closed on deletion
|
|
328
|
-
fiber.isParked = true;
|
|
329
|
-
// Park the fiber and switch to fallback
|
|
330
|
-
config.showingFallback = true;
|
|
331
|
-
config.parkedFiber = Option.some(fiber);
|
|
332
|
-
config.parkedComplete = Option.some(parkedComplete);
|
|
333
|
-
// Trigger re-render of boundary with fallback
|
|
334
|
-
yield* queueFiberForRerender(boundary);
|
|
335
|
-
});
|
|
336
|
-
/**
|
|
337
|
-
* Called when a parked fiber finally gets its first emission.
|
|
338
|
-
* Signals the boundary to swap back to showing children.
|
|
339
|
-
*/
|
|
340
|
-
const signalFiberReady = (fiber) => Effect.gen(function* () {
|
|
341
|
-
const boundaryOpt = findNearestSuspenseBoundary(fiber);
|
|
342
|
-
if (Option.isNone(boundaryOpt))
|
|
343
|
-
return;
|
|
344
|
-
const boundary = boundaryOpt.value;
|
|
345
|
-
const config = Option.getOrThrow(boundary.suspense);
|
|
346
|
-
// Unpark the fiber - it's ready now, scope can be closed normally on next deletion
|
|
347
|
-
fiber.isParked = false;
|
|
348
|
-
// Signal that parked fiber is ready
|
|
349
|
-
yield* config.parkedComplete.pipe(Option.map((deferred) => Deferred.succeed(deferred, undefined)), Option.getOrElse(() => Effect.void));
|
|
350
|
-
// Trigger re-render to swap fallback → children
|
|
351
|
-
yield* queueFiberForRerender(boundary);
|
|
352
|
-
});
|
|
353
|
-
// =============================================================================
|
|
354
|
-
// Update Function Component
|
|
355
|
-
// =============================================================================
|
|
356
|
-
const updateFunctionComponent = (fiber, runtime) => Effect.gen(function* () {
|
|
357
|
-
// Initialize deferred for child first commit signaling
|
|
358
|
-
if (Option.isNone(fiber.childFirstCommitDeferred)) {
|
|
359
|
-
fiber.childFirstCommitDeferred = Option.some(yield* Deferred.make());
|
|
360
|
-
}
|
|
361
|
-
// Capture current context during render phase for event handlers in commit phase
|
|
362
|
-
// This includes services like Navigator, RouterHandlers, etc.
|
|
363
|
-
const currentContext = (yield* FiberRef.get(FiberRef.currentContext));
|
|
364
|
-
fiber.renderContext = Option.some(currentContext);
|
|
365
|
-
// Check if we can reuse cached stream value from alternate
|
|
366
|
-
const hasAlternate = Option.isSome(fiber.alternate);
|
|
367
|
-
const hasCachedValue = fiber.alternate.pipe(Option.map((alt) => Option.isSome(alt.latestStreamValue) && alt.isMultiEmissionStream), Option.getOrElse(() => false));
|
|
368
|
-
if (hasAlternate && hasCachedValue) {
|
|
369
|
-
// Reuse cached value from alternate (stream component that emitted multiple values)
|
|
370
|
-
const alt = Option.getOrThrow(fiber.alternate);
|
|
371
|
-
const vElement = Option.getOrThrow(alt.latestStreamValue);
|
|
372
|
-
fiber.latestStreamValue = alt.latestStreamValue;
|
|
373
|
-
fiber.accessedAtoms = alt.accessedAtoms;
|
|
374
|
-
fiber.componentScope = alt.componentScope;
|
|
375
|
-
alt.componentScope = Option.none(); // Transfer ownership
|
|
376
|
-
fiber.fiberRef = alt.fiberRef;
|
|
377
|
-
Option.match(fiber.fiberRef, {
|
|
378
|
-
onNone: () => { },
|
|
379
|
-
onSome: (ref) => {
|
|
380
|
-
ref.current = fiber;
|
|
381
|
-
},
|
|
382
|
-
});
|
|
383
|
-
fiber.isMultiEmissionStream = alt.isMultiEmissionStream;
|
|
384
|
-
yield* reconcileChildren(fiber, [vElement]);
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
// Check if this fiber is being restored from parked (suspended) state
|
|
388
|
-
// If so, skip re-execution and use the cached latestStreamValue
|
|
389
|
-
if (fiber.isUnparking && Option.isSome(fiber.latestStreamValue)) {
|
|
390
|
-
fiber.isUnparking = false; // Clear flag
|
|
391
|
-
const vElement = Option.getOrThrow(fiber.latestStreamValue);
|
|
392
|
-
yield* reconcileChildren(fiber, [vElement]);
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
// Create scope for this component FIRST so it's available in context
|
|
396
|
-
yield* resubscribeFiber(fiber);
|
|
397
|
-
const componentScopeService = yield* getComponentScopeService(fiber, "Expected componentScope to be created by resubscribeFiber");
|
|
398
|
-
// Set up atom tracking
|
|
399
|
-
const accessedAtoms = new Set();
|
|
400
|
-
const trackingRegistry = makeTrackingRegistry(runtime.registry, accessedAtoms);
|
|
401
|
-
fiber.accessedAtoms = Option.some(accessedAtoms);
|
|
402
|
-
// Build context with tracking registry AND ComponentScope
|
|
403
|
-
// This allows Effect components to yield* ComponentScope for cleanup registration
|
|
404
|
-
const contextWithTracking = Context.add(Context.add(currentContext, ComponentScope, componentScopeService), AtomRegistry.AtomRegistry, trackingRegistry);
|
|
405
|
-
// Invoke the component
|
|
406
|
-
const output = yield* Option.match(fiber.type, {
|
|
407
|
-
onNone: () => Effect.die("updateFunctionComponent called with no type"),
|
|
408
|
-
onSome: (type) => {
|
|
409
|
-
if (typeof type !== "function") {
|
|
410
|
-
return Effect.die("updateFunctionComponent called with non-function type");
|
|
411
|
-
}
|
|
412
|
-
const component = type;
|
|
413
|
-
return Effect.sync(() => component(fiber.props));
|
|
414
|
-
},
|
|
415
|
-
});
|
|
416
|
-
// Fast path: if component returns a plain VElement (not Effect/Stream),
|
|
417
|
-
// and it's a special element type, just reconcile directly without stream machinery.
|
|
418
|
-
// This handles wrapper components like Suspense and ErrorBoundary efficiently.
|
|
419
|
-
if (!Effect.isEffect(output) && !isStream(output)) {
|
|
420
|
-
const vElement = output;
|
|
421
|
-
if (typeof vElement === "object" && vElement !== null && "type" in vElement) {
|
|
422
|
-
const elementType = vElement.type;
|
|
423
|
-
if (elementType === "SUSPENSE" || elementType === "FRAGMENT") {
|
|
424
|
-
// Simple wrapper component - just reconcile with the VElement directly
|
|
425
|
-
yield* reconcileChildren(fiber, [vElement]);
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
// Check if it's a multi-emission stream
|
|
431
|
-
fiber.isMultiEmissionStream = isStream(output);
|
|
432
|
-
// Normalize to stream and provide context
|
|
433
|
-
const stream = normalizeToStream(output).pipe(Stream.provideContext(contextWithTracking));
|
|
434
|
-
// Set up fiber ref for stream subscriptions
|
|
435
|
-
const fiberRef = fiber.fiberRef.pipe(Option.getOrElse(() => ({ current: fiber })));
|
|
436
|
-
fiber.fiberRef = Option.some(fiberRef);
|
|
437
|
-
// Subscribe to component stream - errors become defects via "die" mode
|
|
438
|
-
const firstValueDeferred = yield* subscribeComponentStream(stream, fiberRef, componentScopeService.scope);
|
|
439
|
-
// Get threshold from nearest Suspense boundary
|
|
440
|
-
const threshold = getSuspenseThreshold(fiber);
|
|
441
|
-
if (threshold > 0) {
|
|
442
|
-
// Race first value vs threshold
|
|
443
|
-
const result = yield* Effect.race(Deferred.await(firstValueDeferred).pipe(Effect.map((v) => ({ _tag: "value", value: v }))), Effect.sleep(`${threshold} millis`).pipe(Effect.map(() => ({ _tag: "timeout" }))));
|
|
444
|
-
if (result._tag === "timeout") {
|
|
445
|
-
// Threshold expired - signal suspension to boundary
|
|
446
|
-
yield* handleFiberSuspension(fiber);
|
|
447
|
-
// Fork background work: wait for value, then signal ready
|
|
448
|
-
// This allows the render loop to continue and show fallback
|
|
449
|
-
yield* Effect.forkIn(Effect.gen(function* () {
|
|
450
|
-
const value = yield* Deferred.await(firstValueDeferred);
|
|
451
|
-
const currentFiber = fiberRef.current;
|
|
452
|
-
currentFiber.latestStreamValue = Option.some(value);
|
|
453
|
-
yield* signalFiberReady(currentFiber);
|
|
454
|
-
}), componentScopeService.scope);
|
|
455
|
-
// Return early - don't reconcile children yet
|
|
456
|
-
// The Suspense boundary will show fallback via queued re-render
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
else {
|
|
460
|
-
// Value arrived before threshold - no suspension
|
|
461
|
-
fiber.latestStreamValue = Option.some(result.value);
|
|
462
|
-
yield* reconcileChildren(fiber, [result.value]);
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
else {
|
|
466
|
-
// No threshold (0) - wait indefinitely, no suspension possible
|
|
467
|
-
const firstVElement = yield* Deferred.await(firstValueDeferred);
|
|
468
|
-
fiber.latestStreamValue = Option.some(firstVElement);
|
|
469
|
-
yield* reconcileChildren(fiber, [firstVElement]);
|
|
470
|
-
}
|
|
471
|
-
// Subscribe to atom changes
|
|
472
|
-
yield* subscribeFiberAtoms(fiber, accessedAtoms, runtime);
|
|
473
|
-
});
|
|
474
|
-
// =============================================================================
|
|
475
|
-
// Perform Unit of Work
|
|
476
|
-
// =============================================================================
|
|
477
|
-
const performUnitOfWork = (fiber, runtime) => Effect.gen(function* () {
|
|
478
|
-
const isFunctionComponent = fiberTypeIsFunction(fiber);
|
|
479
|
-
const eff = isFunctionComponent
|
|
480
|
-
? updateFunctionComponent(fiber, runtime)
|
|
481
|
-
: updateHostComponent(fiber, runtime);
|
|
482
|
-
const exited = yield* Effect.exit(eff);
|
|
483
|
-
if (Exit.isFailure(exited)) {
|
|
484
|
-
// handleFiberError returns the next fiber to process (if recovery happened)
|
|
485
|
-
return yield* handleFiberError(fiber, exited.cause);
|
|
486
|
-
}
|
|
487
|
-
// Return next unit of work: child, then sibling, then uncle
|
|
488
|
-
if (Option.isSome(fiber.child)) {
|
|
489
|
-
return fiber.child;
|
|
490
|
-
}
|
|
491
|
-
let currentFiber = Option.some(fiber);
|
|
492
|
-
while (Option.isSome(currentFiber)) {
|
|
493
|
-
if (Option.isSome(currentFiber.value.sibling)) {
|
|
494
|
-
return currentFiber.value.sibling;
|
|
495
|
-
}
|
|
496
|
-
currentFiber = currentFiber.value.parent;
|
|
497
|
-
}
|
|
498
|
-
return Option.none();
|
|
499
|
-
});
|
|
500
|
-
// =============================================================================
|
|
501
|
-
// Atom Subscription
|
|
502
|
-
// =============================================================================
|
|
503
|
-
const resubscribeFiber = (fiber) => Effect.gen(function* () {
|
|
504
|
-
// Close old scope if exists
|
|
505
|
-
yield* Option.match(fiber.componentScope, {
|
|
506
|
-
onNone: () => Effect.void,
|
|
507
|
-
onSome: (scope) => Scope.close(scope, Exit.void),
|
|
508
|
-
});
|
|
509
|
-
// Create new scope and mounted deferred
|
|
510
|
-
const newScope = yield* Scope.make();
|
|
511
|
-
const newMounted = yield* Deferred.make();
|
|
512
|
-
fiber.componentScope = Option.some(newScope);
|
|
513
|
-
fiber.mountedDeferred = Option.some(newMounted);
|
|
514
|
-
});
|
|
515
|
-
const subscribeFiberAtoms = (fiber, accessedAtoms, runtime) => Effect.gen(function* () {
|
|
516
|
-
const scope = yield* getComponentScopeOrDie(fiber, "subscribeFiberAtoms requires an existing componentScope");
|
|
517
|
-
yield* Effect.forEach(accessedAtoms, (atom) => {
|
|
518
|
-
// Drop first emission (current value), subscribe to changes
|
|
519
|
-
const atomStream = AtomRegistry.toStream(runtime.registry, atom).pipe(Stream.drop(1));
|
|
520
|
-
const subscription = Stream.runForEach(atomStream, () => queueFiberForRerender(fiber));
|
|
521
|
-
return Effect.forkIn(subscription, scope);
|
|
522
|
-
}, { discard: true, concurrency: "unbounded" });
|
|
523
|
-
});
|
|
524
|
-
// =============================================================================
|
|
525
|
-
// Update Host Component
|
|
526
|
-
// =============================================================================
|
|
527
|
-
const updateHostComponent = (fiber, runtime) => Effect.gen(function* () {
|
|
528
|
-
// Inherit renderContext from parent fiber (function components capture it during render)
|
|
529
|
-
// This propagates Navigator, RouterHandlers, etc. down to host elements for event handlers
|
|
530
|
-
if (Option.isNone(fiber.renderContext) && Option.isSome(fiber.parent)) {
|
|
531
|
-
fiber.renderContext = fiber.parent.value.renderContext;
|
|
532
|
-
}
|
|
533
|
-
// Handle BOUNDARY specially (Effect-native error boundary API)
|
|
534
|
-
const isBoundary = fiberTypeIs(fiber, "BOUNDARY");
|
|
535
|
-
if (isBoundary) {
|
|
536
|
-
const boundaryId = fiber.props.boundaryId;
|
|
537
|
-
const onError = fiber.props.onError;
|
|
538
|
-
// Inherit boundary state from alternate (previous render) if present
|
|
539
|
-
// This preserves hasError state across re-renders
|
|
540
|
-
if (Option.isNone(fiber.boundary) && Option.isSome(fiber.alternate)) {
|
|
541
|
-
const alt = fiber.alternate.value;
|
|
542
|
-
if (Option.isSome(alt.boundary)) {
|
|
543
|
-
fiber.boundary = alt.boundary;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
// Initialize boundary config if not already set
|
|
547
|
-
if (Option.isNone(fiber.boundary)) {
|
|
548
|
-
fiber.boundary = Option.some({
|
|
549
|
-
boundaryId,
|
|
550
|
-
onError,
|
|
551
|
-
hasError: false,
|
|
552
|
-
});
|
|
553
|
-
}
|
|
554
|
-
// Render children (BOUNDARY doesn't have fallback state -
|
|
555
|
-
// the fallback is handled by Stream.catchTags in userland)
|
|
556
|
-
const children = fiber.props.children;
|
|
557
|
-
yield* reconcileChildren(fiber, children || []);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
// Handle SUSPENSE specially
|
|
561
|
-
const isSuspense = fiberTypeIs(fiber, "SUSPENSE");
|
|
562
|
-
if (isSuspense) {
|
|
563
|
-
const fallback = fiber.props.fallback;
|
|
564
|
-
const threshold = fiber.props.threshold ?? 100;
|
|
565
|
-
const children = fiber.props.children;
|
|
566
|
-
// Initialize suspense config if not already set
|
|
567
|
-
if (Option.isNone(fiber.suspense)) {
|
|
568
|
-
fiber.suspense = Option.some({
|
|
569
|
-
fallback,
|
|
570
|
-
threshold,
|
|
571
|
-
showingFallback: false,
|
|
572
|
-
parkedFiber: Option.none(),
|
|
573
|
-
parkedComplete: Option.none(),
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
const config = Option.getOrThrow(fiber.suspense);
|
|
577
|
-
// Check if parked fiber has completed (signaled via signalFiberReady)
|
|
578
|
-
const parkedDone = yield* config.parkedComplete.pipe(Option.map((d) => Deferred.isDone(d)), Option.getOrElse(() => Effect.succeed(false)));
|
|
579
|
-
if (parkedDone && config.showingFallback) {
|
|
580
|
-
// Parked fiber is ready - switch back to showing its content
|
|
581
|
-
config.showingFallback = false;
|
|
582
|
-
// Mark the fallback child from alternate (previous render) for deletion
|
|
583
|
-
// The fallback fiber is in the alternate's child, not the current fiber's child
|
|
584
|
-
yield* Option.match(fiber.alternate, {
|
|
585
|
-
onNone: () => Effect.void,
|
|
586
|
-
onSome: (alt) => Option.match(alt.child, {
|
|
587
|
-
onNone: () => Effect.void,
|
|
588
|
-
onSome: (fallbackChild) => Effect.gen(function* () {
|
|
589
|
-
fallbackChild.effectTag = Option.some("DELETION");
|
|
590
|
-
yield* Ref.update(runtime.fiberState, (s) => ({
|
|
591
|
-
...s,
|
|
592
|
-
deletions: [...s.deletions, fallbackChild],
|
|
593
|
-
}));
|
|
594
|
-
}),
|
|
595
|
-
}),
|
|
596
|
-
});
|
|
597
|
-
// Reuse the parked fiber directly - it already has latestStreamValue
|
|
598
|
-
const parkedFiber = Option.getOrThrow(config.parkedFiber);
|
|
599
|
-
// Re-parent the parked fiber under this suspense boundary
|
|
600
|
-
parkedFiber.parent = Option.some(fiber);
|
|
601
|
-
parkedFiber.sibling = Option.none();
|
|
602
|
-
parkedFiber.effectTag = Option.some("PLACEMENT");
|
|
603
|
-
// Mark as unparking - this tells updateFunctionComponent to skip re-execution
|
|
604
|
-
parkedFiber.isUnparking = true;
|
|
605
|
-
// Set as child of this boundary
|
|
606
|
-
fiber.child = Option.some(parkedFiber);
|
|
607
|
-
// Clear parked state
|
|
608
|
-
config.parkedFiber = Option.none();
|
|
609
|
-
config.parkedComplete = Option.none();
|
|
610
|
-
// Don't reconcile here - let performUnitOfWork handle it
|
|
611
|
-
// The work loop will process parkedFiber next, and updateFunctionComponent
|
|
612
|
-
// will see isUnparking=true and use the cached latestStreamValue
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
if (config.showingFallback) {
|
|
616
|
-
// Show fallback while children are suspended
|
|
617
|
-
yield* reconcileChildren(fiber, [fallback]);
|
|
618
|
-
}
|
|
619
|
-
else {
|
|
620
|
-
// Show children normally (they'll race against threshold in updateFunctionComponent)
|
|
621
|
-
yield* reconcileChildren(fiber, children || []);
|
|
622
|
-
}
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
// Virtual element types don't create DOM - just reconcile children
|
|
626
|
-
const isVirtualElement = fiberIsVirtualElement(fiber);
|
|
627
|
-
if (!isVirtualElement && Option.isNone(fiber.dom)) {
|
|
628
|
-
fiber.dom = Option.some(yield* createDom(fiber, runtime));
|
|
629
|
-
}
|
|
630
|
-
const children = fiber.props.children;
|
|
631
|
-
yield* reconcileChildren(fiber, children || []);
|
|
632
|
-
});
|
|
633
|
-
const createDom = (fiber, runtime) => Effect.gen(function* () {
|
|
634
|
-
const dom = yield* Option.match(fiber.type, {
|
|
635
|
-
onNone: () => Effect.die("createDom called with no type"),
|
|
636
|
-
onSome: (type) => {
|
|
637
|
-
if (typeof type !== "string") {
|
|
638
|
-
return Effect.die("createDom called on function component");
|
|
639
|
-
}
|
|
640
|
-
const node = type === "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(type);
|
|
641
|
-
return Effect.succeed(node);
|
|
642
|
-
},
|
|
643
|
-
});
|
|
644
|
-
yield* updateDom(dom, {}, fiber.props, fiber, runtime);
|
|
645
|
-
// Handle ref
|
|
646
|
-
Option.match(Option.fromNullable(fiber.props.ref), {
|
|
647
|
-
onNone: () => { },
|
|
648
|
-
onSome: (ref) => {
|
|
649
|
-
if (typeof ref === "object" && "current" in ref) {
|
|
650
|
-
ref.current = dom;
|
|
651
|
-
}
|
|
652
|
-
},
|
|
653
|
-
});
|
|
654
|
-
return dom;
|
|
655
|
-
});
|
|
656
|
-
// =============================================================================
|
|
657
|
-
// Update DOM
|
|
658
|
-
// =============================================================================
|
|
659
|
-
const isNew = (prev, next) => (key) => prev[key] !== next[key];
|
|
660
|
-
const updateDom = (dom, prevProps, nextProps, ownerFiber, runtime) => Effect.gen(function* () {
|
|
661
|
-
const stateRef = runtime.fiberState;
|
|
662
|
-
const element = dom;
|
|
663
|
-
if (element instanceof Text) {
|
|
664
|
-
if (nextProps.nodeValue !== prevProps.nodeValue) {
|
|
665
|
-
const value = nextProps.nodeValue;
|
|
666
|
-
element.nodeValue =
|
|
667
|
-
typeof value === "string" || typeof value === "number" ? String(value) : "";
|
|
668
|
-
}
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
const stateSnapshot = yield* Ref.get(stateRef);
|
|
672
|
-
const el = element;
|
|
673
|
-
const stored = stateSnapshot.listenerStore.get(el) ?? {};
|
|
674
|
-
// Remove old event listeners
|
|
675
|
-
const eventsToRemove = Object.keys(prevProps)
|
|
676
|
-
.filter(isEvent)
|
|
677
|
-
.filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key));
|
|
678
|
-
for (const name of eventsToRemove) {
|
|
679
|
-
const eventType = name.toLowerCase().substring(2);
|
|
680
|
-
const wrapper = stored[eventType];
|
|
681
|
-
if (wrapper) {
|
|
682
|
-
el.removeEventListener(eventType, wrapper);
|
|
683
|
-
delete stored[eventType];
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
// Remove properties that were in prevProps but not in nextProps
|
|
687
|
-
Object.keys(prevProps)
|
|
688
|
-
.filter(isProperty)
|
|
689
|
-
.filter((key) => !(key in nextProps))
|
|
690
|
-
.forEach((name) => {
|
|
691
|
-
if (el instanceof HTMLElement) {
|
|
692
|
-
setDomProperty(el, name, null);
|
|
693
|
-
}
|
|
694
|
-
});
|
|
695
|
-
// Update changed properties
|
|
696
|
-
Object.keys(nextProps)
|
|
697
|
-
.filter(isProperty)
|
|
698
|
-
.filter(isNew(prevProps, nextProps))
|
|
699
|
-
.forEach((name) => {
|
|
700
|
-
if (el instanceof HTMLElement) {
|
|
701
|
-
setDomProperty(el, name, nextProps[name]);
|
|
702
|
-
}
|
|
703
|
-
});
|
|
704
|
-
// Add new event listeners
|
|
705
|
-
Object.keys(nextProps)
|
|
706
|
-
.filter(isEvent)
|
|
707
|
-
.filter(isNew(prevProps, nextProps))
|
|
708
|
-
.forEach((name) => {
|
|
709
|
-
const eventType = name.toLowerCase().substring(2);
|
|
710
|
-
const handler = nextProps[name];
|
|
711
|
-
const wrapper = (event) => {
|
|
712
|
-
const result = handler(event);
|
|
713
|
-
if (Effect.isEffect(result)) {
|
|
714
|
-
// Use runForkWithRuntime to get the full application context
|
|
715
|
-
// This provides Navigator, FibraeRuntime, AtomRegistry, etc.
|
|
716
|
-
const effectWithErrorHandling = result.pipe(Effect.catchAllCause((cause) => {
|
|
717
|
-
// Convert to EventHandlerError with the event type
|
|
718
|
-
const error = new EventHandlerError({
|
|
719
|
-
cause: Cause.squash(cause),
|
|
720
|
-
eventType,
|
|
721
|
-
});
|
|
722
|
-
return ownerFiber ? handleFiberError(ownerFiber, error) : Effect.void;
|
|
723
|
-
}));
|
|
724
|
-
runForkWithRuntime(runtime)(effectWithErrorHandling);
|
|
725
|
-
}
|
|
726
|
-
};
|
|
727
|
-
const existing = stored[eventType];
|
|
728
|
-
if (existing) {
|
|
729
|
-
el.removeEventListener(eventType, existing);
|
|
730
|
-
}
|
|
731
|
-
el.addEventListener(eventType, wrapper);
|
|
732
|
-
stored[eventType] = wrapper;
|
|
733
|
-
});
|
|
734
|
-
stateSnapshot.listenerStore.set(el, stored);
|
|
735
|
-
});
|
|
736
|
-
// =============================================================================
|
|
737
|
-
// Reconcile Children (key-based diffing)
|
|
738
|
-
// =============================================================================
|
|
739
|
-
const reconcileChildren = (wipFiber, elements) => Effect.gen(function* () {
|
|
740
|
-
const runtime = yield* FibraeRuntime;
|
|
741
|
-
const stateRef = runtime.fiberState;
|
|
742
|
-
// Collect old children from alternate
|
|
743
|
-
const oldChildren = [];
|
|
744
|
-
if (Option.isSome(wipFiber.alternate)) {
|
|
745
|
-
let current = wipFiber.alternate.value.child;
|
|
746
|
-
while (Option.isSome(current)) {
|
|
747
|
-
oldChildren.push(current.value);
|
|
748
|
-
current = current.value.sibling;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
const getKey = (props) => Option.fromNullable(props ? props.key : undefined);
|
|
752
|
-
// Build maps for keyed and unkeyed old fibers
|
|
753
|
-
const oldByKey = new Map();
|
|
754
|
-
const oldUnkeyed = [];
|
|
755
|
-
for (const f of oldChildren) {
|
|
756
|
-
const keyOpt = getKey(f.props);
|
|
757
|
-
Option.match(keyOpt, {
|
|
758
|
-
onNone: () => oldUnkeyed.push(f),
|
|
759
|
-
onSome: (key) => oldByKey.set(key, f),
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
const newFibers = [];
|
|
763
|
-
for (const element of elements) {
|
|
764
|
-
let matchedOldOpt = Option.none();
|
|
765
|
-
const keyOpt = getKey(element.props);
|
|
766
|
-
// Try to match by key first
|
|
767
|
-
matchedOldOpt = Option.match(keyOpt, {
|
|
768
|
-
onNone: () => Option.none(),
|
|
769
|
-
onSome: (key) => {
|
|
770
|
-
const maybe = Option.fromNullable(oldByKey.get(key));
|
|
771
|
-
Option.match(maybe, {
|
|
772
|
-
onNone: () => { },
|
|
773
|
-
onSome: () => oldByKey.delete(key),
|
|
774
|
-
});
|
|
775
|
-
return maybe;
|
|
776
|
-
},
|
|
777
|
-
});
|
|
778
|
-
// Fall back to type matching for unkeyed elements
|
|
779
|
-
if (Option.isNone(matchedOldOpt)) {
|
|
780
|
-
const idx = oldUnkeyed.findIndex((f) => Option.match(f.type, {
|
|
781
|
-
onNone: () => false,
|
|
782
|
-
onSome: (fType) => fType === element.type,
|
|
783
|
-
}));
|
|
784
|
-
if (idx >= 0) {
|
|
785
|
-
matchedOldOpt = Option.some(oldUnkeyed[idx]);
|
|
786
|
-
oldUnkeyed.splice(idx, 1);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
// Create new fiber based on match
|
|
790
|
-
const newFiber = yield* Option.match(matchedOldOpt, {
|
|
791
|
-
onNone: () => Effect.succeed(createFiber(Option.some(element.type), element.props, Option.some(wipFiber), Option.none(), Option.some("PLACEMENT"))),
|
|
792
|
-
onSome: (matched) => Effect.gen(function* () {
|
|
793
|
-
const typeMatches = Option.match(matched.type, {
|
|
794
|
-
onNone: () => false,
|
|
795
|
-
onSome: (mType) => mType === element.type,
|
|
796
|
-
});
|
|
797
|
-
if (typeMatches) {
|
|
798
|
-
// UPDATE - reuse DOM, update props
|
|
799
|
-
const fiber = createFiber(matched.type, element.props, Option.some(wipFiber), matchedOldOpt, Option.some("UPDATE"));
|
|
800
|
-
fiber.dom = matched.dom;
|
|
801
|
-
fiber.boundary = matched.boundary;
|
|
802
|
-
fiber.suspense = matched.suspense;
|
|
803
|
-
return fiber;
|
|
804
|
-
}
|
|
805
|
-
else {
|
|
806
|
-
// Type changed - delete old, create new
|
|
807
|
-
matched.effectTag = Option.some("DELETION");
|
|
808
|
-
yield* Ref.update(stateRef, (s) => ({
|
|
809
|
-
...s,
|
|
810
|
-
deletions: [...s.deletions, matched],
|
|
811
|
-
}));
|
|
812
|
-
return createFiber(Option.some(element.type), element.props, Option.some(wipFiber), Option.none(), Option.some("PLACEMENT"));
|
|
813
|
-
}
|
|
814
|
-
}),
|
|
815
|
-
});
|
|
816
|
-
newFibers.push(newFiber);
|
|
817
|
-
}
|
|
818
|
-
// Mark leftover old fibers for deletion
|
|
819
|
-
const leftovers = [...oldByKey.values(), ...oldUnkeyed];
|
|
820
|
-
yield* Effect.forEach(leftovers, (leftover) => Effect.gen(function* () {
|
|
821
|
-
leftover.effectTag = Option.some("DELETION");
|
|
822
|
-
yield* Ref.update(stateRef, (s) => ({
|
|
823
|
-
...s,
|
|
824
|
-
deletions: [...s.deletions, leftover],
|
|
825
|
-
}));
|
|
826
|
-
}), { discard: true });
|
|
827
|
-
// Link new fibers as child/sibling chain
|
|
828
|
-
linkFibersAsSiblings(newFibers, wipFiber);
|
|
829
|
-
});
|
|
830
|
-
// =============================================================================
|
|
831
|
-
// Commit Phase
|
|
832
|
-
// =============================================================================
|
|
833
|
-
const deleteFiber = (fiber) => Effect.gen(function* () {
|
|
834
|
-
// Close component scope (unless fiber is parked - its scope must stay alive)
|
|
835
|
-
if (!fiber.isParked && Option.isSome(fiber.componentScope)) {
|
|
836
|
-
yield* Scope.close(fiber.componentScope.value, Exit.void);
|
|
837
|
-
}
|
|
838
|
-
// Recursively delete children
|
|
839
|
-
if (Option.isSome(fiber.child)) {
|
|
840
|
-
yield* deleteFiber(fiber.child.value);
|
|
841
|
-
}
|
|
842
|
-
});
|
|
843
|
-
const commitDeletion = (fiber, domParent) => Option.match(fiber.dom, {
|
|
844
|
-
onSome: (dom) => Effect.sync(() => {
|
|
845
|
-
domParent.removeChild(dom);
|
|
846
|
-
}),
|
|
847
|
-
onNone: () =>
|
|
848
|
-
// Function component - find DOM children
|
|
849
|
-
Effect.iterate(fiber.child, {
|
|
850
|
-
while: (opt) => Option.isSome(opt),
|
|
851
|
-
body: (childOpt) => Effect.gen(function* () {
|
|
852
|
-
const child = childOpt.value;
|
|
853
|
-
yield* commitDeletion(child, domParent);
|
|
854
|
-
return child.sibling;
|
|
855
|
-
}),
|
|
856
|
-
}),
|
|
857
|
-
});
|
|
858
|
-
const commitRoot = (runtime) => Effect.gen(function* () {
|
|
859
|
-
const stateRef = runtime.fiberState;
|
|
860
|
-
const currentState = yield* Ref.get(stateRef);
|
|
861
|
-
// Process deletions first
|
|
862
|
-
yield* Effect.forEach(currentState.deletions, (fiber) => Effect.gen(function* () {
|
|
863
|
-
const domParent = findDomParent(fiber);
|
|
864
|
-
if (Option.isSome(domParent)) {
|
|
865
|
-
yield* commitDeletion(fiber, domParent.value);
|
|
866
|
-
}
|
|
867
|
-
yield* deleteFiber(fiber);
|
|
868
|
-
}), { discard: true });
|
|
869
|
-
// Commit work starting from wipRoot.child
|
|
870
|
-
const firstChild = currentState.wipRoot.pipe(Option.flatMap((root) => root.child), Option.getOrUndefined);
|
|
871
|
-
if (firstChild) {
|
|
872
|
-
yield* commitWork(firstChild, runtime);
|
|
873
|
-
}
|
|
874
|
-
// Swap wipRoot to currentRoot
|
|
875
|
-
yield* Ref.update(stateRef, (s) => ({
|
|
876
|
-
...s,
|
|
877
|
-
currentRoot: currentState.wipRoot,
|
|
878
|
-
wipRoot: Option.none(),
|
|
879
|
-
deletions: [],
|
|
880
|
-
}));
|
|
881
|
-
});
|
|
882
|
-
const commitWork = (fiber, runtime) => Effect.gen(function* () {
|
|
883
|
-
// KEY INSIGHT: If fiber has no DOM (function component), just process children
|
|
884
|
-
if (Option.isNone(fiber.dom)) {
|
|
885
|
-
if (Option.isSome(fiber.child)) {
|
|
886
|
-
yield* commitWork(fiber.child.value, runtime);
|
|
887
|
-
}
|
|
888
|
-
// Signal mounted after subtree commits (for function components)
|
|
889
|
-
yield* Option.match(fiber.mountedDeferred, {
|
|
890
|
-
onNone: () => Effect.void,
|
|
891
|
-
onSome: (deferred) => Effect.gen(function* () {
|
|
892
|
-
const done = yield* Deferred.isDone(deferred);
|
|
893
|
-
if (!done) {
|
|
894
|
-
yield* Deferred.succeed(deferred, undefined);
|
|
895
|
-
}
|
|
896
|
-
}),
|
|
897
|
-
});
|
|
898
|
-
if (Option.isSome(fiber.sibling)) {
|
|
899
|
-
yield* commitWork(fiber.sibling.value, runtime);
|
|
900
|
-
}
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
// Find DOM parent by walking up to nearest fiber with dom
|
|
904
|
-
const domParentOpt = findDomParent(fiber);
|
|
905
|
-
if (Option.isNone(domParentOpt)) {
|
|
906
|
-
// No DOM parent found - continue with children/siblings
|
|
907
|
-
if (Option.isSome(fiber.child)) {
|
|
908
|
-
yield* commitWork(fiber.child.value, runtime);
|
|
909
|
-
}
|
|
910
|
-
if (Option.isSome(fiber.sibling)) {
|
|
911
|
-
yield* commitWork(fiber.sibling.value, runtime);
|
|
912
|
-
}
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
const domParent = domParentOpt.value;
|
|
916
|
-
// Process effect tag
|
|
917
|
-
const tag = fiber.effectTag.pipe(Option.getOrUndefined);
|
|
918
|
-
if (tag === "PLACEMENT") {
|
|
919
|
-
// Append DOM node
|
|
920
|
-
if (Option.isSome(fiber.dom)) {
|
|
921
|
-
domParent.appendChild(fiber.dom.value);
|
|
922
|
-
}
|
|
923
|
-
// Signal first child committed (for Suspense)
|
|
924
|
-
const deferred = fiber.parent.pipe(Option.flatMap((p) => p.childFirstCommitDeferred), Option.getOrUndefined);
|
|
925
|
-
if (deferred) {
|
|
926
|
-
const done = yield* Deferred.isDone(deferred);
|
|
927
|
-
if (!done) {
|
|
928
|
-
yield* Deferred.succeed(deferred, undefined);
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
else if (tag === "UPDATE") {
|
|
933
|
-
const prevProps = fiber.alternate.pipe(Option.map((alt) => alt.props), Option.getOrElse(() => ({})));
|
|
934
|
-
if (Option.isSome(fiber.dom)) {
|
|
935
|
-
yield* updateDom(fiber.dom.value, prevProps, fiber.props, fiber, runtime);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
else if (tag === "DELETION") {
|
|
939
|
-
yield* commitDeletion(fiber, domParent);
|
|
940
|
-
return;
|
|
941
|
-
}
|
|
942
|
-
// Continue with children and siblings
|
|
943
|
-
if (Option.isSome(fiber.child)) {
|
|
944
|
-
yield* commitWork(fiber.child.value, runtime);
|
|
945
|
-
}
|
|
946
|
-
if (Option.isSome(fiber.sibling)) {
|
|
947
|
-
yield* commitWork(fiber.sibling.value, runtime);
|
|
948
|
-
}
|
|
949
|
-
});
|
|
24
|
+
import {} from "./live/atom.js";
|
|
25
|
+
import { createFiber, getComponentScopeService, linkFibersAsSiblings } from "./fiber-tree.js";
|
|
26
|
+
import { handleFiberError } from "./fiber-boundary.js";
|
|
27
|
+
import { performUnitOfWork, resubscribeFiber, subscribeComponentStream, subscribeFiberAtoms, activateLiveAtoms, reconcileChildren, } from "./fiber-update.js";
|
|
28
|
+
import { commitRoot } from "./fiber-commit.js";
|
|
950
29
|
// =============================================================================
|
|
951
30
|
// Work Loop
|
|
952
31
|
// =============================================================================
|
|
@@ -968,6 +47,46 @@ const workLoop = (runtime) => Effect.gen(function* () {
|
|
|
968
47
|
}
|
|
969
48
|
});
|
|
970
49
|
// =============================================================================
|
|
50
|
+
// Mailbox Consumer (batched re-render loop)
|
|
51
|
+
// =============================================================================
|
|
52
|
+
/**
|
|
53
|
+
* Consumer loop that processes batched re-render requests from the Mailbox.
|
|
54
|
+
*
|
|
55
|
+
* Runs as a long-lived fiber: waits for items via takeAll, then reconciles
|
|
56
|
+
* the full tree. Sequential processing guarantees no concurrent workLoops.
|
|
57
|
+
*/
|
|
58
|
+
const renderMailboxConsumer = (runtime) => Effect.gen(function* () {
|
|
59
|
+
const stateRef = runtime.fiberState;
|
|
60
|
+
// eslint-disable-next-line no-constant-condition
|
|
61
|
+
while (true) {
|
|
62
|
+
// takeAll suspends until items are available, then returns all queued items
|
|
63
|
+
const [_fibers, done] = yield* runtime.renderMailbox.takeAll;
|
|
64
|
+
if (done)
|
|
65
|
+
break;
|
|
66
|
+
const stateSnapshot = yield* Ref.get(stateRef);
|
|
67
|
+
yield* Option.match(stateSnapshot.currentRoot, {
|
|
68
|
+
onNone: () => Effect.void,
|
|
69
|
+
onSome: (currentRoot) => Effect.gen(function* () {
|
|
70
|
+
yield* Ref.update(stateRef, (s) => ({
|
|
71
|
+
...s,
|
|
72
|
+
wipRoot: Option.some(createFiber(currentRoot.type, currentRoot.props, Option.none(), Option.some(currentRoot), Option.none())),
|
|
73
|
+
deletions: [],
|
|
74
|
+
}));
|
|
75
|
+
// Copy dom from currentRoot to wipRoot
|
|
76
|
+
const newState = yield* Ref.get(stateRef);
|
|
77
|
+
newState.wipRoot.pipe(Option.map((wip) => {
|
|
78
|
+
wip.dom = currentRoot.dom;
|
|
79
|
+
}));
|
|
80
|
+
yield* Ref.update(stateRef, (s) => ({
|
|
81
|
+
...s,
|
|
82
|
+
nextUnitOfWork: s.wipRoot,
|
|
83
|
+
}));
|
|
84
|
+
yield* workLoop(runtime);
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
// =============================================================================
|
|
971
90
|
// Main Render Function
|
|
972
91
|
// =============================================================================
|
|
973
92
|
/**
|
|
@@ -999,8 +118,8 @@ export const renderFiber = (element, container) => Effect.gen(function* () {
|
|
|
999
118
|
nextUnitOfWork: newState.wipRoot,
|
|
1000
119
|
}));
|
|
1001
120
|
yield* workLoop(runtime);
|
|
1002
|
-
//
|
|
1003
|
-
return yield*
|
|
121
|
+
// Process re-render requests from the Mailbox (runs forever)
|
|
122
|
+
return yield* renderMailboxConsumer(runtime);
|
|
1004
123
|
});
|
|
1005
124
|
// =============================================================================
|
|
1006
125
|
// Hydration Support
|
|
@@ -1078,27 +197,23 @@ export const hydrateFiber = (element, container) => Effect.gen(function* () {
|
|
|
1078
197
|
wipRoot: Option.none(),
|
|
1079
198
|
deletions: [],
|
|
1080
199
|
}));
|
|
1081
|
-
//
|
|
1082
|
-
return yield*
|
|
200
|
+
// Process re-render requests from the Mailbox (runs forever)
|
|
201
|
+
return yield* renderMailboxConsumer(runtime);
|
|
1083
202
|
});
|
|
1084
203
|
/**
|
|
1085
204
|
* Hydrate multiple vElements against DOM nodes using cursor-based walking.
|
|
1086
205
|
* Returns the next DOM cursor position after all children are hydrated.
|
|
1087
206
|
*/
|
|
1088
207
|
const hydrateChildren = (parentFiber, vElements, startCursor, runtime) => Effect.gen(function* () {
|
|
1089
|
-
|
|
1090
|
-
const fibers = []
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
fibers.push(fiber);
|
|
1099
|
-
cursor = nextCursor;
|
|
1100
|
-
}
|
|
1101
|
-
// Link fibers
|
|
208
|
+
// Walk vElements and DOM cursor together using Effect.reduce
|
|
209
|
+
const { fibers, cursor } = yield* Effect.reduce(vElements, { fibers: [], cursor: startCursor }, (acc, vElement) => {
|
|
210
|
+
if (Option.isNone(acc.cursor))
|
|
211
|
+
return Effect.succeed(acc);
|
|
212
|
+
return Effect.map(hydrateElement(parentFiber, vElement, acc.cursor.value, runtime), ({ fiber, nextCursor }) => ({
|
|
213
|
+
fibers: [...acc.fibers, fiber],
|
|
214
|
+
cursor: nextCursor,
|
|
215
|
+
}));
|
|
216
|
+
});
|
|
1102
217
|
linkFibersAsSiblings(fibers, parentFiber);
|
|
1103
218
|
return cursor;
|
|
1104
219
|
});
|
|
@@ -1107,6 +222,25 @@ const hydrateChildren = (parentFiber, vElements, startCursor, runtime) => Effect
|
|
|
1107
222
|
* Returns the created fiber and the next DOM cursor position.
|
|
1108
223
|
*/
|
|
1109
224
|
const hydrateElement = (parentFiber, vElement, domNode, runtime) => Effect.gen(function* () {
|
|
225
|
+
// Detect hydration mismatch: VElement type vs DOM node tag
|
|
226
|
+
if (typeof vElement.type === "string" &&
|
|
227
|
+
vElement.type !== "TEXT_ELEMENT" &&
|
|
228
|
+
vElement.type !== "FRAGMENT" &&
|
|
229
|
+
vElement.type !== "SUSPENSE" &&
|
|
230
|
+
vElement.type !== "BOUNDARY") {
|
|
231
|
+
if (domNode.nodeType === Node.ELEMENT_NODE) {
|
|
232
|
+
const expected = vElement.type.toUpperCase();
|
|
233
|
+
const actual = domNode.tagName;
|
|
234
|
+
if (expected !== actual) {
|
|
235
|
+
yield* Effect.logError(`Hydration mismatch: expected <${vElement.type}> but found <${actual.toLowerCase()}>. ` +
|
|
236
|
+
`SSR and client component trees differ.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else if (domNode.nodeType === Node.TEXT_NODE) {
|
|
240
|
+
yield* Effect.logError(`Hydration mismatch: expected <${vElement.type}> but found text node "${domNode.textContent?.substring(0, 30)}". ` +
|
|
241
|
+
`SSR and client component trees differ.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
1110
244
|
const fiber = createFiber(Option.some(vElement.type), vElement.props, Option.some(parentFiber), Option.none(), Option.none());
|
|
1111
245
|
let nextCursor;
|
|
1112
246
|
if (typeof vElement.type === "function") {
|
|
@@ -1122,6 +256,74 @@ const hydrateElement = (parentFiber, vElement, domNode, runtime) => Effect.gen(f
|
|
|
1122
256
|
// Fragment - children consume DOM nodes starting from current cursor
|
|
1123
257
|
nextCursor = yield* hydrateChildren(fiber, vElement.props.children || [], Option.some(domNode), runtime);
|
|
1124
258
|
}
|
|
259
|
+
else if (vElement.type === "SUSPENSE") {
|
|
260
|
+
// Suspense boundary - DOM has comment markers from SSR
|
|
261
|
+
// Initialize suspense config on the fiber
|
|
262
|
+
const fallback = vElement.props.fallback;
|
|
263
|
+
const threshold = vElement.props.threshold ?? 100;
|
|
264
|
+
fiber.suspense = Option.some({
|
|
265
|
+
fallback,
|
|
266
|
+
threshold,
|
|
267
|
+
showingFallback: false,
|
|
268
|
+
parkedFiber: Option.none(),
|
|
269
|
+
parkedComplete: Option.none(),
|
|
270
|
+
});
|
|
271
|
+
const children = (vElement.props.children || []);
|
|
272
|
+
if (domNode.nodeType === Node.COMMENT_NODE &&
|
|
273
|
+
domNode.data.includes("fibrae:sus:resolved")) {
|
|
274
|
+
// Resolved: hydrate children against DOM nodes between markers
|
|
275
|
+
// Use hydrateChildren for proper fiber linking, but skip comment markers
|
|
276
|
+
const firstContentNode = getNextHydratableSibling(domNode);
|
|
277
|
+
// hydrateChildren links fibers as siblings and sets fiber.child
|
|
278
|
+
const afterChildren = yield* hydrateChildren(fiber, children, firstContentNode, runtime);
|
|
279
|
+
// Skip past closing marker
|
|
280
|
+
if (Option.isSome(afterChildren)) {
|
|
281
|
+
const maybeClosing = afterChildren.value;
|
|
282
|
+
if (maybeClosing.nodeType === Node.COMMENT_NODE &&
|
|
283
|
+
maybeClosing.data.includes("/fibrae:sus")) {
|
|
284
|
+
nextCursor = getNextHydratableSibling(maybeClosing);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
nextCursor = afterChildren;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
nextCursor = Option.none();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else if (domNode.nodeType === Node.COMMENT_NODE &&
|
|
295
|
+
domNode.data.includes("fibrae:sus:fallback")) {
|
|
296
|
+
// Fallback: remove fallback DOM and render Suspense fresh
|
|
297
|
+
const parent = domNode.parentNode;
|
|
298
|
+
// Collect nodes between markers using a scan loop
|
|
299
|
+
const { nodesToRemove, closingMarker } = (() => {
|
|
300
|
+
const nodes = [domNode];
|
|
301
|
+
let current = domNode.nextSibling;
|
|
302
|
+
while (current) {
|
|
303
|
+
nodes.push(current);
|
|
304
|
+
if (current.nodeType === Node.COMMENT_NODE &&
|
|
305
|
+
current.data.includes("/fibrae:sus")) {
|
|
306
|
+
return { nodesToRemove: nodes, closingMarker: current };
|
|
307
|
+
}
|
|
308
|
+
current = current.nextSibling;
|
|
309
|
+
}
|
|
310
|
+
return { nodesToRemove: nodes, closingMarker: null };
|
|
311
|
+
})();
|
|
312
|
+
// Cursor after the closing marker
|
|
313
|
+
nextCursor = closingMarker ? getNextHydratableSibling(closingMarker) : Option.none();
|
|
314
|
+
// Remove fallback DOM
|
|
315
|
+
nodesToRemove.forEach((node) => parent.removeChild(node));
|
|
316
|
+
// Mark as showing fallback initially, then reconcile children
|
|
317
|
+
// (the normal render path will handle the Suspense race)
|
|
318
|
+
const suspenseConfig = Option.getOrThrow(fiber.suspense);
|
|
319
|
+
suspenseConfig.showingFallback = false;
|
|
320
|
+
yield* reconcileChildren(fiber, children);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
// No Suspense markers — treat as normal render
|
|
324
|
+
nextCursor = yield* hydrateChildren(fiber, children, Option.some(domNode), runtime);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
1125
327
|
else {
|
|
1126
328
|
// Host element - adopt DOM node and hydrate children
|
|
1127
329
|
const el = domNode;
|
|
@@ -1130,9 +332,9 @@ const hydrateElement = (parentFiber, vElement, domNode, runtime) => Effect.gen(f
|
|
|
1130
332
|
if (Option.isNone(fiber.renderContext) && Option.isSome(fiber.parent)) {
|
|
1131
333
|
fiber.renderContext = fiber.parent.value.renderContext;
|
|
1132
334
|
}
|
|
1133
|
-
// Attach event listeners
|
|
1134
|
-
|
|
1135
|
-
attachEventListeners(el, vElement.props, runtime, (cause) => handleFiberError(fiber, cause));
|
|
335
|
+
// Attach event listeners with listenerStore tracking for proper cleanup on re-render
|
|
336
|
+
const stateSnapshot = yield* Ref.get(runtime.fiberState);
|
|
337
|
+
attachEventListeners(el, vElement.props, runtime, (cause) => handleFiberError(fiber, cause), stateSnapshot.listenerStore);
|
|
1136
338
|
// Handle ref
|
|
1137
339
|
const ref = vElement.props.ref;
|
|
1138
340
|
if (ref && typeof ref === "object" && "current" in ref) {
|
|
@@ -1152,14 +354,15 @@ const hydrateElement = (parentFiber, vElement, domNode, runtime) => Effect.gen(f
|
|
|
1152
354
|
*/
|
|
1153
355
|
const hydrateFunctionComponent = (fiber, vElement, domNode, runtime) => Effect.gen(function* () {
|
|
1154
356
|
// Capture current context during render phase for event handlers in commit phase
|
|
1155
|
-
const currentContext =
|
|
357
|
+
const currentContext = yield* FiberRef.get(FiberRef.currentContext);
|
|
1156
358
|
fiber.renderContext = Option.some(currentContext);
|
|
1157
359
|
// Create scope for this component FIRST so it's available in context
|
|
1158
360
|
yield* resubscribeFiber(fiber);
|
|
1159
361
|
const componentScopeService = yield* getComponentScopeService(fiber, "Expected componentScope");
|
|
1160
362
|
// Set up atom tracking
|
|
1161
363
|
const accessedAtoms = new Set();
|
|
1162
|
-
const
|
|
364
|
+
const accessedLiveAtoms = new Set();
|
|
365
|
+
const trackingRegistry = makeTrackingRegistry(runtime.registry, accessedAtoms, accessedLiveAtoms);
|
|
1163
366
|
fiber.accessedAtoms = Option.some(accessedAtoms);
|
|
1164
367
|
// Build context with tracking registry AND ComponentScope
|
|
1165
368
|
const contextWithTracking = Context.add(Context.add(currentContext, ComponentScope, componentScopeService), AtomRegistry.AtomRegistry, trackingRegistry);
|
|
@@ -1184,6 +387,10 @@ const hydrateFunctionComponent = (fiber, vElement, domNode, runtime) => Effect.g
|
|
|
1184
387
|
yield* Deferred.succeed(componentScopeService.mounted, undefined);
|
|
1185
388
|
// Subscribe to atom changes
|
|
1186
389
|
yield* subscribeFiberAtoms(fiber, accessedAtoms, runtime);
|
|
390
|
+
// Activate SSE connections for any live atoms
|
|
391
|
+
if (accessedLiveAtoms.size > 0) {
|
|
392
|
+
yield* activateLiveAtoms(accessedLiveAtoms, currentContext, runtime, componentScopeService.scope);
|
|
393
|
+
}
|
|
1187
394
|
return nextCursor;
|
|
1188
395
|
});
|
|
1189
396
|
//# sourceMappingURL=fiber-render.js.map
|