Skip to content

Conversation

@alxhub
Copy link
Member

@alxhub alxhub commented Dec 2, 2025

Previously, navigating a FieldTree in signal forms involved reactive reads of the value of the parent field(s), both directly and via .childrenMap().

This meant that on any change to the value of a field, reactive notifications would trigger updates of computeds, reruns of effects, etc. So for example, this effect would run on every change to the form:

const f = form(signal({data: 'abc', unrelated: 0}));
effect(() => {
  // accessing f.data incurs a dependency on f().value() which changes
  // on every change in the whole form
  console.log(f.data().value());
});

This is deeply counterintuitive and troublesome when attempting to write effect logic, and also results in computeds unnecessarily updating.

This change introduces the concept of a "reader" computed, which memoizes the access of a field at a given key via the reactive graph. With this, the same f.data access above now depends on the data reader in f only, which is effectively a constant computed. As a result, the effect only reruns on changes to data's value, as intended.

@ngbot ngbot bot added this to the Backlog milestone Dec 2, 2025
Previously, several values were being passed into the creation of
`FieldNodeStructure`s that were only used in the creation of child nodes.
Separately, we also passed a `createChildNode` function which these values
were passed back into.

Instead, this moves the small bit of logic from structure.ts behind the
`createChildNode` callback, which reduces the passing of values back-and-
forth and gives `createChildNode` a much more suitable signature.
@leonsenft leonsenft self-requested a review December 3, 2025 00:17
/** The logic to apply to this field. */
readonly logic: LogicNode,
) {}
constructor(logic: LogicNode, node: FieldNode, createChildNode: ChildNodeCtor) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Use parameter properties?

