breaking: add $bindable() rune to denote bindable props (#10851)

Alternative to / closes #10804
closes #10768
closes #10711
pull/10877/head
Simon H 4 months ago committed by GitHub
parent 2cabc884ca
commit 416bc85d9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -182,6 +182,7 @@ const runes = {
`$props() assignment must not contain nested properties or computed keys`,
'invalid-props-location': () =>
`$props() can only be used at the top level of components as a variable declaration initializer`,
'invalid-bindable-location': () => `$bindable() can only be used inside a $props() declaration`,
/** @param {string} rune */
'invalid-state-location': (rune) =>
`${rune}(...) can only be used as a variable declaration initializer or a class field`,

@ -436,7 +436,7 @@ export function analyze_component(root, options) {
);
}
} else {
instance.scope.declare(b.id('$$props'), 'prop', 'synthetic');
instance.scope.declare(b.id('$$props'), 'bindable_prop', 'synthetic');
instance.scope.declare(b.id('$$restProps'), 'rest_prop', 'synthetic');
for (const { ast, scope, scopes } of [module, instance, template]) {
@ -466,7 +466,10 @@ export function analyze_component(root, options) {
}
for (const [name, binding] of instance.scope.declarations) {
if (binding.kind === 'prop' && binding.node.name !== '$$props') {
if (
(binding.kind === 'prop' || binding.kind === 'bindable_prop') &&
binding.node.name !== '$$props'
) {
const references = binding.references.filter(
(r) => r.node !== binding.node && r.path.at(-1)?.type !== 'ExportSpecifier'
);
@ -759,7 +762,7 @@ const legacy_scope_tweaker = {
(binding.kind === 'normal' &&
(binding.declaration_kind === 'let' || binding.declaration_kind === 'var')))
) {
binding.kind = 'prop';
binding.kind = 'bindable_prop';
if (specifier.exported.name !== specifier.local.name) {
binding.prop_alias = specifier.exported.name;
}
@ -797,7 +800,7 @@ const legacy_scope_tweaker = {
for (const declarator of node.declaration.declarations) {
for (const id of extract_identifiers(declarator.id)) {
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(id.name));
binding.kind = 'prop';
binding.kind = 'bindable_prop';
}
}
}
@ -886,11 +889,24 @@ const runes_scope_tweaker = {
property.key.type === 'Identifier'
? property.key.name
: /** @type {string} */ (/** @type {import('estree').Literal} */ (property.key).value);
const initial = property.value.type === 'AssignmentPattern' ? property.value.right : null;
let initial = property.value.type === 'AssignmentPattern' ? property.value.right : null;
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(name));
binding.prop_alias = alias;
binding.initial = initial; // rewire initial from $props() to the actual initial value
// rewire initial from $props() to the actual initial value, stripping $bindable() if necessary
if (
initial?.type === 'CallExpression' &&
initial.callee.type === 'Identifier' &&
initial.callee.name === '$bindable'
) {
binding.initial = /** @type {import('estree').Expression | null} */ (
initial.arguments[0] ?? null
);
binding.kind = 'bindable_prop';
} else {
binding.initial = initial;
}
}
}
},

@ -299,17 +299,19 @@ const validation = {
error(node, 'invalid-binding-expression');
}
const binding = context.state.scope.get(left.name);
if (
assignee.type === 'Identifier' &&
node.name !== 'this' // bind:this also works for regular variables
) {
const binding = context.state.scope.get(left.name);
// reassignment
if (
!binding ||
(binding.kind !== 'state' &&
binding.kind !== 'frozen_state' &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'each' &&
binding.kind !== 'store_sub' &&
!binding.mutated)
@ -328,8 +330,6 @@ const validation = {
// TODO handle mutations of non-state/props in runes mode
}
const binding = context.state.scope.get(left.name);
if (node.name === 'group') {
if (!binding) {
error(node, 'INTERNAL', 'Cannot find declaration for bind:group');
@ -780,7 +780,25 @@ function validate_call_expression(node, scope, path) {
error(node, 'invalid-props-location');
}
if (rune === '$state' || rune === '$derived' || rune === '$derived.by') {
if (rune === '$bindable') {
if (parent.type === 'AssignmentPattern' && path.at(-3)?.type === 'ObjectPattern') {
const declarator = path.at(-4);
if (
declarator?.type === 'VariableDeclarator' &&
get_rune(declarator.init, scope) === '$props'
) {
return;
}
}
error(node, 'invalid-bindable-location');
}
if (
rune === '$state' ||
rune === '$state.frozen' ||
rune === '$derived' ||
rune === '$derived.by'
) {
if (parent.type === 'VariableDeclarator') return;
if (parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) return;
error(node, 'invalid-state-location', rune);
@ -873,6 +891,8 @@ export const validation_runes_js = {
error(node, 'invalid-rune-args-length', rune, [0, 1]);
} else if (rune === '$props') {
error(node, 'invalid-props-location');
} else if (rune === '$bindable') {
error(node, 'invalid-bindable-location');
}
},
AssignmentExpression(node, { state }) {
@ -1022,6 +1042,9 @@ export const validation_runes = merge(validation, a11y_validators, {
}
},
CallExpression(node, { state, path }) {
if (get_rune(node, state.scope) === '$bindable' && node.arguments.length > 1) {
error(node, 'invalid-rune-args-length', '$bindable', [0, 1]);
}
validate_call_expression(node, state.scope, path);
},
EachBlock(node, { next, state }) {
@ -1062,7 +1085,7 @@ export const validation_runes = merge(validation, a11y_validators, {
state.has_props_rune = true;
if (args.length > 0) {
error(node, 'invalid-rune-args-length', '$props', [0]);
error(node, 'invalid-rune-args-length', rune, [0]);
}
if (node.id.type !== 'ObjectPattern') {

@ -239,7 +239,7 @@ export function client_component(source, analysis, options) {
);
});
const properties = analysis.exports.map(({ name, alias }) => {
const component_returned_object = analysis.exports.map(({ name, alias }) => {
const expression = serialize_get_binding(b.id(name), instance_state);
if (expression.type === 'Identifier' && !options.dev) {
@ -249,10 +249,26 @@ export function client_component(source, analysis, options) {
return b.get(alias ?? name, [b.return(expression)]);
});
if (analysis.accessors) {
for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind !== 'prop' || name.startsWith('$$')) continue;
const properties = [...analysis.instance.scope.declarations].filter(
([name, binding]) =>
(binding.kind === 'prop' || binding.kind === 'bindable_prop') && !name.startsWith('$$')
);
if (analysis.runes && options.dev) {
/** @type {import('estree').Literal[]} */
const bindable = [];
for (const [name, binding] of properties) {
if (binding.kind === 'bindable_prop') {
bindable.push(b.literal(binding.prop_alias ?? name));
}
}
instance.body.unshift(
b.stmt(b.call('$.validate_prop_bindings', b.id('$$props'), b.array(bindable)))
);
}
if (analysis.accessors) {
for (const [name, binding] of properties) {
const key = binding.prop_alias ?? name;
const getter = b.get(key, [b.return(b.call(b.id(name)))]);
@ -271,12 +287,12 @@ export function client_component(source, analysis, options) {
};
}
properties.push(getter, setter);
component_returned_object.push(getter, setter);
}
}
if (options.legacy.componentApi) {
properties.push(
component_returned_object.push(
b.init('$set', b.id('$.update_legacy_props')),
b.init(
'$on',
@ -292,7 +308,7 @@ export function client_component(source, analysis, options) {
)
);
} else if (options.dev) {
properties.push(
component_returned_object.push(
b.init(
'$set',
b.thunk(
@ -360,8 +376,8 @@ export function client_component(source, analysis, options) {
append_styles();
component_block.body.push(
properties.length > 0
? b.return(b.call('$.pop', b.object(properties)))
component_returned_object.length > 0
? b.return(b.call('$.pop', b.object(component_returned_object)))
: b.stmt(b.call('$.pop'))
);
@ -369,7 +385,7 @@ export function client_component(source, analysis, options) {
/** @type {string[]} */
const named_props = analysis.exports.map(({ name, alias }) => alias ?? name);
for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind === 'prop') named_props.push(binding.prop_alias ?? name);
if (binding.kind === 'bindable_prop') named_props.push(binding.prop_alias ?? name);
}
component_block.body.unshift(
@ -476,9 +492,7 @@ export function client_component(source, analysis, options) {
/** @type {import('estree').Property[]} */
const props_str = [];
for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind !== 'prop' || name.startsWith('$$')) continue;
for (const [name, binding] of properties) {
const key = binding.prop_alias ?? name;
const prop_def = typeof ce === 'boolean' ? {} : ce.props?.[key] || {};
if (

@ -78,7 +78,7 @@ export function serialize_get_binding(node, state) {
return typeof binding.expression === 'function' ? binding.expression(node) : binding.expression;
}
if (binding.kind === 'prop') {
if (binding.kind === 'prop' || binding.kind === 'bindable_prop') {
if (binding.node.name === '$$props') {
// Special case for $$props which only exists in the old world
// TODO this probably shouldn't have a 'prop' binding kind
@ -377,6 +377,7 @@ export function serialize_set_binding(node, context, fallback, options) {
binding.kind !== 'state' &&
binding.kind !== 'frozen_state' &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'each' &&
binding.kind !== 'legacy_reactive' &&
!is_store
@ -389,7 +390,7 @@ export function serialize_set_binding(node, context, fallback, options) {
const serialize = () => {
if (left === node.left) {
if (binding.kind === 'prop') {
if (binding.kind === 'prop' || binding.kind === 'bindable_prop') {
return b.call(left, value);
} else if (is_store) {
return b.call('$.store_set', serialize_get_binding(b.id(left_name), state), value);
@ -467,7 +468,7 @@ export function serialize_set_binding(node, context, fallback, options) {
b.call('$.untrack', b.id('$' + left_name))
);
} else if (!state.analysis.runes) {
if (binding.kind === 'prop') {
if (binding.kind === 'bindable_prop') {
return b.call(
left,
b.sequence([
@ -571,7 +572,7 @@ function get_hoistable_params(node, context) {
params.push(b.id(binding.expression.object.arguments[0].name));
} else if (
// If we are referencing a simple $$props value, then we need to reference the object property instead
binding.kind === 'prop' &&
(binding.kind === 'prop' || binding.kind === 'bindable_prop') &&
!binding.reassigned &&
binding.initial === null &&
!context.state.analysis.accessors

@ -52,6 +52,7 @@ export const global_visitors = {
binding?.kind === 'each' ||
binding?.kind === 'legacy_reactive' ||
binding?.kind === 'prop' ||
binding?.kind === 'bindable_prop' ||
is_store
) {
/** @type {import('estree').Expression[]} */
@ -64,7 +65,7 @@ export const global_visitors = {
fn += '_store';
args.push(serialize_get_binding(b.id(name), state), b.call('$' + name));
} else {
if (binding.kind === 'prop') fn += '_prop';
if (binding.kind === 'prop' || binding.kind === 'bindable_prop') fn += '_prop';
args.push(b.id(name));
}

@ -40,7 +40,7 @@ export const javascript_visitors_legacy = {
state.scope.get_bindings(declarator)
);
const has_state = bindings.some((binding) => binding.kind === 'state');
const has_props = bindings.some((binding) => binding.kind === 'prop');
const has_props = bindings.some((binding) => binding.kind === 'bindable_prop');
if (!has_state && !has_props) {
const init = declarator.init;
@ -80,7 +80,7 @@ export const javascript_visitors_legacy = {
declarations.push(
b.declarator(
path.node,
binding.kind === 'prop'
binding.kind === 'bindable_prop'
? get_prop_source(binding, state, binding.prop_alias ?? name, value)
: value
)
@ -168,7 +168,7 @@ export const javascript_visitors_legacy = {
// If the binding is a prop, we need to deep read it because it could be fine-grained $state
// from a runes-component, where mutations don't trigger an update on the prop as a whole.
if (name === '$$props' || name === '$$restProps' || binding.kind === 'prop') {
if (name === '$$props' || name === '$$restProps' || binding.kind === 'bindable_prop') {
serialized = b.call('$.deep_read_state', serialized);
}

@ -207,33 +207,30 @@ export const javascript_visitors_runes = {
seen.push(name);
let id = property.value;
let initial = undefined;
if (property.value.type === 'AssignmentPattern') {
id = property.value.left;
initial = /** @type {import('estree').Expression} */ (visit(property.value.right));
}
let id =
property.value.type === 'AssignmentPattern' ? property.value.left : property.value;
assert.equal(id.type, 'Identifier');
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(id.name));
const initial =
binding.initial &&
/** @type {import('estree').Expression} */ (visit(binding.initial));
if (binding.reassigned || state.analysis.accessors || initial) {
declarations.push(b.declarator(id, get_prop_source(binding, state, name, initial)));
}
} else {
// RestElement
declarations.push(
b.declarator(
property.argument,
b.call(
'$.rest_props',
b.id('$$props'),
b.array(seen.map((name) => b.literal(name)))
)
)
);
/** @type {import('estree').Expression[]} */
const args = [b.id('$$props'), b.array(seen.map((name) => b.literal(name)))];
if (state.options.dev) {
// include rest name, so we can provide informative error messages
args.push(
b.literal(/** @type {import('estree').Identifier} */ (property.argument).name)
);
}
declarations.push(b.declarator(property.argument, b.call('$.rest_props', ...args)));
}
}

@ -1382,6 +1382,7 @@ function serialize_event_handler(node, { state, visit }) {
binding.kind === 'legacy_reactive' ||
binding.kind === 'derived' ||
binding.kind === 'prop' ||
binding.kind === 'bindable_prop' ||
binding.kind === 'store_sub')
) {
handler = dynamic_handler();

@ -446,6 +446,7 @@ function serialize_set_binding(node, context, fallback) {
binding.kind !== 'state' &&
binding.kind !== 'frozen_state' &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'each' &&
binding.kind !== 'legacy_reactive' &&
!is_store
@ -690,7 +691,21 @@ const javascript_visitors_runes = {
}
if (rune === '$props') {
declarations.push(b.declarator(declarator.id, b.id('$$props')));
// remove $bindable() from props declaration
const id = walk(declarator.id, null, {
AssignmentPattern(node) {
if (
node.right.type === 'CallExpression' &&
get_rune(node.right, state.scope) === '$bindable'
) {
const right = node.right.arguments.length
? /** @type {import('estree').Expression} */ (visit(node.right.arguments[0]))
: b.id('undefined');
return b.assignment_pattern(node.left, right);
}
}
});
declarations.push(b.declarator(id, b.id('$$props')));
continue;
}
@ -1131,7 +1146,7 @@ const javascript_visitors_legacy = {
state.scope.get_bindings(declarator)
);
const has_state = bindings.some((binding) => binding.kind === 'state');
const has_props = bindings.some((binding) => binding.kind === 'prop');
const has_props = bindings.some((binding) => binding.kind === 'bindable_prop');
if (!has_state && !has_props) {
declarations.push(/** @type {import('estree').VariableDeclarator} */ (visit(declarator)));
@ -2217,7 +2232,8 @@ export function server_component(analysis, options) {
});
}
// If the component binds to a child, we need to put the template in a loop and repeat until bindings are stable
// If the component binds to a child, we need to put the template in a loop and repeat until legacy bindings are stable.
// We can remove this once the legacy syntax is gone.
if (analysis.uses_component_bindings) {
template.body = [
b.let('$$settled', b.true),
@ -2258,7 +2274,7 @@ export function server_component(analysis, options) {
/** @type {import('estree').Property[]} */
const props = [];
for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind === 'prop' && !name.startsWith('$$')) {
if (binding.kind === 'bindable_prop' && !name.startsWith('$$')) {
props.push(b.init(binding.prop_alias ?? name, b.id(name)));
}
}
@ -2266,6 +2282,8 @@ export function server_component(analysis, options) {
props.push(b.init(alias ?? name, b.id(name)));
}
if (props.length > 0) {
// This has no effect in runes mode other than throwing an error when someone passes
// undefined to a binding that has a default value.
template.body.push(b.stmt(b.call('$.bind_props', b.id('$$props'), b.object(props))));
}
@ -2280,7 +2298,7 @@ export function server_component(analysis, options) {
/** @type {string[]} */
const named_props = analysis.exports.map(({ name, alias }) => alias ?? name);
for (const [name, binding] of analysis.instance.scope.declarations) {
if (binding.kind === 'prop') named_props.push(binding.prop_alias ?? name);
if (binding.kind === 'bindable_prop') named_props.push(binding.prop_alias ?? name);
}
component_block.body.unshift(

@ -32,6 +32,7 @@ export const Runes = /** @type {const} */ ([
'$state',
'$state.frozen',
'$props',
'$bindable',
'$derived',
'$derived.by',
'$effect',

@ -241,7 +241,8 @@ export interface Binding {
node: Identifier;
/**
* - `normal`: A variable that is not in any way special
* - `prop`: A normal prop (possibly mutated)
* - `prop`: A normal prop (possibly reassigned or mutated)
* - `bindable_prop`: A prop one can `bind:` to (possibly reassigned or mutated)
* - `rest_prop`: A rest prop
* - `state`: A state variable
* - `derived`: A derived variable
@ -253,6 +254,7 @@ export interface Binding {
kind:
| 'normal'
| 'prop'
| 'bindable_prop'
| 'rest_prop'
| 'state'
| 'frozen_state'
@ -280,7 +282,7 @@ export interface Binding {
scope: Scope;
/** For `legacy_reactive`: its reactive dependencies */
legacy_dependencies: Binding[];
/** Legacy props: the `class` in `{ export klass as class}` */
/** Legacy props: the `class` in `{ export klass as class}`. $props(): The `class` in { class: klass } = $props() */
prop_alias: string | null;
/**
* If this is set, all references should use this expression instead of the identifier name.

@ -17,6 +17,15 @@ export function array_pattern(elements) {
return { type: 'ArrayPattern', elements };
}
/**
* @param {import('estree').Pattern} left
* @param {import('estree').Expression} right
* @returns {import('estree').AssignmentPattern}
*/
export function assignment_pattern(left, right) {
return { type: 'AssignmentPattern', left, right };
}
/**
* @param {Array<import('estree').Pattern>} params
* @param {import('estree').BlockStatement | import('estree').Expression} body

@ -36,13 +36,22 @@ export function update_pre_prop(fn, d = 1) {
/**
* The proxy handler for rest props (i.e. `const { x, ...rest } = $props()`).
* Is passed the full `$$props` object and excludes the named props.
* @type {ProxyHandler<{ props: Record<string | symbol, unknown>, exclude: Array<string | symbol> }>}}
* @type {ProxyHandler<{ props: Record<string | symbol, unknown>, exclude: Array<string | symbol>, name?: string }>}}
*/
const rest_props_handler = {
get(target, key) {
if (target.exclude.includes(key)) return;
return target.props[key];
},
set(target, key) {
if (DEV) {
throw new Error(
`Rest element properties of $props() such as ${target.name}.${String(key)} are readonly`
);
}
return false;
},
getOwnPropertyDescriptor(target, key) {
if (target.exclude.includes(key)) return;
if (key in target.props) {
@ -64,11 +73,12 @@ const rest_props_handler = {
/**
* @param {Record<string, unknown>} props
* @param {string[]} rest
* @param {string[]} exclude
* @param {string} [name]
* @returns {Record<string, unknown>}
*/
export function rest_props(props, rest) {
return new Proxy({ props, exclude: rest }, rest_props_handler);
export function rest_props(props, exclude, name) {
return new Proxy(DEV ? { props, exclude, name } : { props, exclude }, rest_props_handler);
}
/**

@ -1258,22 +1258,34 @@ export function unwrap(value) {
}
if (DEV) {
/** @param {string} rune */
function throw_rune_error(rune) {
/**
* @param {string} rune
* @param {string[]} [variants]
*/
function throw_rune_error(rune, variants = []) {
if (!(rune in globalThis)) {
// TODO if people start adjusting the "this can contain runes" config through v-p-s more, adjust this message
// @ts-ignore
globalThis[rune] = () => {
// TODO if people start adjusting the "this can contain runes" config through v-p-s more, adjust this message
throw new Error(`${rune} is only available inside .svelte and .svelte.js/ts files`);
throw new Error(`${rune}() is only available inside .svelte and .svelte.js/ts files`);
};
for (const variant of variants) {
// @ts-ignore
globalThis[rune][variant] = () => {
throw new Error(
`${rune}.${variant}() is only available inside .svelte and .svelte.js/ts files`
);
};
}
}
}
throw_rune_error('$state');
throw_rune_error('$effect');
throw_rune_error('$derived');
throw_rune_error('$state', ['frozen']);
throw_rune_error('$effect', ['pre', 'root', 'active']);
throw_rune_error('$derived', ['by']);
throw_rune_error('$inspect');
throw_rune_error('$props');
throw_rune_error('$bindable');
}
/**

@ -1,5 +1,5 @@
import { untrack } from './runtime.js';
import { is_array } from './utils.js';
import { get_descriptor, is_array } from './utils.js';
/** regex of all html void element names */
const void_element_names =
@ -137,3 +137,22 @@ export function validate_component(component_fn) {
}
return component_fn;
}
/**
* @param {Record<string, any>} $$props
* @param {string[]} bindable
*/
export function validate_prop_bindings($$props, bindable) {
for (const key in $$props) {
if (!bindable.includes(key)) {
var setter = get_descriptor($$props, key)?.set;
if (setter) {
throw new Error(
`Cannot use bind:${key} on this component because the property was not declared as bindable. ` +
`To mark a property as bindable, use the $bindable() rune like this: \`let { ${key} = $bindable() } = $props()\``
);
}
}
}
}

@ -588,8 +588,8 @@ export function sanitize_slots(props) {
}
/**
* If the prop has a fallback and is bound in the parent component,
* propagate the fallback value upwards.
* Legacy mode: If the prop has a fallback and is bound in the
* parent component, propagate the fallback value upwards.
* @param {Record<string, unknown>} props_parent
* @param {Record<string, unknown>} props_now
*/

@ -172,13 +172,24 @@ declare namespace $effect {
* Declares the props that a component accepts. Example:
*
* ```ts
* let { optionalProp = 42, requiredProp }: { optionalProp?: number; requiredProps: string } = $props();
* let { optionalProp = 42, requiredProp, bindableProp = $bindable() }: { optionalProp?: number; requiredProps: string; bindableProp: boolean } = $props();
* ```
*
* https://svelte-5-preview.vercel.app/docs/runes#$props
*/
declare function $props(): any;
/**
* Declares a prop as bindable, meaning the parent component can use `bind:propName={value}` to bind to it.
*
* ```ts
* let { propName = $bindable() }: { propName: boolean } = $props();
* ```
*
* https://svelte-5-preview.vercel.app/docs/runes#$bindable
*/
declare function $bindable<T>(t?: T): T;
/**
* Inspects one or more values whenever they, or the properties they contain, change. Example:
*

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
error: {
code: 'invalid-rune-args-length',
message: '$bindable can only be called with 0 or 1 arguments'
}
});

@ -0,0 +1,3 @@
<script>
const { foo = $bindable(1, 2) } = $props();
</script>

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
error: {
code: 'invalid-bindable-location',
message: '$bindable() can only be used inside a $props() declaration'
}
});

@ -0,0 +1,3 @@
<script>
const { a = $bindable() } = $state();
</script>

@ -1,6 +1,5 @@
<script>
let { value, ...props } = $props();
let { value = $bindable(), ...properties } = $props();
</script>
<button {...props} onclick={() => value++}>{value}</button>
<button {...properties} onclick={() => value++}>{value}</button>

@ -1,5 +1,5 @@
<script>
let { checked, ...rest } = $props();
let { checked = $bindable(), ...rest } = $props();
</script>
<input type="checkbox" bind:checked {...rest} />

@ -1,5 +1,5 @@
<script>
let { items = [{ src: 'https://ds' }] } = $props();
let { items = $bindable([{ src: 'https://ds' }]) } = $props();
</script>
{#each items as item, i}

@ -1,6 +1,6 @@
<script>
/** @type {{ object: { count: number }}} */
let { object } = $props();
let { object = $bindable() } = $props();
</script>
<button onclick={() => object.count += 1}>

@ -1,5 +1,5 @@
<script>
let { count, inc } = $props();
let { inc, count = $bindable() } = $props();
</script>
<button onclick={inc}>{count.a} (ok)</button>

@ -1,6 +1,6 @@
<script>
/** @type {{ object: { count: number }}} */
let { object } = $props();
let { object = $bindable() } = $props();
</script>
<button onclick={() => object.count += 1}>

@ -1,5 +1,5 @@
<script>
let { count: definedCount } = $props();
let { count: definedCount = $bindable() } = $props();
</script>
<button on:click={() => definedCount++}>{definedCount}</button>

@ -1,5 +1,5 @@
<script>
let { count = 0 } = $props();
let { count = $bindable(0) } = $props();
</script>
<span>{count}</span>

@ -1,5 +1,5 @@
<script>
let { bar } = $props();
let { bar = $bindable() } = $props();
</script>
<button on:click={() => bar--}>{bar}</button>

@ -1,5 +1,5 @@
<script>
let { count } = $props();
let { count = $bindable() } = $props();
</script>
<button on:click={() => count++}>{count}</button>

@ -1,6 +1,6 @@
<script>
/** @type {{ object?: { count: number }}} */
let { object = { count: 0 } } = $props();
let { object = $bindable({ count: 0 }) } = $props();
</script>
<button onclick={() => object.count += 1}>

@ -1,5 +1,5 @@
<script>
let { readonly, readonlyWithDefault = 1, binding } = $props();
let { readonly, readonlyWithDefault = 1, binding = $bindable() } = $props();
</script>
<p>

@ -0,0 +1,5 @@
<script>
let { ...rest } = $props();
</script>
{rest.count}

@ -0,0 +1,11 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
error:
'Cannot use bind:count on this component because the property was not declared as bindable. ' +
'To mark a property as bindable, use the $bindable() rune like this: `let { count = $bindable() } = $props()`',
html: `0`
});

@ -0,0 +1,7 @@
<script>
import Counter from './Counter.svelte';
let count = $state(0);
</script>
<Counter bind:count />

@ -0,0 +1,5 @@
<script>
let { count } = $props();
</script>
{count}

@ -0,0 +1,11 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
error:
'Cannot use bind:count on this component because the property was not declared as bindable. ' +
'To mark a property as bindable, use the $bindable() rune like this: `let { count = $bindable() } = $props()`',
html: `0`
});

@ -0,0 +1,7 @@
<script>
import Counter from './Counter.svelte';
let count = $state(0);
</script>
<Counter bind:count />

@ -1,6 +1,6 @@
<script>
/** @type {{ object: { count: number }}} */
let { object } = $props();
let { object = $bindable() } = $props();
</script>
<button onclick={() => object.count += 1}>

@ -11,6 +11,5 @@ export default function Svelte_element($$payload, $$props) {
$$payload.out += `${anchor}`;
if (tag) $.element($$payload, tag, () => {}, () => {});
$$payload.out += `${anchor}`;
$.bind_props($$props, { tag });
$.pop();
}

@ -708,7 +708,8 @@ declare module 'svelte/compiler' {
node: Identifier;
/**
* - `normal`: A variable that is not in any way special
* - `prop`: A normal prop (possibly mutated)
* - `prop`: A normal prop (possibly reassigned or mutated)
* - `bindable_prop`: A prop one can `bind:` to (possibly reassigned or mutated)
* - `rest_prop`: A rest prop
* - `state`: A state variable
* - `derived`: A derived variable
@ -720,6 +721,7 @@ declare module 'svelte/compiler' {
kind:
| 'normal'
| 'prop'
| 'bindable_prop'
| 'rest_prop'
| 'state'
| 'frozen_state'
@ -747,7 +749,7 @@ declare module 'svelte/compiler' {
scope: Scope;
/** For `legacy_reactive`: its reactive dependencies */
legacy_dependencies: Binding[];
/** Legacy props: the `class` in `{ export klass as class}` */
/** Legacy props: the `class` in `{ export klass as class}`. $props(): The `class` in { class: klass } = $props() */
prop_alias: string | null;
/**
* If this is set, all references should use this expression instead of the identifier name.
@ -2501,13 +2503,24 @@ declare namespace $effect {
* Declares the props that a component accepts. Example:
*
* ```ts
* let { optionalProp = 42, requiredProp }: { optionalProp?: number; requiredProps: string } = $props();
* let { optionalProp = 42, requiredProp, bindableProp = $bindable() }: { optionalProp?: number; requiredProps: string; bindableProp: boolean } = $props();
* ```
*
* https://svelte-5-preview.vercel.app/docs/runes#$props
*/
declare function $props(): any;
/**
* Declares a prop as bindable, meaning the parent component can use `bind:propName={value}` to bind to it.
*
* ```ts
* let { propName = $bindable() }: { propName: boolean } = $props();
* ```
*
* https://svelte-5-preview.vercel.app/docs/runes#$bindable
*/
declare function $bindable<T>(t?: T): T;
/**
* Inspects one or more values whenever they, or the properties they contain, change. Example:
*

@ -220,6 +220,7 @@
boost: 5
}),
{ label: '$state.frozen', type: 'keyword', boost: 4 },
{ label: '$bindable', type: 'keyword', boost: 4 },
snip('$effect.root(() => {\n\t${}\n});', {
label: '$effect.root',
type: 'keyword',

@ -490,7 +490,7 @@ let { a, b, c, ...everythingElse }: MyProps = $props();
>
> ...TypeScript [widens the type](https://www.typescriptlang.org/play?#code/CYUwxgNghgTiAEAzArgOzAFwJYHtXwBIAHGHIgZwB4AVeAXnilQE8A+ACgEoAueagbgBQgiCAzwA3vAAe9eABYATPAC+c4qQqUp03uQwwsqAOaqOnIfCsB6a-AB6AfiA) of `x` to be `string | number`, instead of erroring.
Props cannot be mutated, unless the parent component uses `bind:`. During development, attempts to mutate props will result in an error.
By default props are treated as readonly, meaning reassignments will not propagate upwards and mutations will result in a warning at runtime in development mode. You will also get a runtime error when trying to `bind:` to a readonly prop in a parent component. To declare props as bindable, use [`$bindable()`](#bindable).
### What this replaces
@ -498,6 +498,26 @@ Props cannot be mutated, unless the parent component uses `bind:`. During develo
Note that you can still use `export const` and `export function` to expose things to users of your component (if they're using `bind:this`, for example).
### `$bindable()`
To declare props as bindable, use `$bindable()`. Besides using them as regular props, the parent can (_can_, not _must_) then also `bind:` to them.
```svelte
<script>
let { bindableProp = $bindable() } = $props();
</script>
```
You can pass an argument to `$bindable()`. This argument is used as a fallback value when the property is `undefined`.
```svelte
<script>
let { bindableProp = $bindable('fallback') } = $props();
</script>
```
Note that the parent is not allowed to pass `undefined` to a property with a fallback if it `bind:`s to that property.
## `$inspect`
The `$inspect` rune is roughly equivalent to `console.log`, with the exception that it will re-run whenever its

Loading…
Cancel
Save