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