fibrae 0.1.0 → 0.1.2

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 (45) hide show
  1. package/dist/components.d.ts +45 -11
  2. package/dist/components.js +80 -18
  3. package/dist/components.js.map +1 -1
  4. package/dist/core.js.map +1 -1
  5. package/dist/dom.d.ts +4 -1
  6. package/dist/dom.js +15 -2
  7. package/dist/dom.js.map +1 -1
  8. package/dist/fiber-render.d.ts +5 -3
  9. package/dist/fiber-render.js +197 -114
  10. package/dist/fiber-render.js.map +1 -1
  11. package/dist/h.d.ts +5 -10
  12. package/dist/h.js +9 -3
  13. package/dist/h.js.map +1 -1
  14. package/dist/hydration.js +34 -48
  15. package/dist/hydration.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +2 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/jsx-runtime/index.d.ts +6 -5
  20. package/dist/jsx-runtime/index.js +4 -4
  21. package/dist/jsx-runtime/index.js.map +1 -1
  22. package/dist/render.js +20 -60
  23. package/dist/render.js.map +1 -1
  24. package/dist/router/History.js.map +1 -1
  25. package/dist/router/Link.d.ts +2 -2
  26. package/dist/router/Link.js +8 -4
  27. package/dist/router/Link.js.map +1 -1
  28. package/dist/router/Navigator.js +1 -3
  29. package/dist/router/Navigator.js.map +1 -1
  30. package/dist/router/Route.js +1 -1
  31. package/dist/router/Route.js.map +1 -1
  32. package/dist/router/Router.js +1 -3
  33. package/dist/router/Router.js.map +1 -1
  34. package/dist/router/RouterBuilder.js.map +1 -1
  35. package/dist/router/RouterState.js.map +1 -1
  36. package/dist/runtime.js +9 -2
  37. package/dist/runtime.js.map +1 -1
  38. package/dist/scope-utils.js.map +1 -1
  39. package/dist/server.js +16 -23
  40. package/dist/server.js.map +1 -1
  41. package/dist/shared.d.ts +85 -10
  42. package/dist/shared.js +34 -3
  43. package/dist/shared.js.map +1 -1
  44. package/dist/tracking.js +1 -1
  45. package/package.json +27 -23
@@ -20,11 +20,10 @@ import * as Context from "effect/Context";
20
20
  import * as FiberRef from "effect/FiberRef";
21
21
  import * as Cause from "effect/Cause";
22
22
  import { Atom, Registry as AtomRegistry } from "@effect-atom/atom";
23
- import { isEvent, isProperty, isStream, } from "./shared.js";
23
+ import { RenderError, StreamError, EventHandlerError, isEvent, isProperty, isStream, } from "./shared.js";
24
24
  import { FibraeRuntime, runForkWithRuntime } from "./runtime.js";
25
25
  import { setDomProperty, attachEventListeners } from "./dom.js";
26
26
  import { normalizeToStream, makeTrackingRegistry } from "./tracking.js";
27
- import { h } from "./h.js";
28
27
  // =============================================================================
29
28
  // Stream Subscription Helper
30
29
  // =============================================================================
@@ -56,11 +55,17 @@ const subscribeComponentStream = (stream, fiberRef, scope) => Effect.gen(functio
56
55
  }
57
56
  })).pipe(Effect.catchAllCause((cause) => Effect.gen(function* () {
58
57
  const done = yield* Deferred.isDone(firstValueDeferred);
58
+ // Convert to StreamError with the correct phase
59
+ const streamError = new StreamError({
60
+ cause: Cause.squash(cause),
61
+ phase: done ? "after-first-emission" : "before-first-emission",
62
+ });
59
63
  if (!done) {
60
- yield* Deferred.failCause(firstValueDeferred, cause);
64
+ // Fail the deferred with StreamError so awaiting code gets the typed error
65
+ yield* Deferred.fail(firstValueDeferred, streamError);
61
66
  }
62
67
  const currentFiber = fiberRef.current;
63
- yield* handleFiberError(currentFiber, cause);
68
+ yield* handleFiberError(currentFiber, streamError);
64
69
  })));
