Skip to content

Spike: remove trackedData and other copies of value-plus-tag state - #21670

Draft
NullVoxPopuli-ai-agent wants to merge 5 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/spike-one-reactive-primitive
Draft

NullVoxPopuli-ai-agent wants to merge 5 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/spike-one-reactive-primitive

Conversation

@NullVoxPopuli-ai-agent

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

Copy link
Copy Markdown
Contributor

Spike: one way to hold reactive state. trackedData is gone, and four other copies of "a value next to a tag" now use TrackedValue or the tag registry.

  • 14 files changed, 125 lines added, 327 removed, against the content of Store each @tracked field in a TrackedValue #21662.
  • After the rebase, the full suite passes with legacy decorators: 9522 pass, 18 skipped, 0 failed. Before the rebase it also passed with VITE_STABLE_DECORATORS=true. That mode did not run again.
  • After the rebase, against main: pnpm bench shows no change (-0.1%, 0 of 23 phases), and rere-benchmark shows -0.2% across 8 cycles, which is no clear change. Against Store each @tracked field in a TrackedValue #21662 alone, the four spike commits are 1.8% slower in rere-benchmark, inside the spread of the cycles.

Depends on #21662

The branch is rebased on main of 2026-10-07. It holds the one commit of #21662 (9f82b544f7), then the four commits of this spike. The parts of #21650 that it needed are merged. The PRs are not a GitHub stack, because the branches are in a fork and the repository does not have stacked PRs on.

The four commits

commit what was there now
c93f7db5b4 Remove trackedData tracked-data.ts: a WeakMap of values plus the tag registry. It had no caller after #21662. deleted, with its export and its tests
f00920fb3b Keep a tracked accessor in a TrackedValue too the accessor form of @tracked had its own copy of the tag logic, with a registry lookup on each read and write the storage of the accessor holds a TrackedValue. The field form and the accessor form share one read function and one write function.
5839bbc474 Keep the list item value and the root outlet state in a TrackedValue createIteratorItemRef and createRootOutletState were each a closure with a value and a tag a TrackedValue
f45c1cbde8 Use the tag registry functions in the tracked collections each of the six collections had its own "get or make the tag of a key" and "dirty the tag of a key" tagFor and dirtyTagFor, with the map of the collection as the store

What changes for callers

  • trackedData is no longer an export of @glimmer/validator. In the repo it had one caller. As one sample of outside use, the 1,843 packages in the node_modules of one Ember app have no other user.
  • tagFor and dirtyTagFor take any key type, and a store with get and set. A WeakMap fits.
  • In debug builds, a write to a tracked collection after a read in the same computation now uses the assertion of dirtyTagFor. The message names the object and the key.
  • A tracked accessor makes its TrackedValue, and asks the registry for its tag, when the instance is made. Before, the registry made the tag at the first read.

Looked at and left alone

These also keep a value or a revision next to a tag, but they are not a second way to hold source state:

  • createCache, ReferenceImpl, computed properties, and observers keep a derived value with a tag and a revision. ReferenceImpl repeats what createCache does. That is a bigger change than this spike.
  • Component[DIRTY_TAG] and Helper[RECOMPUTE_TAG] are tags with no value.
  • Modifier tags and the lazy chain tags are tags that updateTag points at other tags.
  • CUSTOM_TAG_FOR computes a tag on demand for args and proxies.
  • smoke-tests/benchmark-app/app/services/cell.js is a copy of TrackedValue. I left it, because a change there changes the control side of pnpm bench.

Measurements

After the rebase

Control is main at 9bec1cb2a8. The spike is f45c1cbde8, and #21662 is 9f82b544f7.

pnpm bench, 50 paired rounds, spike against main: tracerbench-report.pdf

  • Script time for the full run without GC: -0.1%, which is not significant. 0 of 23 phases have a significant change.

rere-benchmark, 8 mirrored cycles, 16 runs for each build, 0 void runs, geometric mean of 13 benches:

