Skip to content

refactor(agera,kida,store): replace morph with the signal constructor protocol - #197

Merged
dangreen merged 1 commit into
mainfrom
refactor/drop-morph
Aug 12, 2026
Merged

refactor(agera,kida,store): replace morph with the signal constructor protocol#197
dangreen merged 1 commit into
mainfrom
refactor/drop-morph

Conversation

@dangreen

@dangreen dangreen commented Aug 12, 2026

Copy link
Copy Markdown
Member

morph and the Morph type 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.

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.

What replaces it

The protocol itself, marked @private: createSignal, computedOper, and the NoneFlag / WritableMode constants a call site needs to build a node. createSignal loses its third parameter — it existed only for morph — 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 compute is already invoked as a method on the node (c.compute(oldValue) in updateComputed, this.compute() in computedOper). A module-level childCompute reading this.p and this.k is therefore exactly as correct as the closure it replaces, and costs nothing per child.

A writable child in kida:

before after
computed node 1 1
compute closure 1
bound getter 1 1
setter closure 1
morph context object 1
bound morph 1
total 6 2

Counted with an instrumented build: one createSignal call 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/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. That second face is exactly what the upcoming for_ row change needs: the reconciler writes the raw signal, user code goes through the write-back.

Sizes (gzip)

entry before after Δ
nanoviews Average usage 4399 4356 −43
kida Popular set 2427 2388 −39
store Popular set 2430 2392 −38
nanoviews All publics 7697 7661 −36
agera All publics 2944 2921 −23
store All publics 6090 6101 +11

store All is 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 onSignal hook fires from inside createSignal, and it is what attaches set in the Svelte adapter (packages/svelte/src/core.ts). Marking the signal writable afterwards would leave writable children without .set there.

external hands the node to its factory instead of allocating an ops object. 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.node inside the same factory, since .node is part of the public signal type.

Rejected while measuring

  • Inlining the untracked barrier in childOper (the trick that paid off in singleEffect): +19 gzip on agera All and kida All, because it needs pushActiveSub/popActiveSub exported — 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.
  • A this-based installer in external instead of a closure: +9 gzip. A captured variable minifies to one character; this.x stays six. State belongs on the node when the alternative is a closure per instance — which is child — not when it is one closure per cold factory call.
  • Codex's inheritSignal (child 6→4 allocations) and Fable's writableComputed (6→4) both landed above this design; three independent researchers converged on removing morph and differed only on the replacement.

Migration

// before
const $doubled = morph($source, {
  get() { return this.source() * 2 },
  set(value) { this.source(value / 2) }
})

// after
const node = $source.node

node.get = () => $source() * 2
node.set = value => $source(value / 2)

const $doubled = createSignal(function doubled(...value) {
  if (value.length) {
    this.set(value[0])
  } else {
    return this.get()
  }
}, node)

this.source becomes the captured signal, and reassigning this.get / this.set on the fly becomes reassigning those slots. Both faces still share one node, so the reactive graph is unchanged. Documentation for morph is removed rather than rewritten: the protocol is @private, not a public API.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.76%. Comparing base (3d1c93b) to head (1d3611f).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…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
dangreen force-pushed the refactor/drop-morph branch from bdb3188 to 1d3611f Compare August 12, 2026 14:15
@dangreen dangreen changed the title refactor(agera,kida,store)!: replace morph with the signal constructor protocol refactor(agera,kida,store): replace morph with the signal constructor protocol Aug 12, 2026
@dangreen
dangreen merged commit 3ba59e4 into main Aug 12, 2026
10 checks passed
@dangreen
dangreen deleted the refactor/drop-morph branch August 12, 2026 14:30
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`.
@github-actions github-actions Bot mentioned this pull request Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant