-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
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
fix: #4265 prevent double update when bind to object #8992
Conversation
|
if (ready) make_dirty(component, i); | ||
else $$.bound[i] = true; // dirty flag consumed in bind() |
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.
The $$invalidate
runtime function, when ready == false
sets $$.bound[i] = true
in order to cater for case 3. This mark is essentially the same as $$.dirty
which is not available before ready
.
let dirty = false; | ||
if (component.$$.bound[i]) dirty = true; | ||
component.$$.bound[i] = callback; | ||
// first binding call, if child value is not yet dirty, skip to prevent unnecessary backflow | ||
callback(component.$$.ctx[i], /** skip_binding */ !dirty); |
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.
The bind
runtime function triggers the first backflow during flush
, here I pass a skip_binding
flag to child_value_binding
. The flag is determined by $$.bound[i]
dirty mark. This takes care of case 2 and 3.
Case 2: Child does not touch passed-in prop value
Case 3: Child modifies the value inside $:
reactive statements
if (!#skip_binding || ${lhs} !== #value) { | ||
${invalidate_binding} | ||
} |
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.
if (!#skip_binding || ${lhs} !== #value)
guard prevents the unnecessary backflow of case 2, also ${lhs} !== #value
check allows backflow for case 1 and 4.1.
Case 2: Child does not touch passed-in prop value, regardless it's primitive or object, backflow is unnecessary
Case 1 and 4.1: Child value is referentially different from Parent value
4.2 working now but not working anymore is a breaking change very strictly speaking - though this case is probably rare and you shouldn't be doing something like this anyway, so I could be persuaded to agree with this PR. I'll wait on the opinion of other maintainers here, cc @benmccann / @Conduitry |
@dummdidumm It's possible to cater for case 4.2 and get rid of breaking change. Basically we need to insert <script>
export let value = {}
value.foo = 'bar'
</script> compiled to function instance($$self, $$props, $$invalidate) {
let { value = {} } = $$props;
- value.foo = 'bar';
+ $$invalidate(0, value.foo = 'bar', value); IMO that comes at the cost of breaking the elegancy of So I don't think it's worth it, since 4.2 feels like a really bad pattern to begin with and shouldn't be encouraged. But if you guys decide that it's really important to stick to non-breaking-change policy, I'll look further into it. Look forward to feedback. |
let invalidate; | ||
if (!main_execution_context) { | ||
if (!main_execution_context || is_props) { |
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.
Compiler also inserts $$invalidate
around exported variables at instance main_execution_context
.
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.
@Conduitry / @tanhauhau is this ok to add invalidation in this case, too? Why's invalidation generally forbidden in the main execution context?
} else if (main_execution_context && is_props) { | ||
const member = renderer.context_lookup.get(name); | ||
return x`$$invalidate(${member.index}, ${name})`; |
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.
Compiler also inserts $$invalidate
around exported variables at instance main_execution_context
.
svelte/packages/svelte/src/compiler/compile/render_dom/invalidate.js Lines 130 to 132 in 0e5e471
Mod |
Hi @dummdidumm and other core team members, I'd like to push forward this PR. Now that I've removed the breaking change issue, would you take another look and give me some feedback on how to proceed? cc @benmccann @Conduitry |
Any update on if this is ready to be merged? |
Closing Svelte 4 PRs as stale — thank you (this is fixed in Svelte 5) |
fix #4265
This is a long standing issue that's been reported multiple times, see #4430 #4447 #5555 #6590, the list goes on...It was once partially fixed in #7981 but then reverted in #8114.
Main idea of the fix
The thing that bugs ppl the most is double firing update when bind to non-primitive value at component init phase. This fix targets this scenario specifically.
I'm gonna use the term "backflow" to refer to the process of
child_value_binding() -> invalidate(parent_value)
. Backflow is necessary to sync child value back to parent. But it could be unnecessary in some cases. Becausesafe_not_equal
conservatively invalidates non-primitive value, it's a failed guard. So this fix propose an extra flagskip_binding
to guard against unnecessary backflow.Noted that first backflow happen at
flush -> binding_callbacks.pop()() -> bind -> child_value_binding
. By this time we've completed init phase, and every component isready
.Now focus on init phase and consider the following cases (✅ = necessary backflow, ❌ = unnecessary backflow)
undefined
, Child shall backflow to populate Parent value.Below we discuss cases where Parent pass non-undefined down to Child.
This is also THE MOST COMMON USE CASE which current PR seek to fix.
$:
statements) modifies the value, duringchild.$$.update()
call, need backflow.init -> instance
call, need backflow.This case should be further broken down to 2 sub-cases:
4.1. Immutable modification, value's reference changed, e.g.
export let value; value = ["default"];
4.2. Mutable modification, e.g.
export let value; value.foo = "bar";
Proposed fix correctly handles backflow in all above cases EXCEPT sub-case 4.2.Proposed fix correctly handles backflow in all above cases.
Implementation
Changes made to three places:
$$invalidate
runtime function, whenready == false
sets$$.bound[i] = true
in order to cater for case 3. This mark is essentially the same as$$.dirty
which is not available beforeready
.bind
runtime function triggers the first backflow duringflush
, here I pass askip_binding
flag tochild_value_binding
. The flag is determined by$$.bound[i]
dirty mark. This takes care of case 2 and 3.child_value_binding
function, I addif (!#skip_binding || ${lhs} !== #value)
guard. It prevents the unnecessary backflow of case 2, also${lhs} !== #value
check allows backflow for case 1 and 4.1.Case 4.2. needs backflow
but is unfortunately left out, because a)and is now detected by inserting$$invalidate
is not called, b)${lhs} !== #value
fails to detect, we don't have the tool to distinguish this case.$$invalidate
call on prop assignment atinstance
top level context.Before submitting the PR, please make sure you do the following
feat:
,fix:
,chore:
, ordocs:
.Tests and linting
pnpm test
and lint the project withpnpm lint