pair change c1 c2 c3 c4 c5 c6 c7 c8
#21662 against main -1.9% +0.7% -4.2% +3.4% +2.3% -7.5% -2.7% -0.3% -0.7%
this spike against main -0.2% -0.3% -2.9% +1.8% +8.7% -5.2% +0.4% -3.4% +5.1%
this spike against #21662 +1.8% -1.0% +1.4% -1.5% +6.3% +2.4% +3.2% -3.0% +5.8%
  • The main build drifted 5.4% between the first four cycles and the last four, and the sign of each pair changes between cycles. No pair shows a clear change.
  • The spike is slower than Store each @tracked field in a TrackedValue #21662 in 5 of 8 cycles. The largest single bench is 1k items 1 update on 5% (random, async) at 11.9% slower. A cost of the TrackedValue for each list item is possible, but this round does not prove it.
  • 1 item, 100k updates: Store each @tracked field in a TrackedValue #21662 is 11.8% faster than main, and the spike is 9.5% faster.

Before the rebase

These numbers are from the head before the rebase (249ec6cef0). The control for the four spike commits is the content of #21662 on top of #21650.

pnpm bench, 50 paired rounds, script time without GC: tracerbench-report.pdf

  • Full run: -0.6%, which is not significant.
  • 3 of 23 phases have a significant change: render1000Items2 is 3.9% faster, render10000Items2 is 1.3% faster, and swapRows1 is 5.0% slower.

rere-benchmark, 4 mirrored cycles, 8 runs for each build, 0 void runs, geometric mean of 13 benches:

pair change cycle 1 cycle 2 cycle 3 cycle 4
#21662 against main -4.6% -6.3% -3.8% -1.2% -6.6%
this spike against main -3.1% -7.2% -2.8% +1.3% -2.6%
this spike against #21662 +1.7% -1.0% +1.0% +2.5% +4.2%

Tests

  • The trackedData tests are deleted. The decorator tests cover the same cases: a first read with and without an initializer, a write before a read, and the tag of the field.
  • tsc --noEmit, ESLint, Prettier and pnpm lint:docs pass.

🤖 Generated with Claude Code

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

this is kinda messy right now. other PRs need to land for this to look like a smaller PR

NullVoxPopuli and others added 5 commits October 7, 2026 13:18
The `@tracked` decorator kept each field in `trackedData`: a WeakMap of
values, plus the tag registry (a WeakMap, then a Map per object) for the
tag. A read or a write went through three maps.

The decorator now keeps one WeakMap from the instance to a TrackedValue,
which holds the value and the tag. This is the same class that the
standalone `tracked(value)` form returns.

- The TrackedValue takes its tag from the tag registry, so `tagFor`,
  `dirtyTagFor`, observers, computed chains and `notifyPropertyChange`
  work on the tag of the field.
- A field notifies on every write, so its `equals` is a function that
  returns false, unless the decorator got an `equals` option.
- The field setter runs the "already consumed" assertion itself, because
  only the decorator knows the object and the key for the message.
- The TrackedValue is made at the first read or write. A first write
  does not run the initializer, as before.

`trackedData` has no caller in the repo after this.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The `@tracked` decorator was its one caller, and it now keeps each field
in a TrackedValue. The tests of the decorator cover what the trackedData
tests covered: first read with and without an initializer, a write
before a read, and the tag of the field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The accessor form of `@tracked` had its own copy of the tag logic: a
registry lookup on each read and each write, next to the native storage
of the accessor. The storage now holds a TrackedValue, and the field
form and the accessor form share one read function and one write
function.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each one was a closure with a value and a tag of its own, which is what
a TrackedValue is. The item keeps its `!==` compare, and a set of the
root outlet state always notifies, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each of the six collections had its own copy of two functions: get or
make the tag of a key, and dirty the tag of a key if it exists. That is
what `tagFor` and `dirtyTagFor` do with a map that the caller passes.

The two functions now take any key type, with a store that has `get`
and `set`, so a WeakMap fits too. In debug builds a write to a
collection now uses the assertion of `dirtyTagFor`, which names the
object and the key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent force-pushed the nvp/spike-one-reactive-primitive branch from 249ec6c to f45c1cb Compare October 7, 2026 17:23
export { trackedWeakSet } from './lib/collections/weak-set';
export { debug } from './lib/debug';
export { dirtyTagFor, tagFor, type TagMeta, tagMetaFor } from './lib/meta';
export { trackedData } from './lib/tracked-data';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this package is not public, so 🙈

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