Suggested change
constructor(logic: LogicNode, node: FieldNode, createChildNode: ChildNodeCtor) {
constructor(
readonly logic: LogicNode,
readonly node: FieldNode,
readonly createChildNode: ChildNodeCtor,
) {}

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm starting to move away from non-erasable syntax in the TS I write.

Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we should have a lint for it, I didn't realize I shouldn't be doing this

Copy link
Member Author

Choose a reason for hiding this comment

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

It's more just something I'm experimenting with, it's not part of our style guide (yet).

Copy link
Contributor

Choose a reason for hiding this comment

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

Is the idea that eventually we'll be able to execute the TS source as JS without any compilation for faster dev if we exclusively use non-erasable syntax?

Comment on lines +189 to +190
// Remove fields that have disappeared since the last time this map was computed.
if (prevData !== undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like a good candidate to move to its own function to improve readability.

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe - it depends on type narrowing from the parent function and modifies local variable state though, so it'd be tricky to extract like this.

Copy link
Contributor

Choose a reason for hiding this comment

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

What parent function? It only appears to modify data, which isn't initialized before this block. Couldn't you simply do something like:

if (parentIsArray) {
  data = remainingDataFromArray(prevData, value);
} else {
  data = remainingDataFromObject(prevData, value);
}

Previously, navigating a `FieldTree` in signal forms involved reactive reads
of the value of the parent field(s), both directly and via `.childrenMap()`.

This meant that on _any_ change to the value of a field, reactive
notifications would trigger updates of computeds, reruns of effects, etc.
So for example, this effect would run on every change to the form:

```ts
const f = form(signal({data: 'abc', unrelated: 0}));
effect(() => {
  // accessing f.data incurs a dependency on f().value() which changes
  // on every change in the whole form
  console.log(f.data().value());
});
```

This is deeply counterintuitive and troublesome when attempting to write
effect logic, and also results in `computed`s unnecessarily updating.

This change introduces the concept of a "reader" computed, which memoizes
the access of a field at a given key via the reactive graph. With this, the
same `f.data` access above now depends on the `data` reader in `f` only,
which is effectively a constant computed. As a result, the effect only
reruns on changes to `data`'s value, as intended.
@alxhub alxhub added the target: patch This PR is targeted for the next patch release label Dec 3, 2025
let childLogic: LogicNode;
if (isArray) {
// Fields for array elements have their logic defined by the `element` mechanism.
// TODO: other dynamic data
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this TODO in reference to dynamic object logic? if so I think it can go away, as we have that now

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure - it was moved from the other file, I didn't write it (just now, anyway)

return Array.prototype[p as any];
return () => {
tgt.value();
return Array.prototype[Symbol.iterator].apply(tgt.fieldProxy);
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you actually need the .apply? I would think since its accessed as a property of the proxy, the this should be correct (unless the () => messes that up or something)

Copy link
Member Author

@alxhub alxhub Dec 3, 2025

Choose a reason for hiding this comment

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

Yep, the () messes it up - this in the arrow function is the proxy handler object, not the proxy itself. I think this is true even without the arrow function - we need the this to be the proxy.

/** The logic to apply to this field. */
readonly logic: LogicNode,
) {}
constructor(logic: LogicNode, node: FieldNode, createChildNode: ChildNodeCtor) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if we should have a lint for it, I didn't realize I shouldn't be doing this

Copy link
Contributor

@leonsenft leonsenft left a comment

Choose a reason for hiding this comment

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

Please use a fixup / additional commit in the future as the amended commit prevents GitHub from showing me what's changed since my last review. Fortunately this change is small enough that manually cross-referencing my old comments and the new code was feasible.

/** The logic to apply to this field. */
readonly logic: LogicNode,
) {}
constructor(logic: LogicNode, node: FieldNode, createChildNode: ChildNodeCtor) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the idea that eventually we'll be able to execute the TS source as JS without any compilation for faster dev if we exclusively use non-erasable syntax?

Comment on lines +189 to +190
// Remove fields that have disappeared since the last time this map was computed.
if (prevData !== undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What parent function? It only appears to modify data, which isn't initialized before this block. Couldn't you simply do something like:

if (parentIsArray) {
  data = remainingDataFromArray(prevData, value);
} else {
  data = remainingDataFromObject(prevData, value);
}

@alxhub alxhub added the action: merge The PR is ready for merge by the caretaker label Dec 3, 2025
@alxhub
Copy link
Member Author

alxhub commented Dec 3, 2025

This PR was merged into the repository. The changes were merged into the following branches:

alxhub added a commit that referenced this pull request Dec 3, 2025
Previously, several values were being passed into the creation of
`FieldNodeStructure`s that were only used in the creation of child nodes.
Separately, we also passed a `createChildNode` function which these values
were passed back into.

Instead, this moves the small bit of logic from structure.ts behind the
`createChildNode` callback, which reduces the passing of values back-and-
forth and gives `createChildNode` a much more suitable signature.

PR Close #65802
alxhub added a commit that referenced this pull request Dec 3, 2025
Previously, navigating a `FieldTree` in signal forms involved reactive reads
of the value of the parent field(s), both directly and via `.childrenMap()`.

This meant that on _any_ change to the value of a field, reactive
notifications would trigger updates of computeds, reruns of effects, etc.
So for example, this effect would run on every change to the form:

```ts
const f = form(signal({data: 'abc', unrelated: 0}));
effect(() => {
  // accessing f.data incurs a dependency on f().value() which changes
  // on every change in the whole form
  console.log(f.data().value());
});
```

This is deeply counterintuitive and troublesome when attempting to write
effect logic, and also results in `computed`s unnecessarily updating.

This change introduces the concept of a "reader" computed, which memoizes
the access of a field at a given key via the reactive graph. With this, the
same `f.data` access above now depends on the `data` reader in `f` only,
which is effectively a constant computed. As a result, the effect only
reruns on changes to `data`'s value, as intended.

PR Close #65802
@alxhub alxhub closed this in 37472e9 Dec 3, 2025
alxhub added a commit that referenced this pull request Dec 3, 2025
Previously, navigating a `FieldTree` in signal forms involved reactive reads
of the value of the parent field(s), both directly and via `.childrenMap()`.

This meant that on _any_ change to the value of a field, reactive
notifications would trigger updates of computeds, reruns of effects, etc.
So for example, this effect would run on every change to the form:

```ts
const f = form(signal({data: 'abc', unrelated: 0}));
effect(() => {
  // accessing f.data incurs a dependency on f().value() which changes
  // on every change in the whole form
  console.log(f.data().value());
});
```

This is deeply counterintuitive and troublesome when attempting to write
effect logic, and also results in `computed`s unnecessarily updating.

This change introduces the concept of a "reader" computed, which memoizes
the access of a field at a given key via the reactive graph. With this, the
same `f.data` access above now depends on the `data` reader in `f` only,
which is effectively a constant computed. As a result, the effect only
reruns on changes to `data`'s value, as intended.

PR Close #65802
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: merge The PR is ready for merge by the caretaker area: forms target: patch This PR is targeted for the next patch release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants