refactor(agera,kida,store): replace morph with the signal constructor protocol - #197
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #197 +/- ##
==========================================
+ Coverage 84.70% 84.76% +0.06%
==========================================
Files 139 140 +1
Lines 3131 3125 -6
Branches 587 586 -1
==========================================
- Hits 2652 2649 -3
+ Misses 342 339 -3
Partials 137 137 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…or protocol `morph` was never a primitive. It was a work-around for agera's own constructor protocol being private: each of its three call sites already owned a node and only needed to put its own operator in front of it, and `morph` charged every one of them a context object plus a second bound function for the privilege. The protocol is exported instead - `createSignal` and `computedOper`, plus the `NoneFlag` and `WritableMode` constants a call site needs to build a node, all marked `@private` - and each site assembles its own signal. `createSignal` loses its third parameter, which existed only for `morph`, and binds the operator to the node itself, so whatever a face needs at call time lives on the node and costs no closure. A writable `child` in kida is now one node literal and one bound function, down from a computed node, a compute closure, a bound getter, a setter closure, a context object and a bound morph. Counted: one `createSignal` call per writable child instead of two. It is free because `compute` is already invoked as a method on the node, so a module-level `childCompute` reading `this.p` and `this.k` is exactly as correct as the closure it replaces. The writable mode is set in the node literal rather than after construction: the `onSignal` hook fires from inside `createSignal`, and it is what attaches `set` in the Svelte adapter. The two store sites keep a four-line operator of their own over a node carrying `get` and `set` slots - `external` installs its lazy pair there, `paced` its write-through - while the raw signal bound to the same node stays available to internal code. `external` hands that node to its factory instead of allocating a separate `ops` object: the slots default to the source before the factory runs and it overrides whichever side it wants, which is the documented behaviour with one allocation and one fallback branch less. `morph` and the `Morph` type go with it. They were exported and documented, but as a low-level escape hatch rather than part of the everyday surface: a facade is now written directly, by putting `get`/`set` where the operator can reach them - captured in scope or stored on the node - and binding a four-line operator with `createSignal(oper, $signal.node)`. `this.source` becomes the captured signal, and reassigning `this.get`/`this.set` becomes reassigning those slots. Both faces still share one node, so the reactive graph is unchanged. The documentation is removed rather than rewritten, since the protocol is `@private`.
dangreen
force-pushed
the
refactor/drop-morph
branch
from
August 12, 2026 14:15
bdb3188 to
1d3611f
Compare
morph with the signal constructor protocolmorph with the signal constructor protocol
dangreen
added a commit
that referenced
this pull request
Aug 12, 2026
…rotocol (#197) `morph` was never a primitive. It was a work-around for agera's own constructor protocol being private: each of its three call sites already owned a node and only needed to put its own operator in front of it, and `morph` charged every one of them a context object plus a second bound function for the privilege. The protocol is exported instead - `createSignal` and `computedOper`, plus the `NoneFlag` and `WritableMode` constants a call site needs to build a node, all marked `@private` - and each site assembles its own signal. `createSignal` loses its third parameter, which existed only for `morph`, and binds the operator to the node itself, so whatever a face needs at call time lives on the node and costs no closure. A writable `child` in kida is now one node literal and one bound function, down from a computed node, a compute closure, a bound getter, a setter closure, a context object and a bound morph. Counted: one `createSignal` call per writable child instead of two. It is free because `compute` is already invoked as a method on the node, so a module-level `childCompute` reading `this.p` and `this.k` is exactly as correct as the closure it replaces. The writable mode is set in the node literal rather than after construction: the `onSignal` hook fires from inside `createSignal`, and it is what attaches `set` in the Svelte adapter. The two store sites keep a four-line operator of their own over a node carrying `get` and `set` slots - `external` installs its lazy pair there, `paced` its write-through - while the raw signal bound to the same node stays available to internal code. `external` hands that node to its factory instead of allocating a separate `ops` object: the slots default to the source before the factory runs and it overrides whichever side it wants, which is the documented behaviour with one allocation and one fallback branch less. `morph` and the `Morph` type go with it. They were exported and documented, but as a low-level escape hatch rather than part of the everyday surface: a facade is now written directly, by putting `get`/`set` where the operator can reach them - captured in scope or stored on the node - and binding a four-line operator with `createSignal(oper, $signal.node)`. `this.source` becomes the captured signal, and reassigning `this.get`/`this.set` becomes reassigning those slots. Both faces still share one node, so the reactive graph is unchanged. The documentation is removed rather than rewritten, since the protocol is `@private`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
morphand theMorphtype are removed. They were exported and documented, but as a low-level escape hatch rather than part of the everyday surface — migration at the bottom, and it is mechanical.morphwas never a primitive. It was a work-around for agera's own constructor protocol being private: each of its three call sites already owned a node and only needed to put its own operator in front of it, andmorphcharged every one of them a context object plus a second bound function for the privilege.What replaces it
The protocol itself, marked
@private:createSignal,computedOper, and theNoneFlag/WritableModeconstants a call site needs to build a node.createSignalloses its third parameter — it existed only formorph— and binds the operator to the node, so whatever a face needs at call time lives on the node and costs no closure.The load-bearing observation is that
computeis already invoked as a method on the node (c.compute(oldValue)inupdateComputed,this.compute()incomputedOper). A module-levelchildComputereadingthis.pandthis.kis therefore exactly as correct as the closure it replaces, and costs nothing per child.A writable
childin kida:Counted with an instrumented build: one
createSignalcall per writable child instead of two, and write-back through the child still lands in the parent array.The two store sites keep a four-line operator of their own over a node carrying
get/setslots —externalinstalls its lazy pair there,pacedits write-through — while the raw signal bound to the same node stays available to internal code. That second face is exactly what the upcomingfor_row change needs: the reconciler writes the raw signal, user code goes through the write-back.Sizes (gzip)
store Allis the only entry that grows, and it is the one that carries both facade sites.Part of the win is the repo's own duplication effect: kida's node literal is nearly the same text as the literal inside agera's
computed(), so gzip matches almost all of it.Two details worth reviewing
The writable mode is set in the node literal, not after construction. The
onSignalhook fires from insidecreateSignal, and it is what attachessetin the Svelte adapter (packages/svelte/src/core.ts). Marking the signal writable afterwards would leave writable children without.setthere.externalhands the node to its factory instead of allocating anopsobject. The slots default to the source before the factory runs, and it overrides whichever side it wants — the documented behaviour, with one allocation and one fallback branch less. This is not a new exposure: the node is already reachable as$source.nodeinside the same factory, since.nodeis part of the public signal type.Rejected while measuring
untrackedbarrier inchildOper(the trick that paid off insingleEffect): +19 gzip on agera All and kida All, because it needspushActiveSub/popActiveSubexported — two names in an export list and two in an import list. The barrier is on the write path, which is cold compared to creation. agera grew 19 B without a single line of its own code changing, purely for the exports.this-based installer inexternalinstead of a closure: +9 gzip. A captured variable minifies to one character;this.xstays six. State belongs on the node when the alternative is a closure per instance — which ischild— not when it is one closure per cold factory call.inheritSignal(child 6→4 allocations) and Fable'swritableComputed(6→4) both landed above this design; three independent researchers converged on removingmorphand differed only on the replacement.Migration
this.sourcebecomes the captured signal, and reassigningthis.get/this.seton the fly becomes reassigning those slots. Both faces still share one node, so the reactive graph is unchanged. Documentation formorphis removed rather than rewritten: the protocol is@private, not a public API.