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.
Files changed (138) hide show
  1. package/dist/atom-utils.d.ts +52 -0
  2. package/dist/atom-utils.js +64 -0
  3. package/dist/atom-utils.js.map +1 -0
  4. package/dist/cli/build.d.ts +34 -0
  5. package/dist/cli/build.js +92 -0
  6. package/dist/cli/build.js.map +1 -0
  7. package/dist/cli/cli.d.ts +10 -0
  8. package/dist/cli/cli.js +43 -0
  9. package/dist/cli/cli.js.map +1 -0
  10. package/dist/cli/config.d.ts +19 -0
  11. package/dist/cli/config.js +5 -0
  12. package/dist/cli/config.js.map +1 -0
  13. package/dist/cli/html.d.ts +19 -0
  14. package/dist/cli/html.js +95 -0
  15. package/dist/cli/html.js.map +1 -0
  16. package/dist/cli/index.d.ts +6 -0
  17. package/dist/cli/index.js +4 -0
  18. package/dist/cli/index.js.map +1 -0
  19. package/dist/cli/vite-plugin.d.ts +9 -0
  20. package/dist/cli/vite-plugin.js +143 -0
  21. package/dist/cli/vite-plugin.js.map +1 -0
  22. package/dist/components.d.ts +28 -30
  23. package/dist/components.js +35 -53
  24. package/dist/components.js.map +1 -1
  25. package/dist/core.js +7 -10
  26. package/dist/core.js.map +1 -1
  27. package/dist/dom.d.ts +25 -6
  28. package/dist/dom.js +161 -27
  29. package/dist/dom.js.map +1 -1
  30. package/dist/fiber-boundary.d.ts +39 -0
  31. package/dist/fiber-boundary.js +151 -0
  32. package/dist/fiber-boundary.js.map +1 -0
  33. package/dist/fiber-commit.d.ts +27 -0
  34. package/dist/fiber-commit.js +247 -0
  35. package/dist/fiber-commit.js.map +1 -0
  36. package/dist/fiber-render.d.ts +9 -9
  37. package/dist/fiber-render.js +165 -958
  38. package/dist/fiber-render.js.map +1 -1
  39. package/dist/fiber-tree.d.ts +77 -0
  40. package/dist/fiber-tree.js +152 -0
  41. package/dist/fiber-tree.js.map +1 -0
  42. package/dist/fiber-update.d.ts +46 -0
  43. package/dist/fiber-update.js +521 -0
  44. package/dist/fiber-update.js.map +1 -0
  45. package/dist/h.js.map +1 -1
  46. package/dist/index.d.ts +3 -2
  47. package/dist/index.js +4 -2
  48. package/dist/index.js.map +1 -1
  49. package/dist/jsx-runtime/index.d.ts +368 -2
  50. package/dist/live/atom.d.ts +31 -0
  51. package/dist/live/atom.js +33 -0
  52. package/dist/live/atom.js.map +1 -0
  53. package/dist/live/client.d.ts +50 -0
  54. package/dist/live/client.js +90 -0
  55. package/dist/live/client.js.map +1 -0
  56. package/dist/live/codec.d.ts +39 -0
  57. package/dist/live/codec.js +41 -0
  58. package/dist/live/codec.js.map +1 -0
  59. package/dist/live/config.d.ts +13 -0
  60. package/dist/live/config.js +11 -0
  61. package/dist/live/config.js.map +1 -0
  62. package/dist/live/index.d.ts +25 -0
  63. package/dist/live/index.js +19 -0
  64. package/dist/live/index.js.map +1 -0
  65. package/dist/live/server.d.ts +83 -0
  66. package/dist/live/server.js +106 -0
  67. package/dist/live/server.js.map +1 -0
  68. package/dist/live/sse-stream.d.ts +14 -0
  69. package/dist/live/sse-stream.js +30 -0
  70. package/dist/live/sse-stream.js.map +1 -0
  71. package/dist/live/types.d.ts +40 -0
  72. package/dist/live/types.js +20 -0
  73. package/dist/live/types.js.map +1 -0
  74. package/dist/mdx/index.d.ts +125 -0
  75. package/dist/mdx/index.js +137 -0
  76. package/dist/mdx/index.js.map +1 -0
  77. package/dist/mdx/parse.d.ts +42 -0
  78. package/dist/mdx/parse.js +147 -0
  79. package/dist/mdx/parse.js.map +1 -0
  80. package/dist/mdx/render.d.ts +23 -0
  81. package/dist/mdx/render.js +263 -0
  82. package/dist/mdx/render.js.map +1 -0
  83. package/dist/router/Form.d.ts +90 -0
  84. package/dist/router/Form.js +166 -0
  85. package/dist/router/Form.js.map +1 -0
  86. package/dist/router/History.d.ts +4 -9
  87. package/dist/router/History.js +0 -8
  88. package/dist/router/History.js.map +1 -1
  89. package/dist/router/Link.d.ts +27 -28
  90. package/dist/router/Link.js +50 -119
  91. package/dist/router/Link.js.map +1 -1
  92. package/dist/router/Navigator.d.ts +25 -33
  93. package/dist/router/Navigator.js +41 -149
  94. package/dist/router/Navigator.js.map +1 -1
  95. package/dist/router/Route.d.ts +24 -7
  96. package/dist/router/Route.js +42 -27
  97. package/dist/router/Route.js.map +1 -1
  98. package/dist/router/Router.d.ts +27 -19
  99. package/dist/router/Router.js +112 -120
  100. package/dist/router/Router.js.map +1 -1
  101. package/dist/router/RouterBuilder.d.ts +171 -36
  102. package/dist/router/RouterBuilder.js +101 -39
  103. package/dist/router/RouterBuilder.js.map +1 -1
  104. package/dist/router/RouterOutlet.d.ts +1 -18
  105. package/dist/router/RouterOutlet.js +60 -48
  106. package/dist/router/RouterOutlet.js.map +1 -1
  107. package/dist/router/RouterState.d.ts +1 -1
  108. package/dist/router/index.d.ts +8 -5
  109. package/dist/router/index.js +6 -3
  110. package/dist/router/index.js.map +1 -1
  111. package/dist/router/register.d.ts +37 -0
  112. package/dist/router/register.js +18 -0
  113. package/dist/router/register.js.map +1 -0
  114. package/dist/router/utils.d.ts +36 -0
  115. package/dist/router/utils.js +48 -0
  116. package/dist/router/utils.js.map +1 -0
  117. package/dist/runtime.d.ts +11 -8
  118. package/dist/runtime.js +20 -2
  119. package/dist/runtime.js.map +1 -1
  120. package/dist/server.d.ts +2 -2
  121. package/dist/server.js +15 -29
  122. package/dist/server.js.map +1 -1
  123. package/dist/shared.d.ts +61 -62
  124. package/dist/shared.js +51 -13
  125. package/dist/shared.js.map +1 -1
  126. package/dist/tracking.d.ts +4 -3
  127. package/dist/tracking.js +6 -1
  128. package/dist/tracking.js.map +1 -1
  129. package/package.json +45 -7
  130. package/dist/hydration.d.ts +0 -30
  131. package/dist/hydration.js +0 -355
  132. package/dist/hydration.js.map +0 -1
  133. package/dist/render.d.ts +0 -19
  134. package/dist/render.js +0 -285
  135. package/dist/render.js.map +0 -1
  136. package/dist/scope-utils.d.ts +0 -14
  137. package/dist/scope-utils.js +0 -29
  138. package/dist/scope-utils.js.map +0 -1
