-
Notifications
You must be signed in to change notification settings - Fork 26.9k
fix(forms): memoize reads of child fields in signal forms #65802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
c7e0f8d to
83d7050
Compare
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.
83d7050 to
2f79277
Compare
| /** The logic to apply to this field. */ | ||
| readonly logic: LogicNode, | ||
| ) {} | ||
| constructor(logic: LogicNode, node: FieldNode, createChildNode: ChildNodeCtor) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Use parameter properties?
| constructor(logic: LogicNode, node: FieldNode, createChildNode: ChildNodeCtor) { | |
| constructor( | |
| readonly logic: LogicNode, | |
| readonly node: FieldNode, | |
| readonly createChildNode: ChildNodeCtor, | |
| ) {} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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?
| // Remove fields that have disappeared since the last time this map was computed. | ||
| if (prevData !== undefined) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
2f79277 to
e67abd4
Compare
| let childLogic: LogicNode; | ||
| if (isArray) { | ||
| // Fields for array elements have their logic defined by the `element` mechanism. | ||
| // TODO: other dynamic data |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
leonsenft
left a comment
There was a problem hiding this 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) { |
There was a problem hiding this comment.
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?
| // Remove fields that have disappeared since the last time this map was computed. | ||
| if (prevData !== undefined) { |
There was a problem hiding this comment.
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, 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
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
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
Previously, navigating a
FieldTreein 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:
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.dataaccess above now depends on thedatareader infonly, which is effectively a constant computed. As a result, the effect only reruns on changes todata's value, as intended.