Repository: sveltejs/svelte (JavaScript)

Issue:
Svelte 5: Custom Element prop to attribute mapping inconsistent
### Describe the bug

For custom elements where the props and attributes are defined with `<svelte:options/>`, there appears to be an inconsistency in how Svelte handles props-to-attributes mapping depending on how the props are accessed.
```svelte
<svelte:options customElement={{
	tag:"ps-greet",
	props: {
		message: {attribute:"data-external-props-message"}
	}	
}} />
```

When accessing props directly:
```typescript
let props = $props();
props.message; // undefined
props["data-external-props-message"]; // works, but requires using attribute name
```

When using destructuring:
```typescript
let { message } = $props(); // works as expected
````	
Correct me if I am wrong but I think props should be consistently accessible regardless of the method used to reference them, either through direct reference or destructuring.

Upon initial investigation, the issue seems to originate from [here](https://github.com/sveltejs/svelte/blob/86ef18165f35d132f77b711263f00899f8134554/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js#L568-L589) where the prop definition of the custom element is constructed based on the explicitly defined props of the component (see [here](https://github.com/sveltejs/svelte/blob/86ef18165f35d132f77b711263f00899f8134554/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js#L293-L296)). This worked in Svelte 4 because the props are always defined with `export let message`, however in svelte 5 where it is possible to define props like `let props = $props()`, properties will always be an empty array in this case.

### Reproduction

[REPL](https://svelte-5-preview.vercel.app/#H4sIAAAAAAAAE4VSy47UMBD8labFAaR4co8ykTisgBsHbus9mLhnxsKxLXcHdhXlX_gWvgw5j4FdoZ1bu1RdXdXtCU_OE2NzP2EwA2GDH1LCCuUplQf_IC-EFXIcc1-QlvvsknQ6aHFDilngYyYSynDKcQCNh3oDDmu3xsL1JDAQszkTHEHjJ_I-ws-YvS2Etv6rG9rE6lw0wBoxih6FcjBepRwTq03lOG3F3LX13tBhhUO07uTIYiN5pLm6JttsvZ5ugZqYxMXA0I8scbjzNFCQ4zSVJGLOjcZ9osaqgIu15vevhaFls9bAZESy-zYKNRpfSaNxLp2zFh3mGep1D__suq7LBq-Z4QhvF4F376GuwRJLHnsZswtn0HhVXY8iF9rokMdAZe3fwTDQY6JeyO4HWinPpZfysJ_uQpkgE49eGFwAjWOwdHKBrMYKPkNvAsTgn8DGtfX-Ru4HkAhnksXkjr78D90Xw0y2TNydOG5gemZubuvUQftGKfh6cQw2EkOIssRVqrspdNPqywlFmJX6z697mP8AHRiQ_lsDAAA=)

### Logs

_No response_

### System Info

```shell
System:
    OS: macOS 14.6.1
    CPU: (11) arm64 Apple M3 Pro
    Memory: 58.38 MB / 18.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.13.1 - ~/.local/state/fnm_multishells/69839_1729547059074/bin/node
    Yarn: 1.22.22 - /opt/homebrew/bin/yarn
    npm: 10.5.2 - ~/.local/state/fnm_multishells/69839_1729547059074/bin/npm
    pnpm: 9.11.0 - ~/.local/share/pnpm/pnpm
    bun: 1.1.29 - /opt/homebrew/bin/bun
  Browsers:
    Edge: 130.0.2849.46
    Safari: 18.0
  npmPackages:
    svelte: ^5.0.0 => 5.0.4
```


### Severity

annoyance


Discussion:

Patch:
diff --git a/.changeset/warm-eyes-protect.md b/.changeset/warm-eyes-protect.md
new file mode 100644
index 000000000000..fbb971f31477
--- /dev/null
+++ b/.changeset/warm-eyes-protect.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: recognize all custom element prop definitions
diff --git a/documentation/docs/07-misc/04-custom-elements.md b/documentation/docs/07-misc/04-custom-elements.md
index 83874f2f5bb6..71c66f7edce5 100644
--- a/documentation/docs/07-misc/04-custom-elements.md
+++ b/documentation/docs/07-misc/04-custom-elements.md
@@ -49,6 +49,8 @@ console.log(el.name);
 el.name = 'everybody';
 ```
 