65
70
  yield* Effect.forkIn(subscription, scope);
66
71
  return firstValueDeferred;
@@ -83,7 +88,7 @@ const createFiber = (type, props, parent, alternate, effectTag) => ({
83
88
  childFirstCommitDeferred: Option.none(),
84
89
  fiberRef: Option.none(),
85
90
  isMultiEmissionStream: false,
86
- errorBoundary: Option.none(),
91
+ boundary: Option.none(),
87
92
  suspense: Option.none(),
88
93
  renderContext: Option.none(),
89
94
  isParked: false,
@@ -101,8 +106,7 @@ const fiberTypeIsFunction = (fiber) => fiber.type.pipe(Option.map((t) => typeof
101
106
  * Check if a fiber is a virtual element (no DOM node created).
102
107
  * Returns true for root fiber (no type) or FRAGMENT type.
103
108
  */
104
- const fiberIsVirtualElement = (fiber) => fiber.type.pipe(Option.map((t) => t === "FRAGMENT"), Option.getOrElse(() => true) // Root fiber has no type
105
- );
109
+ const fiberIsVirtualElement = (fiber) => fiber.type.pipe(Option.map((t) => t === "FRAGMENT"), Option.getOrElse(() => true));
106
110
  /**
107
111
  * Get a fiber's required componentScope or die with a message.
108
112
  */
@@ -189,19 +193,6 @@ const processBatch = () => Effect.gen(function* () {
189
193
  // =============================================================================
190
194
  // Fiber Tree Walking Helpers
191
195
  // =============================================================================
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
196
  /**
206
197
  * Walk up the fiber tree from the starting fiber's parent, returning the first
207
198
  * ancestor that matches the predicate (excludes the starting fiber itself).
@@ -239,40 +230,55 @@ const findDomParent = (fiber) => {
239
230
  // =============================================================================
240
231
  // Error Boundary Support
241
232
  // =============================================================================
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
- }
233
+ /** Find nearest BOUNDARY by walking up the fiber tree */
234
+ const findNearestBoundary = (fiber) => findAncestorExcludingSelf(fiber, (f) => Option.isSome(f.boundary));
235
+ /**
236
+ * Convert a raw cause/error to a ComponentError.
237
+ * If it's already a ComponentError, return as-is.
238
+ * Otherwise, wrap in RenderError as a fallback.
239
+ */
240
+ const toComponentError = (cause) => {
241
+ // Check if it's already a ComponentError
242
+ if (cause instanceof RenderError ||
243
+ cause instanceof StreamError ||
244
+ cause instanceof EventHandlerError) {
245
+ return cause;
271
246
  }
272
- else {
273
- yield* Effect.logError("Unhandled error without ErrorBoundary", cause);
274
- return Option.none();
247
+ // Extract the actual error from Cause if needed
248
+ const actualError = Cause.isCause(cause) ? Cause.squash(cause) : cause;
249
+ if (actualError instanceof RenderError ||
250
+ actualError instanceof StreamError ||
251
+ actualError instanceof EventHandlerError) {
252
+ return actualError;
275
253
  }
254
+ // Wrap unknown errors as RenderError
255
+ return new RenderError({ cause: actualError });
256
+ };
257
+ /**
258
+ * Handle an error by finding the nearest error boundary and invoking its handler.
259
+ *
260
+ * Finds the nearest BOUNDARY and calls its onError callback to fail the stream.
261
+ * The Stream.catchTags in user code will produce the fallback.
262
+ */
263
+ const handleFiberError = (fiber, cause) => Effect.gen(function* () {
264
+ const componentError = toComponentError(cause);
265
+ // Find nearest boundary
266
+ const boundaryOpt = findNearestBoundary(fiber);
267
+ return yield* Option.match(boundaryOpt, {
268
+ onNone: () => Effect.gen(function* () {
269
+ yield* Effect.logError("Unhandled error without any error boundary", componentError);
270
+ return Option.none();
271
+ }),
272
+ onSome: (boundary) => Effect.sync(() => {
273
+ // Call onError to fail the stream
274
+ const cfg = Option.getOrThrow(boundary.boundary);
275
+ cfg.onError(componentError);
276
+ cfg.hasError = true;
277
+ // The stream will fail and catchTags will produce fallback
278
+ // which will trigger a re-render via the stream subscription
279
+ return Option.none();
280
+ }),
281
+ });
276
282
  });
277
283
  // =============================================================================
278
284
  // Suspense Support
@@ -390,14 +396,14 @@ const updateFunctionComponent = (fiber, runtime) => Effect.gen(function* () {
390
396
  return Effect.sync(() => component(fiber.props));
391
397
  },
392
398
  });
393
- // Fast path: if component returns a plain VElement (not Effect/Stream),
399
+ // Fast path: if component returns a plain VElement (not Effect/Stream),
394
400
  // and it's a special element type, just reconcile directly without stream machinery.
395
401
  // This handles wrapper components like Suspense and ErrorBoundary efficiently.
396
402
  if (!Effect.isEffect(output) && !isStream(output)) {
397
403
  const vElement = output;
398
404
  if (typeof vElement === "object" && vElement !== null && "type" in vElement) {
399
405
  const elementType = vElement.type;
400
- if (elementType === "SUSPENSE" || elementType === "ERROR_BOUNDARY" || elementType === "FRAGMENT") {
406
+ if (elementType === "SUSPENSE" || elementType === "FRAGMENT") {
401
407
  // Simple wrapper component - just reconcile with the VElement directly
402
408
  yield* reconcileChildren(fiber, [vElement]);
403
409
  return;
@@ -509,29 +515,31 @@ const updateHostComponent = (fiber, runtime) => Effect.gen(function* () {
509
515
  if (Option.isNone(fiber.renderContext) && Option.isSome(fiber.parent)) {
510
516
  fiber.renderContext = fiber.parent.value.renderContext;
511
517
  }
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,
518
+ // Handle BOUNDARY specially (Effect-native error boundary API)
519
+ const isBoundary = fiberTypeIs(fiber, "BOUNDARY");
520
+ if (isBoundary) {
521
+ const boundaryId = fiber.props.boundaryId;
522
+ const onError = fiber.props.onError;
523
+ // Inherit boundary state from alternate (previous render) if present
524
+ // This preserves hasError state across re-renders
525
+ if (Option.isNone(fiber.boundary) && Option.isSome(fiber.alternate)) {
526
+ const alt = fiber.alternate.value;
527
+ if (Option.isSome(alt.boundary)) {
528
+ fiber.boundary = alt.boundary;
529
+ }
530
+ }
531
+ // Initialize boundary config if not already set
532
+ if (Option.isNone(fiber.boundary)) {
533
+ fiber.boundary = Option.some({
534
+ boundaryId,
521
535
  onError,
522
536
  hasError: false,
523
537
  });
524
538
  }
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
- }
539
+ // Render children (BOUNDARY doesn't have fallback state -
540
+ // the fallback is handled by Stream.catchTags in userland)
541
+ const children = fiber.props.children;
542
+ yield* reconcileChildren(fiber, children || []);
535
543
  return;
536
544
  }
537
545
  // Handle SUSPENSE specially
@@ -614,9 +622,7 @@ const createDom = (fiber, runtime) => Effect.gen(function* () {
614
622
  if (typeof type !== "string") {
615
623
  return Effect.die("createDom called on function component");
616
624
  }
617
- const node = type === "TEXT_ELEMENT"
618
- ? document.createTextNode("")
619
- : document.createElement(type);
625
+ const node = type === "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(type);
620
626
  return Effect.succeed(node);
621
627
  },
622
628
  });
@@ -642,9 +648,8 @@ const updateDom = (dom, prevProps, nextProps, ownerFiber, runtime) => Effect.gen
642
648
  if (element instanceof Text) {
643
649
  if (nextProps.nodeValue !== prevProps.nodeValue) {
644
650
  const value = nextProps.nodeValue;
645
- element.nodeValue = typeof value === "string" || typeof value === "number"
646
- ? String(value)
647
- : "";
651
+ element.nodeValue =
652
+ typeof value === "string" || typeof value === "number" ? String(value) : "";
648
653
  }
649
654
  return;
650
655
  }
@@ -684,7 +689,14 @@ const updateDom = (dom, prevProps, nextProps, ownerFiber, runtime) => Effect.gen
684
689
  if (Effect.isEffect(result)) {
685
690
  // Use runForkWithRuntime to get the full application context
686
691
  // This provides Navigator, FibraeRuntime, AtomRegistry, etc.
687
- const effectWithErrorHandling = result.pipe(Effect.catchAllCause((cause) => ownerFiber ? handleFiberError(ownerFiber, cause) : Effect.void));
692
+ const effectWithErrorHandling = result.pipe(Effect.catchAllCause((cause) => {
693
+ // Convert to EventHandlerError with the event type
694
+ const error = new EventHandlerError({
695
+ cause: Cause.squash(cause),
696
+ eventType,
697
+ });
698
+ return ownerFiber ? handleFiberError(ownerFiber, error) : Effect.void;
699
+ }));
688
700
  runForkWithRuntime(runtime)(effectWithErrorHandling);
689
701
  }
690
702
  };
@@ -762,7 +774,7 @@ const reconcileChildren = (wipFiber, elements) => Effect.gen(function* () {
762
774
  // UPDATE - reuse DOM, update props
763
775
  const fiber = createFiber(matched.type, element.props, Option.some(wipFiber), matchedOldOpt, Option.some("UPDATE"));
764
776
  fiber.dom = matched.dom;
765
- fiber.errorBoundary = matched.errorBoundary;
777
+ fiber.boundary = matched.boundary;
766
778
  fiber.suspense = matched.suspense;
767
779
  return fiber;
768
780
  }
@@ -959,11 +971,62 @@ export const renderFiber = (element, container) => Effect.gen(function* () {
959
971
  // =============================================================================
960
972
  // Hydration Support
961
973
  // =============================================================================
974
+ // =============================================================================
975
+ // Hydration Helpers - Cursor-based DOM Walking (like React)
976
+ // =============================================================================
977
+ /**
978
+ * Get the next hydratable node, skipping whitespace-only text nodes
979
+ * and non-marker comments. Returns None if no more hydratable nodes.
980
+ *
981
+ * Hydratable nodes are:
982
+ * - Element nodes (always)
983
+ * - Text nodes with non-whitespace content
984
+ * - Comment nodes that are Fibrae markers (fibrae:...)
985
+ */
986
+ const getNextHydratable = (node) => {
987
+ let current = node;
988
+ while (current) {
989
+ const nodeType = current.nodeType;
990
+ // Element nodes are always hydratable
991
+ if (nodeType === Node.ELEMENT_NODE) {
992
+ return Option.some(current);
993
+ }
994
+ // Text nodes are hydratable if they have non-whitespace content
995
+ if (nodeType === Node.TEXT_NODE) {
996
+ const textContent = current.textContent;
997
+ if (textContent !== null && textContent.trim() !== "") {
998
+ return Option.some(current);
999
+ }
1000
+ // Skip whitespace-only text nodes
1001
+ }
1002
+ // Comment nodes are hydratable if they're Fibrae markers
1003
+ if (nodeType === Node.COMMENT_NODE) {
1004
+ const data = current.data;
1005
+ if (data.startsWith("fibrae:")) {
1006
+ return Option.some(current);
1007
+ }
1008
+ // Skip non-marker comments
1009
+ }
1010
+ current = current.nextSibling;
1011
+ }
1012
+ return Option.none();
1013
+ };
1014
+ const getFirstHydratableChild = (parent) => {
1015
+ return getNextHydratable(parent.firstChild);
1016
+ };
1017
+ const getNextHydratableSibling = (node) => {
1018
+ return getNextHydratable(node.nextSibling);
1019
+ };
1020
+ // =============================================================================
1021
+ // Fiber Hydration - Cursor-based Implementation
1022
+ // =============================================================================
962
1023
  /**
963
- * Hydrate an existing DOM tree with a VElement tree.
1024
+ * Hydrate an existing DOM tree by building a fiber tree that references
1025
+ * the existing DOM nodes. This enables event handlers and reactivity
1026
+ * without replacing the DOM.
964
1027
  *
965
- * This walks the existing DOM and builds a fiber tree that matches it,
966
- * enabling reactive updates without re-creating the DOM.
1028
+ * Uses cursor-based DOM walking (like React) to match VElement tree
1029
+ * against existing DOM, skipping whitespace-only text nodes and comments.
967
1030
  */
968
1031
  export const hydrateFiber = (element, container) => Effect.gen(function* () {
969
1032
  const runtime = yield* FibraeRuntime;
@@ -972,8 +1035,9 @@ export const hydrateFiber = (element, container) => Effect.gen(function* () {
972
1035
  const rootFiber = createFiber(Option.none(), { children: [element] }, Option.none(), Option.none(), Option.none());
973
1036
  rootFiber.dom = Option.some(container);
974
1037
  // 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);
1038
+ // Start with first hydratable child (skipping whitespace)
1039
+ const firstChild = getFirstHydratableChild(container);
1040
+ yield* hydrateChildren(rootFiber, [element], firstChild, runtime);
977
1041
  yield* Ref.update(stateRef, (s) => ({
978
1042
  ...s,
979
1043
  currentRoot: Option.some(rootFiber),
@@ -983,59 +1047,76 @@ export const hydrateFiber = (element, container) => Effect.gen(function* () {
983
1047
  // Keep runtime alive
984
1048
  return yield* Effect.never;
985
1049
  });
986
- const hydrateChildren = (parentFiber, vElements, domNodes, runtime) => Effect.gen(function* () {
987
- let domIndex = 0;
1050
+ /**
1051
+ * Hydrate multiple vElements against DOM nodes using cursor-based walking.
1052
+ * Returns the next DOM cursor position after all children are hydrated.
1053
+ */
1054
+ const hydrateChildren = (parentFiber, vElements, startCursor, runtime) => Effect.gen(function* () {
1055
+ let cursor = startCursor;
988
1056
  const fibers = [];
989
1057
  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++;
1058
+ if (Option.isNone(cursor)) {
1059
+ // No more DOM nodes but we have more vElements - mismatch
1060
+ // For now, just skip (could throw HydrationMismatch in strict mode)
1061
+ break;
995
1062
  }
996
- // Function components don't consume DOM nodes directly
1063
+ const { fiber, nextCursor } = yield* hydrateElement(parentFiber, vElement, cursor.value, runtime);
1064
+ fibers.push(fiber);
1065
+ cursor = nextCursor;
997
1066
  }
998
1067
  // Link fibers
999
1068
  linkFibersAsSiblings(fibers, parentFiber);
1069
+ return cursor;
1000
1070
  });
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
- );
1071
+ /**
1072
+ * Hydrate a single vElement against a DOM node.
1073
+ * Returns the created fiber and the next DOM cursor position.
1074
+ */
1075
+ const hydrateElement = (parentFiber, vElement, domNode, runtime) => Effect.gen(function* () {
1076
+ const fiber = createFiber(Option.some(vElement.type), vElement.props, Option.some(parentFiber), Option.none(), Option.none());
1077
+ let nextCursor;
1004
1078
  if (typeof vElement.type === "function") {
1005
1079
  // Function component - invoke to get children
1006
- yield* hydrateFunctionComponent(fiber, vElement, domNodes, domIndex, runtime);
1080
+ nextCursor = yield* hydrateFunctionComponent(fiber, vElement, domNode, runtime);
1007
1081
  }
1008
1082
  else if (vElement.type === "TEXT_ELEMENT") {
1009
- // Text node
1010
- const domNode = domNodes[domIndex];
1083
+ // Text node - adopt the DOM text node
1011
1084
  fiber.dom = Option.some(domNode);
1085
+ nextCursor = getNextHydratableSibling(domNode);
1012
1086
  }
1013
1087
  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);
1088
+ // Fragment - children consume DOM nodes starting from current cursor
1089
+ nextCursor = yield* hydrateChildren(fiber, vElement.props.children || [], Option.some(domNode), runtime);
1016
1090
  }
1017
1091
  else {
1018
- // Host element
1019
- const domNode = domNodes[domIndex];
1020
- fiber.dom = Option.some(domNode);
1092
+ // Host element - adopt DOM node and hydrate children
1093
+ const el = domNode;
1094
+ fiber.dom = Option.some(el);
1021
1095
  // Inherit renderContext from parent fiber (function components capture it during render)
1022
1096
  if (Option.isNone(fiber.renderContext) && Option.isSome(fiber.parent)) {
1023
1097
  fiber.renderContext = fiber.parent.value.renderContext;
1024
1098
  }
1025
1099
  // Attach event listeners - uses runForkWithRuntime internally for full context
1026
- attachEventListeners(domNode, vElement.props, runtime);
1100
+ // Pass error callback to trigger ErrorBoundary on event handler failures
1101
+ attachEventListeners(el, vElement.props, runtime, (cause) => handleFiberError(fiber, cause));
1027
1102
  // Handle ref
1028
1103
  const ref = vElement.props.ref;
1029
1104
  if (ref && typeof ref === "object" && "current" in ref) {
1030
- ref.current = domNode;
1105
+ ref.current = el;
1031
1106
  }
1032
- // Hydrate children
1033
- const childNodes = Array.from(domNode.childNodes);
1034
- yield* hydrateChildren(fiber, vElement.props.children || [], childNodes, runtime);
1107
+ // Hydrate children using cursor-based walking
1108
+ const firstChildCursor = getFirstHydratableChild(el);
1109
+ yield* hydrateChildren(fiber, vElement.props.children || [], firstChildCursor, runtime);
1110
+ // Move to next sibling for the parent's cursor
1111
+ nextCursor = getNextHydratableSibling(domNode);
1035
1112
  }
1036
- return fiber;
1113
+ return { fiber, nextCursor };
1037
1114
  });
1038
- const hydrateFunctionComponent = (fiber, vElement, domNodes, domIndex, runtime) => Effect.gen(function* () {
1115
+ /**
1116
+ * Hydrate a function component by invoking it and hydrating its output.
1117
+ * Returns the next DOM cursor position.
1118
+ */
1119
+ const hydrateFunctionComponent = (fiber, vElement, domNode, runtime) => Effect.gen(function* () {
1039
1120
  // Capture current context during render phase for event handlers in commit phase
1040
1121
  const currentContext = (yield* FiberRef.get(FiberRef.currentContext));
1041
1122
  fiber.renderContext = Option.some(currentContext);
@@ -1061,9 +1142,11 @@ const hydrateFunctionComponent = (fiber, vElement, domNodes, domIndex, runtime)
1061
1142
  // Wait for first value
1062
1143
  const childVElement = yield* Deferred.await(firstValueDeferred);
1063
1144
  fiber.latestStreamValue = Option.some(childVElement);
1064
- // Hydrate the child VElement against remaining DOM nodes
1065
- yield* hydrateChildren(fiber, [childVElement], domNodes.slice(domIndex), runtime);
1145
+ // Hydrate the child VElement against DOM node
1146
+ // Function components render output directly to DOM (no wrapper)
1147
+ const nextCursor = yield* hydrateChildren(fiber, [childVElement], Option.some(domNode), runtime);
1066
1148
  // Subscribe to atom changes
1067
1149
  yield* subscribeFiberAtoms(fiber, accessedAtoms, runtime);
1150
+ return nextCursor;
1068
1151
  });
1069
1152
  //# sourceMappingURL=fiber-render.js.map