@@ -1,952 +1,31 @@
1
1
  /**
2
- * Fiber-based rendering implementation.
2
+ * Fiber-based rendering — public API and orchestration.
3
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!
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
- * This fixes SSR hydration by producing identical DOM structure on server and client.
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, RenderError, StreamError, EventHandlerError, isEvent, isProperty, isStream, } from "./shared.js";
24
- import { FibraeRuntime, runForkWithRuntime } from "./runtime.js";
25
- import { setDomProperty, attachEventListeners } from "./dom.js";
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
- // Stream Subscription Helper
29
- // =============================================================================
30
- /**
31
- * Subscribe to a component's output stream, handling first value and subsequent emissions.
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
- // Keep runtime alive
1003
- return yield* Effect.never;
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
- // Keep runtime alive
1082
- return yield* Effect.never;
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
- let cursor = startCursor;
1090
- const fibers = [];
1091
- for (const vElement of vElements) {
1092
- if (Option.isNone(cursor)) {
1093
- // No more DOM nodes but we have more vElements - mismatch
1094
- // For now, just skip (could throw HydrationMismatch in strict mode)
1095
- break;
1096
- }
1097
- const { fiber, nextCursor } = yield* hydrateElement(parentFiber, vElement, cursor.value, runtime);
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 - uses runForkWithRuntime internally for full context
1134
- // Pass error callback to trigger ErrorBoundary on event handler failures
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 = (yield* FiberRef.get(FiberRef.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 trackingRegistry = makeTrackingRegistry(runtime.registry, accessedAtoms);
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