Skip to content

Spike: replace @glimmer/validator with alien-signals - #21668

Draft
NullVoxPopuli-ai-agent wants to merge 5 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/spike-alien-signals
Draft

NullVoxPopuli-ai-agent wants to merge 5 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/spike-alien-signals

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Spike. Not for merge. It answers one question: what does Ember look like when alien-signals replaces @glimmer/validator?

Try it: git fetch origin pull/21668/head && pnpm install && pnpm test:wip

Result

  • packages/@glimmer/validator is deleted. Every export is treated as private.
  • The revision model is gone. No tag has a revision. valueForTag, validateTag, CURRENT_TAG, and VOLATILE_TAG do not exist.
  • packages/@glimmer/signals builds on alien-signals/system (link, unlink, propagate). "Is this still valid?" is a flag check on a node.
  • @glimmer/signals is private. It is not in renamed-modules, and no app or addon can import it.
  • The branch is rebased on main of 2026-10-07. After the rebase, the full suite passes in development mode: 9534 tests, 0 failures. Production mode did not run again. It passed before the rebase with 9306 tests.
  • The rebase adds one commit, 57457412c6. It ports the tests that main gained for the tracker pool of Pool the trackers of tracking frames #21663, and removes two that test the tracker that this spike deletes.
  • Size, measured with an esbuild bundle of @ember/-internals/glimmer: +2.4 kB raw, +0.9 kB brotli against main.

Speed of the reactive core

Benchmark: ember-reactivity-bench, Node 24.20, weighted geometric mean of 20 cases, plain alien-signals = 1.0x, lower is faster.

build mean
main 1.8x
this PR 0.9x
  • "1000 rows, write 1" goes from 7.2 µs to 2.9 µs.
  • tracked(value) creation does not change (17 µs per 1000, 6.6x of a plain signal). That cost is in TrackedValue, not in the graph.

Rendering numbers (rere-benchmark against main) are not here yet.

How it maps

before now
compute reference, createCache computed node
cache group (JumpIfNotModifiedOpcode + tag + revision) frame node, stale when a flag is set
nothing one root frame for each render result
createTag, createUpdatableTag, tagFor one kind of leaf node
(tag, revision) pair in a caller a subscriber node that the caller owns (watchTag, isFrameStale)
classic computed property: tag + revision in Meta the property tag alone, with a stale bit (isTagFresh)
track(fn) then updateTag(tag, result) trackInto(tag, fn): the tag is the subscriber of the run
combine(tags) a plain array of tags
the tag of a component argument the reference of the argument
global revision writeCount(), only for "did any write occur"

Callers that held a (tag, revision) pair and now own a subscriber: observers, the curly component manager, modifier opcodes, ArrayProxy.

What alien-signals does not cover
  1. Links hold subscribers strongly.

    A tag keeps every node that subscribes to it. The validator had no back link, so the garbage collector did all the work. Now an owner must remove links:

    • A render result owns a root frame. A destroyed render or block drops its links, and the drop cascades (unwatched).
    • A cache that loses its last subscriber drops its links and runs again on the next read. This is the rule of alien-signals.
    • Observers dispose their subscriber when they are removed or their object is destroyed.
    • EmberObject releases the watchers of its tags on destroy.

    Still open:

    • A cache that code reads outside a render has no subscriber. It stays linked to its tags for as long as those tags live.
    • A native class with @computed, @alias or @dependentKeyCompat has no destroy hook. Its watcher stays linked until its sources die.
  2. checkDirty does not stop on a cycle.

    Two classic computed properties can depend on each other, so their tags link to each other. propagate stops on such a cycle. The spike has no walk over dependencies that can pass a tag two times.

  3. A stale node stops propagate.

    A subscriber with no function to run again cannot refresh a stale cache below it. Such a subscriber links to the leaf tags directly.

    A tag that follows other tags has no function at all. A new subscriber clears the flags of such a tag, and a stale bit on the tag keeps the fact that a write reached it.

Behavior that stays the same
  • No equality check on caches or references. A recompute always counts as a change. notifyPropertyChange with the same value still updates the consumers. checkDirty is therefore not in use.
  • The assertion for a write after a read in the same computation.
  • scheduleRevalidate runs for each write.
  • A write to a tag that the open computation read earlier does not make that computation stale.
Behavior that changes
  • @glimmer/validator is no longer an importable module of ember-source, and nothing replaces it. An addon that imports it breaks.
  • A @cached getter or a helper runs again after its last subscriber goes away, for example when its block leaves the DOM and comes back. Before, it kept its value if its tags did not change.
  • alien-signals 3.2.1 is a new bundled dependency.
Not done
  • No memory measurement. One TagNode class serves all kinds, so a leaf tag has unused fields.
  • The update pass can stop at once when the root frame is not stale. The spike does not do that.
  • Docs in internal-docs/ still name the validator.

Tests added: packages/@glimmer/signals/test/graph-test.ts (frames, caches, updateTag) and integration-tests/test/graph-links-test.ts (a removed block and a destroyed render leave no link). Tests that used valueForTag / validateTag now use a subscriber.

🤖 Generated with Claude Code

NullVoxPopuli and others added 5 commits October 7, 2026 13:24
This is a spike. It removes the @glimmer/validator package.
The new private package @glimmer/signals builds on alien-signals/system.

- References and createCache are computed nodes of the graph.
- Each cache group of the VM is a frame node. A render has a root frame.
- A destroyed block or render removes its links from the tags.
- A cache without a subscriber keeps a list of tags and a revision, not links.
- updateTag uses a watcher node that forwards writes to the tag.
- Classic computed properties and observers keep revision snapshots.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No tag has a revision number, and valueForTag and validateTag are gone.

Each caller that held a tag and a revision now owns a subscriber node:

- a classic computed property and an alias, in the meta of the object
- an observer
- the arguments of a curly component
- a modifier opcode
- ArrayProxy for its arranged content

A cache always has links to its tags. A cache that loses its last
subscriber removes its links and runs again on the next read, as a
computed of alien-signals does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The package is no longer an entrypoint, so it is not in
ember-addon.renamed-modules and no app or addon can import it.

- The benchmark app builds its cell on tracked() from @glimmer/tracking.
  The control build of the perf job uses main, which has no @glimmer/signals.
- The smoke test for a v1 addon imports @glimmer/destroyable.
- The smoke test for the object tag reads it through a cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A classic computed property is one node: its property tag.
  The tag links to the tags that the getter or the dependent keys read,
  and a stale bit on the tag says if the cached value is valid.
  Meta keeps only a flag that a value is cached.
- trackInto(tag, fn) runs fn with the tag as the subscriber.
  It replaces track() followed by updateTag().
- A Tag is one node or an array of tags. combine() is gone.
- track() returns a frame. beginTrackFrame and endTrackFrame are gone.
- A tag that follows other tags never stops propagate.
  A new subscriber clears its flags, and the stale bit keeps the fact.
- The tag of an argument is its reference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The rebase brought in the tests that main added for the tracker pool.
A frame of this spike returns a frame node, not a tag, so:

- the two tests that compare the result of a frame with a tag are gone.
  They test the tracker that this spike deletes.
- the other tests use the `watch` and `isValid` helpers of this file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants