Skip to content

Rip out validator state machine; tags are now callable signals - #14

Open
NullVoxPopuli-ai-agent wants to merge 6 commits into
NullVoxPopuli:mainfrom
NullVoxPopuli-ai-agent:nvp/alien-signals-reactivity
Open

NullVoxPopuli-ai-agent wants to merge 6 commits into
NullVoxPopuli:mainfrom
NullVoxPopuli-ai-agent:nvp/alien-signals-reactivity

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented May 21, 2026 •

Copy link
Copy Markdown

Summary

`@glimmer/validator` is entirely private to ember-source — only `createCache`/`getValue`/`isConst` (via `@glimmer/tracking/primitives/cache`) and `Cache` (via `@ember/helper`) are public surface — so this PR reshapes the internals freely.

What this changes

A `Tag` is now just a callable: `tag()` returns the current revision and, when called inside an alien-signals subscriber, registers a dependency. That's the entire protocol.

The whole class hierarchy and discriminator collapses:

  • `MonomorphicTagId`, `DIRTYABLE_TAG_ID`, `UPDATABLE_TAG_ID`, `COMBINATOR_TAG_ID`, `CONSTANT_TAG_ID`, `VOLATILE_TAG_ID`, `CURRENT_TAG_ID` — gone.
  • `[TYPE]` symbol discriminator — gone. `is this tag dirtyable / updatable` is now a WeakMap lookup (`dirtyHandles`, `subRefs`).
  • `COMPUTE` method — replaced with calling the tag. Symbol kept only so the type from `@glimmer/interfaces` still resolves.
  • The `MonomorphicTagImpl` class with its `revision`/`lastChecked`/`lastValue`/`isUpdating`/`subtagBufferCache` state machine — gone.
  • `ALLOW_CYCLES` export — gone (the cycle guard is now a private WeakSet).
  • `UpdatableTag`/`DirtyableTag`/`ConstantTag`/`CombinatorTag` are type aliases for `Tag`.

`createTag()` is a one-liner. `combine([tags])` is an alien-signals `computed` that Math.max-folds. `dirtyTag(tag)` does `signal(advance())`.

What survived and why

Both of these were verified by deleting them and watching specific tests fail:

  • Cycle re-entrance guard on updatable tags. ember's CP `set` and `get` both call `updateTag(propertyTag, depsChain)`, which sets up `barTag.sub → fooTag` and `fooTag.sub → barTag` when foo/bar depend on each other. alien-signals re-entry produces `NaN`; ember's contract needs a stable last value plus a tick bump so neighbouring caches revalidate.
  • Adoption "buffer" on updatable tags. `updateTag` may adopt a subtag whose revision is already higher than the parent's; naive `max(own, sub())` makes the parent's revision jump and fires observers on the parent without anything they care about having changed. The buffer hides the adoption-time jump until the subtag is itself dirtied past its adoption value. Load-bearing for `computed - observer interop: observers that do not consume computed properties still work`.

Diff stats (cumulative vs `main`)

```
@glimmer/interfaces/lib/tags.d.ts | 37 +-
@glimmer/validator/index.ts | 3 -
@glimmer/validator/lib/meta.ts | 4 +-
@glimmer/validator/lib/validators.ts | 387 +++++++--------------
@glimmer/validator/package.json | 3 +-
@glimmer/validator/test/validators-test.ts | 49 +--
6 files changed, 146 insertions(+), 337 deletions(-)
```

Net −191 lines across the package, with the underlying model genuinely reshaped (not refactored in place).

Test plan

  • `pnpm lint:eslint` clean
  • `pnpm lint:format` clean
  • `pnpm type-check:internals` — only the two pre-existing `@glimmer/component` errors that already exist on `main`
  • `pnpm exec vite build --mode development --minify false && pnpm exec testem ci -f testem.cjs` — 9306 / 9306 passing, 17 skipped, 0 failing (parity with `main`)

🤖 Generated with Claude Code

Tags in @glimmer/validator now publish their dirty state through
alien-signals signals: each tag owns a per-tag signal that is written
on DIRTY_TAG and read inside [COMPUTE], and a single shared signal
backs CurrentTag/VolatileTag (which have no per-instance storage).

The numeric revision counter and lastChecked cache are preserved so
that staleness checks (validateTag) and all the existing combinator
semantics behave identically — this change is purely additive on the
inside while exposing the dirty graph to alien-signals subscribers
on the outside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

The previous commit wired alien-signals in as a side channel that
left the revision counter + lastChecked cache + subtagBufferCache
state machine in MonomorphicTagImpl entirely intact. That's not a
swap — it's a piggyback.

This commit deletes that class outright and re-expresses every tag
in terms of alien-signals primitives:

  - dirtyable tag → `signal<Revision>(INITIAL)`; DIRTY_TAG writes it
  - updatable tag → two signals (own revision, current subtag pointer)
    folded by an alien-signals `computed`
  - combinator tag → `computed` that Math.max-folds subtag computes
  - constant tag  → a function that returns INITIAL
  - VolatileTag/CurrentTag → read a shared `tick` signal so subscribers
    re-run on every mutation

The only ember-specific bookkeeping that survives is the per-tag
re-entry flag for ALLOW_CYCLES (alien-signals' own cycle handling
short-circuits silently rather than throwing) and the updatable
buffer that keeps UPDATE_TAG from immediately invalidating against
a fresh subtag's higher revision. Both are guarded behind the public
contract — there are explicit tests for each in
@glimmer/validator/test/validators-test.ts.

Full suite (9308) still passes; net -33 lines in this file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title Back tag dirtiness with alien-signals Replace internal revision-tag state machine with alien-signals May 21, 2026
NullVoxPopuli and others added 3 commits May 21, 2026 00:34
Now that tags are alien-signals all the way down, the global revision
counter ($REVISION) is just shadowing the tick signal's value. Replaces
it with a single `next()` that does `tick(tick() + 1)`.

The per-tag re-entry flag and the explicit guard method also move out
of TagImpl — cycle detection is now a top-level `guard()` function
over a module-level WeakSet, with no per-instance field. Same contract
(throw in DEBUG when !ALLOW_CYCLES, bump revision otherwise) but no
TagImpl state to carry.

What still lives in TagImpl: `own` and `subRef` signals, the alien-
signals `computed` folds, and the updatable buffer/lastValue pair —
the last one because ember's chain-tag-via-updateTag pattern in
@ember/-internals/metal/computed.ts relies on "adoption doesn't
immediately invalidate the parent" to keep observers and caches from
spuriously firing. Without it, computed-test cycle and observer-
interop tests fail.

Net -42 lines in this file on top of the previous commit (-186/+134
became -278/+176 cumulative against main). 9308/9308 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous "rip out" commit was still organized around a class with
type-discriminated branches and full mimicry of the old state machine
internals. This is a real simplification of the *shape*: each tag type
is its own object literal with a small closure for [COMPUTE], no shared
class, no method dispatch over a type ID.

What's gone, vs. the previous commit:

- The TagImpl class entirely (dirtyable, updatable, combinator, constant
  are now constructed by four small factory functions, each returning a
  plain object with closures over its own state)
- Per-instance `computing` field; cycle re-entry is tracked in one
  module-level WeakSet via `safeCompute`
- The `$REVISION` export and `advance()` helper; one signal holds the
  global revision and `bump()` is `tick(tick() + 1)`
- The `CurrentTag` and `VolatileTag` exposed classes (only the
  instances were ever used in the repo; `@glimmer/validator` is private
  to ember-source and re-exports nothing of those classes publicly)
- The validators.ts re-exports of those classes from index.ts

What's still here, and why:

- `buffer`/`lastValue` on updatable tags. `_set` and `get` in
  @ember/-internals/metal/lib/computed.ts both call updateTag during
  the CP lifecycle and rely on adoption not immediately invalidating
  snapshots taken pre-adoption. Without it, "observers that do not
  consume computed properties still work" and the CP cycle interactions
  break.
