fix: ensure props passed to components via mount are updateable (#14210)

The detection whether or not props are writable is buggy; it doesn't take into account the case when instantiating components via `mount` or legacy-`new`

Fixes #14161

This posed an interesting question: What to do about (non-)`bind:`able properties? The answer I arrived on was: Treat it as if everything that _can_ be bound _is_ treated as bound, and everything else as readonly. In other words, if you're reassigning a prop, it will diverge from the passed in props if it's not bindable or not set in the parent, otherwise it will mutate the passed in props. I think that makes the most sense, given that you can't control this behavior from the outside.
pull/14083/merge
Simon H 1 month ago committed by GitHub
parent 7fd3774015
commit e379319626
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: ensure props passed to components via mount are updateable

@ -22,4 +22,5 @@ export const EFFECT_HAS_DERIVED = 1 << 19;
export const STATE_SYMBOL = Symbol('$state');
export const STATE_SYMBOL_METADATA = Symbol('$state metadata');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');

@ -20,7 +20,13 @@ import {
} from '../runtime.js';
import { safe_equals } from './equality.js';
import * as e from '../errors.js';
import { BRANCH_EFFECT, LEGACY_DERIVED_PROP, ROOT_EFFECT } from '../constants.js';
import {
BRANCH_EFFECT,
LEGACY_DERIVED_PROP,
LEGACY_PROPS,
ROOT_EFFECT,
STATE_SYMBOL
} from '../constants.js';
import { proxy } from '../proxy.js';
import { capture_store_binding } from './store.js';
import { legacy_mode_flag } from '../../flags/index.js';
@ -209,6 +215,9 @@ const spread_props_handler = {
}
},
has(target, key) {
// To prevent a false positive `is_entry_props` in the `prop` function
if (key === STATE_SYMBOL || key === LEGACY_PROPS) return false;
for (let p of target.props) {
if (is_function(p)) p = p();
if (p != null && key in p) return true;
@ -282,7 +291,14 @@ export function prop(props, key, flags, fallback) {
} else {
prop_value = /** @type {V} */ (props[key]);
}
var setter = get_descriptor(props, key)?.set;
// Can be the case when someone does `mount(Component, props)` with `let props = $state({...})`
// or `createClassComponent(Component, props)`
var is_entry_props = STATE_SYMBOL in props || LEGACY_PROPS in props;
var setter =
get_descriptor(props, key)?.set ??
(is_entry_props && bindable && key in props ? (v) => (props[key] = v) : undefined);
var fallback_value = /** @type {V} */ (fallback);
var fallback_dirty = true;

@ -1,4 +1,4 @@
import { dev_current_component_function, untrack } from './runtime.js';
import { dev_current_component_function } from './runtime.js';
import { get_descriptor, is_array } from '../shared/utils.js';
import * as e from './errors.js';
import { FILENAME } from '../../constants.js';
@ -6,15 +6,6 @@ import { render_effect } from './reactivity/effects.js';
import * as w from './warnings.js';
import { capture_store_binding } from './reactivity/store.js';
/** regex of all html void element names */
const void_element_names =
/^(?:area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/;
/** @param {string} tag */
function is_void(tag) {
return void_element_names.test(tag) || tag.toLowerCase() === '!doctype';
}
/**
* @param {() => any} collection
* @param {(item: any, index: number) => string} key_fn

@ -1,5 +1,5 @@
/** @import { ComponentConstructorOptions, ComponentType, SvelteComponent, Component } from 'svelte' */
import { DIRTY, MAYBE_DIRTY } from '../internal/client/constants.js';
import { DIRTY, LEGACY_PROPS, MAYBE_DIRTY } from '../internal/client/constants.js';
import { user_pre_effect } from '../internal/client/reactivity/effects.js';
import { mutable_source, set } from '../internal/client/reactivity/sources.js';
import { hydrate, mount, unmount } from '../internal/client/render.js';
@ -89,7 +89,7 @@ class Svelte4Component {
};
// Replicate coarse-grained props through a proxy that has a version source for
// each property, which is increment on updates to the property itself. Do not
// each property, which is incremented on updates to the property itself. Do not
// use our $state proxy because that one has fine-grained reactivity.
const props = new Proxy(
{ ...(options.props || {}), $$events: {} },
@ -98,6 +98,9 @@ class Svelte4Component {
return get(sources.get(prop) ?? add_source(prop, Reflect.get(target, prop)));
},
has(target, prop) {
// Necessary to not throw "invalid binding" validation errors on the component side
if (prop === LEGACY_PROPS) return true;
get(sources.get(prop) ?? add_source(prop, Reflect.get(target, prop)));
return Reflect.has(target, prop);
},

@ -0,0 +1,47 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target }) {
assert.htmlEqual(
target.innerHTML,
// The buz fallback does not propagate back up
`
<button>reset</button> foo baz
<div><button>update</button> foo bar baz buz</div>
<div><button>update</button> foo bar baz buz</div>
`
);
const [btn1, btn2, btn3] = target.querySelectorAll('button');
btn2.click();
btn3.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
// bar is not set in the parent because it's a readonly property
// baz is not set in the parent because while it's a bindable property,
// it wasn't set initially so it's treated as a readonly proeprty
`
<button>reset</button> foo 3
<div><button>update</button> 1 2 3 4</div>
<div><button>update</button> 1 2 3 4</div>
`
);
btn1.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
// Because foo is a readonly property, component.svelte diverges locally from it,
// and the passed in property keeps the initial value of foo. This is why it stays
// at 1, because foo is not updated to a different value.
`
<button>reset</button> foo bar baz buz
<div><button>update</button> 1 bar baz buz</div>
<div><button>update</button> 1 bar baz buz</div>
`
);
}
});

@ -0,0 +1,17 @@
<script>
let { foo, bar = 'bar', baz = $bindable(), buz = $bindable('buz') } = $props();
</script>
<button
onclick={() => {
foo = '1';
bar = '2';
baz = '3';
buz = '4';
}}>update</button
>
{foo}
{bar}
{baz}
{buz}

@ -0,0 +1,30 @@
<script>
import { createClassComponent } from 'svelte/legacy';
import Component from './component.svelte';
import { mount, onMount } from 'svelte';
let div1, div2;
let legacy;
const props = $state({ foo: 'foo', baz: 'baz' });
onMount(() => {
legacy = createClassComponent({
component: Component,
target: div1,
props: { foo: 'foo', baz: 'baz' }
});
mount(Component, { target: div2, props });
});
</script>
<button
onclick={() => {
legacy.$set({ foo: 'foo', bar: 'bar', baz: 'baz', buz: 'buz' });
props.foo = 'foo';
props.bar = 'bar';
props.baz = 'baz';
props.buz = 'buz';
}}>reset</button
> {props.foo} {props.bar} {props.baz} {props.buz}
<div bind:this={div1}></div>
<div bind:this={div2}></div>
Loading…
Cancel
Save