Skip to content
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

Clarification on the behavior of two-way bindings #8905

Closed
mpopovic4116 opened this issue Jul 4, 2023 · 2 comments
Closed

Clarification on the behavior of two-way bindings #8905

mpopovic4116 opened this issue Jul 4, 2023 · 2 comments
Milestone

Comments

@mpopovic4116
Copy link

Describe the problem

The documentation regarding two-way bindings for component properties is very vague. It says you can bind to component props using the same syntax as for elements, but doesn't mention how these bindings interact with the parent component and other reactive variables.

I created some test apps and stepped through the debugger until I had a decent idea of what was happening. Here's my understanding of what goes on in this REPL:

App.svelte

<script>
	import Inner from "./Inner.svelte";

	let value = { v: "initial" };

	$: console.log("Outer.value.v", value.v);
</script>

<Inner bind:outValue={value} />

Inner.svelte

<script>
	let localTrig = 0;
	export let outValue;

	function onClick() {
		console.log("click");
		localTrig++;
	}

	$: outValue = { v: `abc${localTrig}` };
	$: console.log("Inner.outValue.v", outValue.v);
</script>

<button on:click={onClick}>Click</button>

For readability, component.$$.dirty is not an array of bitfields but a Set of strings and component.$$.bound uses strings (prop names) as keys.

Button clicked in Inner
    $$invalidate for local reactive variable "localTrig"
        dirty_components.push(Inner)
        schedule_update()
        Inner.$$.dirty.add("localTrig")

Microtask to update dirty components is called (flush)
    dirty_components, flushidx: [->Inner<-]
        .$$.dirty.has("localTrig")
            $$invalidate for exported variable "outValue"
                Two-way binding detected, call $$.bound["outValue"](outValue)
                    $$invalidate for App's reactive variable "value"
                        dirty_components.push(App)
                        app.$$.dirty.add("value")
                .$$.dirty.add("outValue")
        .$$.dirty.has("outValue")
            console.log(...)

    dirty_components, flushidx: [Inner, ->App<-]
        .$$.dirty.has("value")
            console.log(...)
    
    app.$$.fragment.p
        inner_changes = {}
        !updating_outValue && dirty.has("value")
            updating_outValue = true
            inner_changes["outValue"] = value
            add_flush_callback(() => updating_outValue = false)
        inner.$set(inner_changes)
            Check that inner_changes is not empty
            inner.$$.skip_bound = true
            inner.$$set(inner_changes)
                $$invalidate for Inner's exported variable "outValue"
                    Two-way binding detected but skipped due to being called by SvelteComponent.$set
                    dirty_components.push(Inner)
                    inner.$$.dirty.add("outValue")
            inner.$$.skip_bound = false
    
    dirty_components, flushidx: [Inner, App, ->Inner<-]
        .$$.dirty.has("outValue")
            console.log(...)
    
    Run flush callbacks:
        updating_outValue = false

I'd like to know how much of this is:

  • Wrong
  • Internal behavior that shouldn't be relied upon
  • Guaranteed to keep working after an update

And if #5689 and #8184 ever get fixed, will it be considered a breaking change or a "you brought this on yourself by relying on unspecified behavior" change?

The second REPL tests how many updates can be sent between the parent and child components, and the answer seems to be two updates from the child and one update from the parent.

Not limiting the number of updates would allow something that looks a bit like a useEffect cascade if you squint, so this might be intentional. The presence of updating_outValue in App's $$.fragment.p also suggests it's intentional, but it might serve another purpose. Since this isn't documented, I'd still like a clarification.

The third REPL tests how the order of assignments in code outside the child component's $$.update function (for example, in an event handler) affects the order of reactive statements in the parent and the child.

Since bound variables are updated synchronously at the time of the assignment, the order of reactive variable assignments affects the order of component updates. Assigning a bound variable first will cause the parent component to be updated first, and assigning some other reactive variable will cause the child to be updated first.

The fourth REPL demonstrates how assignments to bound variables can be detected before the parent's $$.update function runs by binding the child's prop to a store with a custom set function.

Describe the proposed solution

Documentation addressing the following points (i.e. a guarantee the documented behavior won't silently break when the internals change):

  • What happens when a component with a bind: prop is mounted by a parent component? Is it guaranteed to cause a reactive update for the parent, even if the child doesn't change the value?
  • What happens when a child component updates a bound variable?
    • If updating outside a reactive statement, do the child's reactive statements run first or do the parent's?
    • Can the parent observe intermediate (i.e. not the final update to this particular variable in $$.update) changes to bound reactive variables?
    • If not, what happens when the parent component receives an event? Does it see the old values, or the new ones? Currently, this doesn't matter since the parent can observe changes, but if the behavior of two-way bindings is changed, this is something that should be addressed.
    • The order of on: and bind: props on "native" elements affects which version of the values are available to the event handlers. Currently, this does not apply to Svelte components.
  • Can the parent and child use two-way binding to update each other's reactive variables in a potentially infinite loop? If not, what's the limit?

Alternatives considered

N/A - this feature request is for documentation regarding two-way binding, and whether or not the current observable behavior is likely to change after an update.

Importance

nice to have

@dummdidumm
Copy link
Member

to be honest most of this is somewhat unspecified behavior, some things are considered bugs. With Svelte 5 we'll make things more consistent and predictable - so I'm somewhat hesitant to write out all the nuances in behavior since some of them will change.

@dummdidumm
Copy link
Member

As promised, Svelte 5 will make this far easier to reason about. You can read more about the new behavior in the preview docs. TLDR: variables that Svelte directly connects to a $state rune are updated consistently, for everything else use fine-grained reactivity.

@dummdidumm dummdidumm added this to the 5.x milestone Nov 15, 2023
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

No branches or pull requests

2 participants