+Note that you need to list out all properties explicitly, i.e. doing `let props = $props()` without declaring `props` in the [component options](#Component-options) means that Svelte can't know which props to expose as properties on the DOM element.
+
 ## Component lifecycle
 
 Custom elements are created from Svelte components using a wrapper approach. This means the inner Svelte component has no knowledge that it is a custom element. The custom element wrapper takes care of handling its lifecycle appropriately.
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
index 8369842e13f7..4daff53efb0f 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
@@ -561,17 +561,19 @@ export function client_component(analysis, options) {
 
 	if (analysis.custom_element) {
 		const ce = analysis.custom_element;
+		const ce_props = typeof ce === 'boolean' ? {} : ce.props || {};
 
 		/** @type {ESTree.Property[]} */
 		const props_str = [];
 
-		for (const [name, binding] of properties) {
-			const key = binding.prop_alias ?? name;
-			const prop_def = typeof ce === 'boolean' ? {} : ce.props?.[key] || {};
+		for (const [name, prop_def] of Object.entries(ce_props)) {
+			const binding = analysis.instance.scope.get(name);
+			const key = binding?.prop_alias ?? name;
+
 			if (
 				!prop_def.type &&
-				binding.initial?.type === 'Literal' &&
-				typeof binding.initial.value === 'boolean'
+				binding?.initial?.type === 'Literal' &&
+				typeof binding?.initial.value === 'boolean'
 			) {
 				prop_def.type = 'Boolean';
 			}
@@ -585,9 +587,17 @@ export function client_component(analysis, options) {
 					].filter(Boolean)
 				)
 			);
+
 			props_str.push(b.init(key, value));
 		}
 
+		for (const [name, binding] of properties) {
+			const key = binding.prop_alias ?? name;
+			if (ce_props[key]) continue;
+
+			props_str.push(b.init(key, b.object([])));
+		}
+
 		const slots_str = b.array([...analysis.slot_names.keys()].map((name) => b.literal(name)));
 		const accessors_str = b.array(
 			analysis.exports.map(({ name, alias }) => b.literal(alias ?? name))


Test patch:
diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/props-rune-attributes/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/props-rune-attributes/_config.js
new file mode 100644
index 000000000000..04b49c5c0057
--- /dev/null
+++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/props-rune-attributes/_config.js
@@ -0,0 +1,21 @@
+import { test } from '../../assert';
+const tick = () => Promise.resolve();
+
+export default test({
+	async test({ assert, target }) {
+		target.innerHTML = '<custom-element foo-bar="1" bar="2" b-az="3"></custom-element>';
+		await tick();
+
+		/** @type {any} */
+		const el = target.querySelector('custom-element');
+
+		assert.htmlEqual(
+			el.shadowRoot.innerHTML,
+			`
+			<p>1</p>
+			<p>2</p>
+			<p>3</p>
+		`
+		);
+	}
+});
diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/props-rune-attributes/main.svelte b/packages/svelte/tests/runtime-browser/custom-elements-samples/props-rune-attributes/main.svelte
new file mode 100644
index 000000000000..bea04b124833
--- /dev/null
+++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/props-rune-attributes/main.svelte
@@ -0,0 +1,14 @@
+<svelte:options
+	customElement={{
+		tag: "custom-element",
+		props: { foo: { attribute: 'foo-bar' } },
+	}}
+/>
+
+<script>
+	let { bar, 'b-az': baz, ...rest } = $props();
+</script>
+
+<p>{rest.foo}</p>
+<p>{bar}</p>
+<p>{baz}</p>
diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/props-rune-attributes/my-widget.svelte b/packages/svelte/tests/runtime-browser/custom-elements-samples/props-rune-attributes/my-widget.svelte
new file mode 100644
index 000000000000..cdba7491f94e
--- /dev/null
+++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/props-rune-attributes/my-widget.svelte
@@ -0,0 +1,14 @@
+<svelte:options
+	customElement={{
+		tag: "my-widget",
+		props: { foo: { attribute: 'foo-bar' } },
+	}}
+/>
+
+<script>
+	let { bar, 'b-az': baz, ...rest } = $props();
+</script>
+
+<p>{rest.foo}</p>
+<p>{bar}</p>
+<p>{baz}</p>