- `inFlight`/`safeCompute` cycle escape. ember's CP set/get path
  intentionally builds cycles between property tags (fooTag.sub →
  barTag, barTag.sub → fooTag) and registers ALLOW_CYCLES. On
  re-entry we bump the global tick (so other caches invalidate next
  read) and return the tag's cached lastValue (so the cycle's stored
  snapshot stays validate-able). The previous attempt at this returned
  `advance()` directly, which kept advancing the tick on every
  validateTag call — that broke the "cached retrieve" assertion in
  computed_test's circular-keys case.

199 lines, 9308/9308 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@glimmer/validator is entirely private to ember-source (only
createCache/getValue/isConst from @glimmer/tracking/primitives/cache
and Cache from @ember/helper are public surface), so I'm free to
reshape the internals.

Tag was previously an interface with a [TYPE] discriminator and a
[COMPUTE]() method that callers went through to read the current
revision. Now Tag is callable: `tag()` returns the revision and, when
called inside an alien-signals subscriber, registers a dependency.
[COMPUTE] is kept only as a property alias so the type from
@glimmer/interfaces still resolves.

The class hierarchy collapses with it:
- MonomorphicTagId / DIRTYABLE_TAG_ID / etc. — gone. There is no
  type-id discriminator anymore; tags identify themselves by which
  WeakMap they're registered in (dirtyHandles, subRefs).
- The class tree (MonomorphicTag/UpdatableTag/DirtyableTag/ConstantTag/
  CombinatorTag) is now a single Tag type with type aliases.
- ALLOW_CYCLES export is gone. The "is this tag dirtyable" /
  "is this tag updatable" runtime checks are now lookups in their
  respective WeakMaps; the cycle guard on updatable tags is a private
  WeakSet inside the closure.

What survives in validators.ts and why (verified by removing each
and watching specific tests fail):
- A cycle re-entrance check. ember's CP set/get both call
  updateTag(propertyTag, depsChain), which sets up
  `barTag.sub → fooTag` and `fooTag.sub → barTag` when foo/bar
  depend on each other. alien-signals re-entry returns NaN, so a
  guard returns the tag's last-seen revision instead and bumps the
  global tick so other caches revalidate.
- The "adoption buffer" on updatable tags. updateTag adopts a
  subtag whose revision may already be higher than the parent's;
  naive `max(own, sub())` makes the parent jump and fires
  observers on the parent without anything they care about having
  changed (see `computed - observer interop`).

175 lines in validators.ts (was 199 last commit, ~301 originally).
9306/9306 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title Replace internal revision-tag state machine with alien-signals Rip out validator state machine; tags are now callable signals May 21, 2026
The `[COMPUTE]` / `[TYPE]` symbols and the whole tag-id hierarchy
were the last remnants of the old class-based Tag design. Now:

- `@glimmer/interfaces/lib/tags.d.ts` is 11 lines: `Tag = () => Revision`,
  with `UpdatableTag` / `DirtyableTag` / `ConstantTag` / `CombinatorTag`
  / `MonomorphicTag` as type aliases for back-compat.
- `validators.ts` no longer wraps things in an `asTag` helper or attaches
  `[COMPUTE]` to anything. Tags are returned as the read function itself.
- `combine` no longer uses an alien-signals `computed`; it's a plain
  closure that walks subtags. Memoization wasn't load-bearing for the
  combinator path and dropping it removes the re-entry/NaN footgun.
- The numeric `CONSTANT` and `VOLATILE` exports were unreferenced outside
  the validator; dropped.

The remaining state in `validators.ts`:
- `$tick` signal + `advance()` for the global revision counter
- `dirtyWriters` WeakMap keying tags to their underlying signal writer
- `updatables` WeakMap + `computing` WeakSet for updatable bookkeeping
  (sub pointer, buffer, last value, cycle re-entry)

142 lines. 9306/9306 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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