===================================================
Let me read the current state of both files carefully.
Read Forms.scala, lines 1 to 100
Read FormsExample.scala, lines 1 to 100
Read FormsExample.scala, lines 100 to 135
Read Forms.scala, lines 185 to 280
Found it. Look at awaitProductStates — it calls awaitField on each element of the product. But for a TextState, awaitField does ts.result.get which blocks until the Deferred is completed. That part is fine.
The bug is in awaitProductStates itself: it's called from onSubmit with working.asInstanceOf[Product], where working is of type Record[FieldStateF[F]]. When Record is PersonRegistration, the product elements are the FieldState values — that's correct.
But wait — look more carefully at awaitProductStates vs the old code in Form.apply:
Old onSubmit:
New awaitProductStates:
The awaitProductStates result is F[EitherNel[String, R[Id]]], and in onSubmit we then .map(_.flatMap(crossValidate)). That looks correct on the surface.
But wait — look at the TextState awaitField case. ts.result.get blocks on the Deferred. If a field was never blurred (i.e. the Deferred was never completed), awaitField will hang forever. That was true before too. Let me look more carefully at what actually changed...
Actually the real issue is subtler. Let me look at awaitProductStates again vs the old awaitField for AggregationState:
Old awaitField AggregationState:
New awaitField AggregationState:
awaitProductStates returns F[EitherNel[String, subrecord[Id]]]. So traverse gives F[List[EitherNel[String, subrecord[Id]]]]. Then collectResults takes List[EitherNel[String, subrecord[Id]]] → EitherNel[String, List[subrecord[Id]]]. That's correct.
Let me look at what awaitProductStates does when called from onSubmit on the root Record[FieldStateF[F]]. The product elements are FieldState[F, PersonalDetails[Id]] and FieldState[F, NonEmptyList[ContactEntry[Id]]]. These are SubformState and AggregationState respectively. The awaitField dispatches on those correctly.
Hmm, let me re-examine awaitProductStates vs the old onSubmit more carefully. In the old code, crossValidate was called after assembling Record[Id]:
In the new onSubmit:
awaitProductStates returns F[EitherNel[String, Record[Id]]], .map(_.flatMap(crossValidate)) is F[EitherNel[String, Record[Id]]]. Then .flatMap: result => — result is EitherNel[String, Record[Id]]. That looks right.
Let me look at the TextState awaitField with Deferred more carefully, and also check if there's a hang issue...
Actually — I think I see it now. In awaitProductStates, the TextState case of awaitField calls ts.result.get — a semantically blocking Deferred.get. If the user hasn't blurred a field, that Deferred is never completed and onSubmit hangs silently.
But that was true before too. The difference must be elsewhere. Let me look at what changed with collectResults more carefully:
Old collectResults (inline in onSubmit):
The List.empty[Any] accumulates Any.
New collectResults:
This is typed correctly. No issue there.
Let me look at awaitProductStates when used in the SubformState case of awaitField. The SubformState.states is Subrecord[FieldStateF[F]] — a product of FieldState values. awaitProductStates calls .productIterator on that and awaitField on each. That mirrors exactly the old code.
Wait — I need to look at the SubformState.finish type. Old:
where sf.finish: Subrecord[Id] => EitherNel[String, Subrecord[Id]]. New:
awaitProductStates returns F[EitherNel[String, R[Id]]] where R = subrecord. .map(_.flatMap(sf.finish)) applies sf.finish: subrecord[Id] => EitherNel[String, subrecord[Id]] inside the Either. That's correct.
Let me look very carefully at TextState — specifically the Deferred blocking concern. If a field has a preset, TextState.init completes the deferred. If not, it leaves it uncompleted. On blur, a fresh Deferred is created and the state is updated. The original Deferred from init is abandoned.
So if you never blur a field, awaitField on that TextState will block forever on the original uncompleted Deferred. That's unchanged by the refactor.
Let me look more carefully at what awaitProductStates receives vs what the old submit code did. In Form.apply, rootRef holds Record[FieldStateF[F]]. When we rootRef.get, we get the current snapshot of the record. Previously that snapshot was passed as a Product directly to iterate. Now awaitProductStates does the same.
Actually, let me look at this from a completely different angle — maybe the issue is that awaitProductStates blocks because TextState.result.get blocks on an uncompleted Deferred, which then hangs the flatMap: result => that sends to the channel. The old code had the same issue — so it must have always worked somehow. Let me re-read TextState.init:
Read Forms.scala, lines 140 to 165
OK so Deferred.get blocks semantically on untouched fields. On blur, a fresh Deferred is created and the old one is replaced in the ref. The old deferred is abandoned — never completed.
But wait: when onSubmit runs rootRef.get, it gets the current snapshot. If the user has blurred a field, the snapshot has the new deferred (already completed). If not, it has the initial deferred (never completed) — and awaitField hangs.
This was always the case. The issue must be something else. Let me look at the onSubmit flow more carefully in the new code — specifically whether awaitProductStates is even being called...
Actually, hold on. Let me re-read the onSubmit logic more carefully:
awaitProductStates returns F[EitherNel[String, Record[Id]]]. .map(_.flatMap(crossValidate)) is still F[EitherNel[String, Record[Id]]]. Then .flatMap: result => — here result: EitherNel[String, Record[Id]]. That's correct.
Now compare awaitProductStates with what was done previously for the root-level record. Previously:
The result was F[EitherNel[String, Record[Id]]]. Then .flatMap: result =>. Same shape.
I'm not finding the bug by reading alone. Let me check whether the issue might be in awaitProductStates when called on a SubformState — the SubformState.states product's element types. Actually, let me trace through very carefully what awaitField does on a SubformState:
Old:
New:
awaitProductStates has mirror: Mirror.ProductOf[R[Id]], but here we pass sf.resultMirror: Mirror.ProductOf[subrecord[Id]]. And R[Id] should unify with subrecord[Id]. In the old code, fromProduct was used on sf.resultMirror to reconstruct subrecord[Id]. In awaitProductStates, it does the same. This looks correct.
Let me now look at awaitProductStates for the AggregationState case more carefully. The old inner loop produced EitherNel[String, subrecord[Id]] values and collected them. The new one:
awaitProductStates(w.asInstanceOf[Product], ag.resultMirror) returns F[EitherNel[String, subrecord[Id]]]. traverse gives F[List[EitherNel[String, subrecord[Id]]]]. Then collectResults gives EitherNel[String, List[subrecord[Id]]]. Then .flatMap(ag.finish) where ag.finish: List[subrecord[Id]] => EitherNel[String, A]. That's correct.
Hmm. Let me look at whether the blocking Deferred.get is actually the issue after all, by rethinking the flow. Previously on submit, were all fields always blurred before submitting? In the example, there are preset fields... no, there aren't presets.
Actually — wait. I need to reconsider. The old awaitField for TextState was ts.result.get, and the new one is the same. But what if the test was passing before because in the old code, awaitField was never called on unblurred TextStates — perhaps because the submit was always done after blurring?
But the user says "nothing happens on form submit anymore" — so it worked before but not now. Let me look for something that changed structurally between the old and new code...
I think I've been overlooking something. Let me re-read awaitProductStates signature vs how it's used in onSubmit:
In onSubmit: awaitProductStates(working.asInstanceOf[Product], resultMirror) where resultMirror: Mirror.ProductOf[Record[Id]]. So R = Record. The product elements of working: Record[FieldStateF[F]] are FieldState[F, X] values. awaitField is called on each. That's right.
But what about type erasure? awaitField[F[_]: Async, A](state: FieldState[F, A]) — when called as awaitField(s.asInstanceOf[FieldState[F, Any]]), A = Any. For TextState, ts.result.get returns F[EitherNel[String, Any]]. Fine.
For SubformState case: awaitProductStates(sf.states.asInstanceOf[Product], sf.resultMirror) — wait, sf here is FieldState[F, Any] (because the outer awaitField call gets Any as A). So sf: SubformState[F, subrecord] after the pattern match, but at runtime the type is erased. The sf.resultMirror and sf.states are accessed as stored fields. The Product coercion on sf.states is fine.
I keep going in circles. Let me just look at the git diff to see exactly what changed:
git diff HEAD -- forms/src/main/scala/calix/forms/Forms.scala
Auto approved by rule git diff
The diff confirms this is a completely new implementation compared to HEAD (the git diff shows the entire file as changed). So there's no "before this refactor" version available — the current file is the one to debug.
Let me focus on the actual runtime symptom: "nothing happens on submit." The onSubmit fires (button click works), it calls awaitProductStates on the root product, which traverses and calls awaitField on each element. For a TextState whose Deferred was never completed (never blurred), ts.result.get blocks semantically forever. That's the hang.
But wait — the user says "Validation on blur is running ok" — so fields ARE being blurred. Let me look more carefully at the onBlurHandler:
The freshDeferred is created, immediately stored into stateRef, and then validation is started as a background fiber. The fiber completes freshDeferred when done.
Now onSubmit does rootRef.get — this reads the current snapshot of Record[FieldStateF[F]]. Each field's TextState in this snapshot holds the freshDeferred reference. Then awaitField calls freshDeferred.get.
If the fiber hasn't finished yet at the moment onSubmit runs (async validation still in flight), Deferred.get will block until the fiber completes it. That should be fine.
But here's the critical question: does awaitProductStates read from the snapshot (values at time of rootRef.get) or from live refs? It reads from the snapshot. Let me check if awaitProductStates actually calls awaitField on the snapshot fields... Yes it does.
Hmm, let me think about whether awaitField on a SubformState snapshot works. The snapshot's SubformState.states field is itself a product of FieldState snapshots. But those snapshots are from the time rootRef.get was called — they hold Deferred references that are shared objects. So ts.result.get on a snapshot TextState will still wait on the same Deferred that the blur handler completes. That should work.
Wait — let me re-read awaitProductStates called from onSubmit more carefully. The root Record[FieldStateF[F]] has fields like FieldState[F, PersonalDetails[Id]] (a SubformState) and FieldState[F, NonEmptyList[ContactEntry[Id]]] (an AggregationState). awaitField on the SubformState calls awaitProductStates(sf.states.asInstanceOf[Product], sf.resultMirror).
sf.states is PersonalDetails[FieldStateF[F]] — a product of TextState[F, FirstName], TextState[F, LastName], TextState[F, DateOfBirth]. The snapshot holds these. Their Deferreds should be completable.
Actually — I just spotted it. Look at the AggregationState case in awaitField:
ag.idStatePairs is List[(Long, Subrecord[FieldStateF[F]])]. Each w is Subrecord[FieldStateF[F]]. awaitProductStates(w.asInstanceOf[Product], ag.resultMirror) — here w.asInstanceOf[Product] iterates over the product elements, which are FieldState values. ag.resultMirror: Mirror.ProductOf[Subrecord[Id]]. This looks correct.
But wait — let me look at collectResults here. Old code had two nested foldLefts: inner one assembled subrecord[Id] from field results, outer one collected List[subrecord[Id]]. New code: awaitProductStates does the inner assembly (returns EitherNel[String, subrecord[Id]]), then collectResults on the list does the outer. The result is EitherNel[String, List[subrecord[Id]]]. Then .flatMap(ag.finish). Correct.
I'm going to try adding some debug tracing, but actually let me look at the onSubmit binding in the component more carefully:
This is defined as a val inside a Resource.eval-like context... actually it's SignallingRef[F, String]("").toResource.flatMap: errorRef =>. So errorRef is a SignallingRef[F, String], not a Resource. And onSubmit is a val that captures errorRef. That's fine.
Hmm — but awaitProductStates will semantically block on Deferred.get for each TextState. On the JS runtime there's only one thread. If Deferred.get truly suspends the fiber waiting for the deferred, and the blur handler's validation fiber needs to run to complete the deferred... but they're on the same cooperative scheduler, so it should interleave correctly via cede.
Actually wait — this IS the issue. ts.result.get in awaitField semantically blocks. The blur handler fires validation in a background fiber (.start). When submit is clicked, if validation is still in flight for a blurred field, awaitField will correctly wait for the fiber. But if a field was never blurred, the deferred is never completed and the submit hangs forever silently.
Let me check: does the user say they blurred all fields? They said "Validation on blur is running ok" — meaning they did blur. But maybe they didn't blur all fields, or maybe they're in a different browser state.
But the user said it was working before the refactor session — and this behaviour (blocking on unblurred fields) would have been there before too since the fundamental Deferred-based mechanism hasn't changed.
Let me look at whether there's something different about how onSubmit is handled on JS. On JS, Deferred.get should yield and allow other fibers to run. The validation fiber started in onBlurHandler should complete the deferred, then awaitField should proceed.
Let me try a completely different angle — maybe the issue is simpler. The onSubmit val might be capturing a stale errorRef or submitChannel. Let me look at whether there's a Resource scope issue...
Actually I want to look at whether the error is silently swallowed. If awaitProductStates throws or the flatMap chain errors, the onClick handler would eat the error. Let me add some error surfacing to debug.
Actually — let me look at this from the simplest possible angle. The user says nothing is printed. The result panel listens on submissions.collect { case Right(r) => r }. So even a Left result from submit wouldn't show. But submitChannel.send(result) should still send — and errorRef.set should update the error span.
If the error span isn't updating either, that means onSubmit is hanging before reaching submitChannel.send. That points back to awaitProductStates blocking.
The most likely cause: a TextState whose Deferred was never completed. On submit, if the user hasn't blurred every single TextField, those Deferred.get calls hang. The form has 4 text fields (firstName, lastName, dob, contactValue). If any one was skipped...
But the user says it worked before. Let me check the old code at HEAD more carefully — the old awaitForm.await used .traverse(_.result.get) on TextInputs. Same mechanism. So this shouldn't be a regression.
Unless... the old code used submitDeferred.complete(result).void — it completed a Deferred once and then the F[EitherNel...] returned from form(...) was the caller's submitDeferred.get. That's fire-and-forget from the UI perspective. The new code does submitChannel.send(result).void which should also be non-blocking since it's an unbounded channel.
Hmm. Let me check whether there's a race condition in the new code with onSubmit and the rootRef snapshot. When blur fires, it does:
stateRef.update(_.copy(input = Some(raw), result = freshDeferred)) — updates the lens, which propagates to rootRef
Starts a fiber to complete freshDeferred
When submit fires, it does rootRef.get to get the snapshot. The snapshot at step 1 has freshDeferred in the TextState. Then awaitField does freshDeferred.get. The fiber from step 2 will complete freshDeferred. So awaitField should eventually return.
I'm going in circles. Let me just add a quick console.log-style trace to narrow down where it hangs:
Read Forms.scala, lines 54 to 70
Now I want to look at awaitField for TextState very carefully one more time, and think about whether ts.result.get blocks or not in the new code path when submit is clicked after blur.
After blur: stateRef (a lens into rootRef) holds TextState(Some(raw), freshDeferred) where the validation fiber has completed freshDeferred (validation is "running ok" per the user). So freshDeferred IS completed.
When submit: rootRef.get returns snapshot of Record[FieldStateF[F]]. This is the current record. awaitProductStates iterates over its product elements. For a SubformState, it calls awaitProductStates on sf.states.asInstanceOf[Product].
Wait. sf.states is a snapshot from when the SubformState was created in initField — it's the initial SubformState. But the actual field updates are stored in rootRef via SignallingRef.lens. Let me think...
In Form.apply, rootRef holds Record[FieldStateF[F]]. When a text field changes, its lens updates the corresponding element of this record. For the SubformSpec case, the state in rootRef is a SubformState. When a sub-field's lens fires, it updates the SubformState.states product element — going through the lens defined in fieldComponent:
So s.copy(states = ...) creates a new SubformState with the updated states. This is stored back into rootRef. Good — so rootRef.get returns the current SubformState with all field updates.
When awaitProductStates is called with this current SubformState.states, each TextState in there is the current one from the blur handler (with the completed freshDeferred). So ts.result.get should return immediately.
So why is nothing happening? Let me look for something even more basic — maybe awaitProductStates is recursing into awaitField which processes a SubformState which calls awaitProductStates again, and the resultMirror used is wrong.
Actually — I just realized I should check whether awaitProductStates is being called with the right mirror in the SubformState case. Let me look at awaitField for SubformState:
sf.resultMirror: Mirror.ProductOf[subrecord[Id]]. The product being passed is sf.states.asInstanceOf[Product] which is subrecord[FieldStateF[F]] — a product of FieldState values. awaitProductStates iterates those, calls awaitField on each, collects results, then uses sf.resultMirror.fromProduct to build subrecord[Id]. Correct.
And the outer call from onSubmit:
resultMirror: Mirror.ProductOf[Record[Id]]. working: Record[FieldStateF[F]]. Product elements: FieldState[F, PersonalDetails[Id]] (SubformState), FieldState[F, NonEmptyList[ContactEntry[Id]]] (AggregationState). awaitField on each recursively awaits. The assembled List[Any] of length 2 is passed to resultMirror.fromProduct(listProduct(values)). That gives Record[Id]. Correct.
I'm completely stumped by static analysis. Let me add minimal temporary debug prints to nail down exactly where the hang is:
Forms.scala+5-1
Also add tracing to awaitField for each branch:
Forms.scala+5-1
Forms.scala+1-1
sbt "forms_example/fastOptJS" 2>&1 | tail -5
Auto approved by rules sbt, tail
Opened Simple Browser at
http://localhost:8080/forms_example/forms_example.htmlThe user will need to open the browser themselves and check the console. But let me think harder rather than relying on runtime tracing — let me read the awaitField case for TextState very carefully one more time.
ts.result.get — result is a Deferred[F, EitherNel[String, A]]. The blur handler does:
The validation + completion is .started as a background fiber. It's not awaited. So the deferred is completed asynchronously.
When onSubmit reads rootRef.get, it gets the snapshot. The TextState in the snapshot has freshDeferred. Then ts.result.get suspends until the fiber completes freshDeferred. Since this is CE3 on JS, the scheduler should interleave these fibers correctly.
But what if the validation fiber has already completed freshDeferred before onSubmit? Then ts.result.get returns immediately (Deferred is idempotent-read once completed). That should work fine.
What if ts.result.get is called on the initial Deferred (from TextState.init) which was never completed because the field was never touched? It blocks. But the user says validation is running on blur — so all fields should have been blurred.
Let me reconsider: maybe the issue isn't a hang but a silent exception. Let me look at whether awaitProductStates can fail:
If mirror.fromProduct(listProduct(values)) throws (e.g. arity mismatch), the whole F fails with an exception which would propagate up through onSubmit. The onClick handler is (_, _) => onSubmit — if onSubmit raises an exception, it depends on how onClick handles errors.
Let me check the onClick implementation:
Let me read the Attribute.EventHandler implementation to understand how onSubmit errors are handled:
Read Attributes.scala, lines 20 to 60
Let me look at the Component.scala to understand how EventHandler attributes are rendered — specifically what happens when the handler F effect raises an exception:
Read Component.scala, lines 1 to 120
Now I see it clearly. The EventHandler is handled via:
The foreach stream processes events by running handler(e, el). If handler raises an exception (or hangs), the stream fiber handles it. But crucially — the foreach runs each handler sequentially in the stream fiber. If handler semantically blocks (e.g. on Deferred.get), it blocks the stream fiber and no more events can be processed from that element.
This is the bug. onSubmit calls awaitProductStates which calls awaitField which calls ts.result.get — a semantically blocking Deferred.get. This blocks the click event handler's stream fiber. On the JS runtime, this means the entire CE3 scheduler is blocked waiting for that Deferred to be completed — but the validation fiber that would complete it cannot run because the scheduler is blocked.
In the old code, awaitForm.await also called ts.result.get, so the same problem existed — but wait, the user says it worked before. This means the old code must have handled it differently. Looking at the old code:
The old code called fieldRefs.traverse(_.get) — reading from SignallingRefs (instant, non-blocking). Then awaitForm.await called _.result.get on each TextInput. Same blocking problem existed. So this can't be the regression.
Unless... the user tested the old code differently? Or maybe the forms example was freshly ported and never actually tested end-to-end with the old code?
Actually, I realize I need to stop reasoning about "the old code worked" — the git diff shows HEAD is the pre-refactor version, but that might be an entirely different architecture. Let me instead focus on fixing the blocking issue properly.
The fix is: onSubmit should not block the click handler fiber. It should be launched as a background fiber. Let me check the onBlurHandler to confirm it does the right thing:
Yes — blur launches validation as a background fiber via .start. The blur handler returns immediately (the fiber starts but the handler completes synchronously from the stream's perspective).
But onSubmit does no such thing — it flatMaps all the way through awaitProductStates (which may block) before returning. This means the stream fiber blocks until all Deferreds complete. But on JS, the validation fibers complete via .start on the JS microtask queue. As long as CE3 cooperatively yields, this should work... unless Deferred.get truly blocks the JS thread rather than yielding.
In CE3 on JS, Deferred.get on an incomplete Deferred will suspend the current fiber and register a callback to resume when completed. Other fibers continue running. So it should not block the JS thread. But... it blocks the stream fiber. listenEventStream processes events one at a time via foreach. While the stream fiber is suspended waiting for the Deferred, no more click events are processed — but that's fine since we only needed one submit.
The real question is: when onSubmit's fiber suspends on Deferred.get, can the validation fiber (started by blur) still run and complete the deferred? Yes — because CE3's fiber scheduler is cooperative and both fibers are on the same thread. The blur validation fiber was .started and is in the scheduler's queue. When the submit fiber suspends, the scheduler runs the next ready fiber, which should include the validation fiber.
So theoretically it should work. Let me look for a different cause. Let me re-read onBlurHandler more carefully:
Wait — fiberRef.get.flatMap(prev => prev.traverse_(_.cancel)) cancels any previous validation fiber. Then starts a new one. The new fiber is spec.validate(raw).flatMap(freshDeferred.complete).void. It completes freshDeferred when done.
But what's the state of stateRef when onSubmit runs rootRef.get? The stateRef has been updated to hold TextState(Some(raw), freshDeferred). So rootRef.get has the current TextState. Good.
Then awaitField on that TextState calls freshDeferred.get. The blur fiber (which is .started) should complete freshDeferred. So freshDeferred.get should eventually return.
Hmm. I genuinely cannot find the bug through static analysis. Let me take a step back and think about whether my debug prints will actually help. On JS, println goes to the browser console. Let me rebuild and ask the user to test with the console open.
Actually wait — let me re-read the onBlurHandler one more time. There's a subtle issue:
stateRef.update puts freshDeferred into the state. Then the validation fiber is started to complete freshDeferred. OK.
But fiberRef holds the previous fiber and cancels it. The previous freshDeferred (from the previous blur) is stored in fiberRef... wait, no. fiberRef holds the previous validation fiber, not the deferred. Cancelling the old fiber means the old freshDeferred (from the previous blur) might never be completed. But that's OK because stateRef now points to the new freshDeferred.
Actually wait — what if we blur twice quickly? First blur: creates d1, stores it, starts fiber to complete d1. Second blur: creates d2, stores it, cancels fiber that would complete d1, starts fiber to complete d2. Now stateRef holds d2. On submit, rootRef.get sees d2. d2.get waits for the second blur's fiber to complete it. That fiber was not cancelled. So it should work.
I'm really stuck. The debug printing I've added should tell us exactly where the hang is. But there's one more thing I want to check — the formsExample's app function calls Form[F, PersonRegistration](...) and uses the result:
The submissions stream is submitChannel.stream. But submitChannel is closed in Resource.onFinalize(submitChannel.close.void) inside the component Resource. The component Resource is created via Resource.eval(Form[...]) — but Form.apply returns F[FormComponent[F, Record[Id]]], and FormComponent is (Component[F], Stream[F, EitherNel[String, A]]).
The component: Component[F] is the inner Resource. Resource.onFinalize(submitChannel.close.void) *> htmlForm(...) — this means submitChannel is closed when the component Resource is released. The submissions stream (= submitChannel.stream) terminates when the channel is closed.
But in the example, the component Resource is used via Mod.child(formComponent) inside the outer div. The div's resource allocation mounts the component and holds it open for the lifetime of the app. So submitChannel stays open. submissions.collect { case Right(r) => r } is running in a background fiber.
When submit is clicked and submitChannel.send(result) runs, the background fiber processing submissions should receive it and call submissionsRef.update(_ :+ r). Then dynKeyChildren should detect the new entry and render it.
Hmm. Everything checks out logically. Let me try a completely different hypothesis: maybe the issue is that onSubmit completes successfully but the result panel doesn't update because submissionsRef update isn't triggering a re-render. But the user said "form contents aren't printed" — the result panel was working before, and submissionsRef as a SignallingRef should notify dynKeyChildren.
Actually — let me re-examine formsExample.app:
Form.apply returns F[FormComponent[...]], so Resource.eval(Form[...]) evaluates it in the Resource context. The returned (formComponent, submissions) are the component and stream. The component: Component[F] is a Resource[F, dom.Node] — it needs to be materialized (allocated) somewhere.
Looking at:
Mod.child(formComponent) mounts the form component as a child of the div. This calls formComponent as a Resource[F, dom.Node], allocating it and adding the node to the DOM. The Resource.onFinalize(submitChannel.close.void) inside formComponent will run when the form is unmounted.
But wait — inside formComponent:
Resource.onFinalize(submitChannel.close.void) is scoped to the htmlForm resource. When htmlForm is released (unmounted), submitChannel is closed. But the submissions stream consumer is started as a background fiber in Resource.eval(...) — it's not scoped to the form resource at all. So if the form resource is somehow released prematurely... but it shouldn't be.
Actually, I want to verify: is the submissions background fiber correctly scoped? Let me re-read:
submissions.compile.drain.start starts the fiber. Resource.eval(...).void discards the fiber handle — it's fire-and-forget. If the form resource finalizes (closes submitChannel), the stream terminates, and the orphaned fiber ends. That seems fine.
But actually — does the submissions fiber even start? Resource.eval(... .start).void evaluates the effect (starts the fiber) and discards the result as a Resource that has no finalizer. So yes, the fiber runs in the background.
Let me look at submitChannel.send. In Channel.unbounded, send never blocks (the channel is unbounded). So submitChannel.send(result).void should complete immediately. The submissions.stream is a Stream[F, EitherNel[String, Record[Id]]] from the channel. collect { case Right(r) => r } filters. .foreach(r => submissionsRef.update(_ :+ r)) processes each submission.
All of this should work. I genuinely believe the debug prints are necessary. Let me rebuild and have the user open the browser console.
Actually — hold on. I just realized something. Let me look at Form.apply again:
This is a map on Channel.unbounded[...]. The Channel is created as an effect. The map then creates the component and submitChannel.stream pair. The component is a lazy Resource description — it's not yet allocated.
But component references submitChannel which was created in the Channel.unbounded[F, ...].map context. The submitChannel object itself is created once. Fine.
But wait — submitChannel is created by Channel.unbounded[F, ...].map: submitChannel =>. This is F[A].map(f) = a new F[B]. The Channel allocation happens when this F is run. In Form.apply, the whole chain starting from initSpecsAsStates(...).flatMap... is an F[FormComponent[F, Record[Id]]]. When Form.apply returns this F, it's not yet run. It's run by Resource.eval(Form[F, PersonRegistration](...)) in the example.
When that F runs, it creates:
Initial working states
A SignallingRef for the root state
A Ref for the id counter
An unbounded Channel
Returns (component, submitChannel.stream) — here component is a Component[F] (a lazy Resource[F, dom.Node])
The component Resource, when allocated (via Mod.child(formComponent)), runs:
SignallingRef[F, String]("").toResource — creates errorRef
Resource.onFinalize(submitChannel.close.void) — registers finalizer
htmlForm(...) — creates the form DOM node
So submitChannel.send(result) is called by onSubmit which is captured in the click handler. When submit is clicked, onSubmit runs, which calls submitChannel.send(result). The submissions background fiber should receive this via submitChannel.stream.
I genuinely cannot find the logical bug. The debug traces will be necessary. Let me build and present the traces to the user.
sbt "forms_example/fastOptJS" 2>&1 | tail -5
Auto approved by rules sbt, tail
The build was cached (no source changes since debug traces were already there from the earlier compile). Good. Let me verify it actually compiled by checking the output timestamp: