svelte icon indicating copy to clipboard operation
svelte copied to clipboard

Changing the reference of a spreadProps object causes bindable props with the default value of "undefined" to revert back to their default value.

Open HighFunctioningSociopathSH opened this issue 9 months ago • 7 comments

Describe the bug

I remember Svelte having this issue before but now I noticed it's back. When a component has spread props from a derived object, or perhaps any state that its reference changes entirely; It can cause the bindable props that have the default value of undefined, to go back to undefined.

Reproduction

For example here we have a simple Test component that does nothing special other than inspecting the bindable prop.

Test.svelte

<script lang="ts">
  let { ref = $bindable(), ...restProps } = $props();

  $inspect(ref);
</script>

<div bind:this={ref} {...restProps}></div>

And then in our page we pass it a spread props object that is derived, for example

+page.svelte

<script lang="ts">
  import Test from "$components/Test/Test.svelte";

  let hovered = $state(false);
  const spreadProps = $derived({
    hovered
  });
</script>

<Test {...spreadProps}></Test>

<button
  onmouseenter={() => {
    hovered = true;
  }}
  onmouseleave={() => {
    hovered = false;
  }}>button</button
>

After hovering over the button, you will notice that the $inspect inside Test.svelte will indicate that ref is changed to undefined. Here's a REPO For some reason the repo's console can't show the output of the $inspect properly so open your browser's devtools instead to see the log.

Logs


System Info

System:
    OS: Windows 11 10.0.22631
    CPU: (16) x64 12th Gen Intel(R) Core(TM) i7-12650H
    Memory: 6.50 GB / 15.63 GB
  Binaries:
    Node: 23.7.0 - C:\Program Files\nodejs\node.EXE
    npm: 11.1.0 - C:\Program Files\nodejs\npm.CMD
    bun: 1.1.3 - ~\.bun\bin\bun.EXE
  Browsers:
    Edge: Chromium (129.0.2792.52)
    Internet Explorer: 11.0.22621.3527

Severity

blocking all usage of svelte

REPL Looks like expected behavior. On hover, spreadProps becames { hovered: true } and by spreading it on Test, you update all props, but there is no ref, so it fallbacks to nothing (undefined).

Though, what is interesting, if the prop has a fallback, the value isn't reset. REPL

7nik avatar Feb 24 '25 14:02 7nik

@7nik I don't think this is expected behavior at all. It didn't used to be like this before and it breaks your code. You are effectively losing your ref and it breaks your code on the new update. If there is no new value for the bindable prop, it just shouldn't do anything and this is correct for anything other than "undefined".

Though, what is interesting, if the prop has a fallback, the value isn't reset.

That's because that is how it should be. "undefined" default values are the only ones that are acting differently.

Looks like expected behavior. On hover, spreadProps becames { hovered: true } and by spreading it on Test, you update all props.

That's not right. The user only updated "hovered" prop and the object only contains "hovered" prop. I know that technically the entire $props object might get a new reference or proxy but either way that is not what the user intended to do. There is no intention or even mention of changing the "ref" prop at all and its unexpected behavior.

@7nik This is the previous issue (#13187) that I said was really similar to this. It seems in that issue the problem was fixed but only for bindable props that actually had a default value.

@trueadm as someone who worked on the old issue mentioned above, I'd be happy if you could give it a look.

That's not right. The user only updated "hovered" prop and the object only contains "hovered" prop. I know that technically the entire $props object might get a new reference or proxy but either way that is not what the user intended to do. There is no intention or even mention of changing the "ref" prop at all and its unexpected behavior.

Well how do you differentiate between what's expected and what is not expected from the user perspective?

<script lang="ts">
  import Test from "$components/Test/Test.svelte";

  const spreadProps = $state({
    hovered: false
  });
</script>

<Test {...spreadProps}></Test>

<button
  onmouseenter={() => {
    spreadProps = {
        hovered: true,
        ref: undefined,
    });
  }}
  onmouseleave={() => {
    spreadProps = {
        hovered: false,
        ref: undefined,
    });
  }}>button</button
>

If i do this should the ref still be the same as before?

paoloricciuti avatar Feb 24 '25 20:02 paoloricciuti

@paoloricciuti Not anymore, because you are actively setting ref to a new value. Note that the ref was changed inside the component on mount. After hovering over the button you are changing the ref from a reference to a div back to undefined. Notice how when you look at your code, there is a clear indication that you want to set ref to undefined! Svelte should be able to distinguish the difference between these two, just as its doing it for bindable props that do have a default value. in javascript, an object that has a property that is set to undefined is different from an object that doesn't have that property defined at all.

  const firstObj = {
    ref: "something"
  };

  const secondObj = {
    ref: undefined
  };

  const finalObj = {
    ...firstObj,
    ...secondObj
  };
  console.log(finalObj); // => {ref: undefined};

While at the same time =>

  const firstObj = {
    ref: "something"
  };

  const secondObj = { };

  const finalObj = {
    ...firstObj,
    ...secondObj
  };
  console.log(finalObj); // => {ref: "something"};

Since it's been a while from when this issue was created and it is still open, I thought maybe I should provide another example to try and show why I think this is a bug. Imagine you have the following component that displays a select element and allows users to add options to this select element using an input element like so =>

<script lang="ts">
  let { firstName = "M", lastName = "R", options = $bindable() } = $props();

  let value = $state("");
</script>

<select style="min-width: 3rem;">
  {#each options as option}
    <option value={option}>{option}</option>
  {/each}
</select>
<br/>
<input bind:value placeholder="option to add"/>
<button onclick={(e) => (options = Array.isArray(options) ? [...options, value] : [value])}>add option</button>

Now just like a normal input element where the user changes the value prop through the UI and doesn't have to always bind or pass the value prop to the input, here the user doesn't want to control the options prop programmatically so they don't pass it when using the component, but they might want to change firstName or lastName.

<script lang="ts">
  import Test from "./Test.svelte";
	
  let firstName = $state("Celia");
  let lastName = $state("Higgins");
  const derivedProps = $derived({ firstName, lastName });
</script>

<Test {...derivedProps}></Test>
<br/>
<button onclick={(e) => (firstName = "Marvin")}>Change firstName prop</button>

Now If the options prop has a default value of undefined, and derivedProps is spread into Test, changing firstName by pressing the button which should have nothing to do with the already added options, causes the options prop to go back to undefined, causing a complete loss of state. This bug was fixed for other types of default values by @trueadm but if the bindable prop has a default value of undefined, it still happens. I hope could show that this kind of loss in a $state's data can be breaking. Repo

I don't know if I'm in the right place, but here's my REPL.

I don't like that the behavior of the component depends on whether it was used with the spread operator or not. Its behavior also changes based on which input receives the first uploaded file.

x0k avatar Nov 15 '25 03:11 x0k