Skip to content

Store each @tracked field in a TrackedValue - #21662

Draft
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/tracked-field-on-tracked-value
Draft

NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/tracked-field-on-tracked-value

Conversation

@NullVoxPopuli-ai-agent

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

Copy link
Copy Markdown
Contributor

The @tracked decorator now keeps each field in a TrackedValue, the class that the standalone tracked(value) form returns. A read or a write of a field does one WeakMap lookup. On main it does three map lookups through trackedData.

Rebased on main

This PR needed two parts of #21650, and both are merged: #21666 makes the functions of a TrackedValue on their first use, and #21667 has the fix for the V8 deopt on the first number.

The branch is now one commit on main, 9f82b544f7, and it merges alone. The numbers below are from before the rebase, with #21650 as the base.

How it works

  • descriptorForField keeps one WeakMap from the instance to a TrackedValue. The getter reads cell.value, and the setter writes it.
  • The TrackedValue is made at the first read or write of the field. A first read runs the initializer. A first write does not, as on main.

TrackedValue did not fit a field in three points. This PR handles each one:

point TrackedValue on main in this PR
tag makes a private tag the constructor takes an optional tag, and the decorator passes the tag of tagFor(obj, key)
equal values Object.is skips the write a field uses an equals that returns false, unless the decorator got an equals option
dev assertion "already consumed" has no object and no key the field setter runs the assertion itself, with the object and the key
  • The tag point matters because observers, computed chains, and notifyPropertyChange get the tag of a field from the registry.
  • The equality point matters because this.items = this.items is how code reports a change inside a value.

After the rebase, against main

Control is main at 9bec1cb2a8, which has the merged parts of #21650. Experiment is the one commit of this PR, 9f82b544f7.

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

  • Script time for the full run without GC: -0.4%, which is not significant.
  • As full phases, 1 of 23 has a significant change: removeSecondRow1 is 4.2% slower.
  • render shows +40.3% in script time without GC, on a phase of 20 ms. With GC time included, the same phase is -0.1%. So this number comes from the GC subtraction on a small phase.

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
this PR against main -1.9% +0.7% -4.2% +3.4% +2.3% -7.5% -2.7% -0.3% -0.7%
pair change cycle 1 cycle 2 cycle 3 cycle 4
cell (#21661) against main -6.2% -0.0% -8.0% -6.4% -11.0%
TrackedValue (#21662) against main -3.7% +2.7% -7.9% -0.1% -5.2%
TrackedValue against the cell +2.7% +2.7% +0.1% +6.7% +6.5%

pnpm bench

Against the cell of #21661, with both changes on top of #21650: no phase differs.

Against #21650 alone: tracerbench-report.pdf.

  • Script time for the full run: -0.3%, which is not significant.
  • selectSecondRow1 is 4.1% faster. swapRows1 is 3.3% slower as a full phase, and render10000Items1 is 2.2% faster as a full phase.

rere-benchmark

main, #21650, #21650 with the cell of #21661, and this PR ran in 4 mirrored cycles: 8 runs for each build, 5 samples per bench in each run, 0 void runs. The numbers are the geometric mean of 13 benches.

pair change cycle 1 cycle 2 cycle 3 cycle 4
cell against #21650 -6.5% -14.2% -6.4% -0.3% -4.6%
this PR against #21650 -2.9% -9.5% -1.2% +2.6% -2.4%
this PR against the cell +3.9% +5.5% +5.5% +2.9% +2.3%

Tests

  • 3 new tests in metal/tests/tracked/validation_test.js: a write dirties the tag that tagForProperty gave before the first read or write, notifyPropertyChange dirties the tag that a read consumed, and a first write does not run the initializer.
  • If the TrackedValue keeps a private tag, 5 tests of that file fail: the first two new ones and three that are on main.
  • The full suite passes locally after the rebase: 9526 pass, 18 skipped, 0 failed.
  • tsc --noEmit, ESLint, Prettier and pnpm lint:docs pass.

Not measured

The benches have one or two tracked fields each. They show the cost of a read and a write, but not the size of a TrackedValue for each field of each instance. With #21650, a TrackedValue has 8 private fields. The cell of #21661 has 3 fields.

🤖 Generated with Claude Code

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>

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