Repository: sveltejs/svelte (2025-07-27T09:28:34Z)

Issue:
$host() styles not synchronously available in onMount and $effect
### Describe the bug

Component styles are not synchronously available when using the $host() rune within onMount.

This prevents reliable access to computed styles, dimensions or CSS variables of the host element immediately after it's mounted.

This bug occurs in Svelte versions 5.36.0 through 5.36.13.
Svelte 5.35.7 it behaves as expected.

### Reproduction

1. Use svelte version 5.36.0 - 5.36.13.
2. Create a custom element
3. access $host() within onMount.

```svelte
<svelte:options customElement={{tag: "my-counter"}}/>

<script lang="ts">
  import {onMount} from "svelte";

  let Host = $host()

  $effect(() => {
    console.log("$effect", Host.shadowRoot?.innerHTML); // BUG: has no <style> tag
  })

  onMount(() => {
    console.log("onMount",Host.shadowRoot?.innerHTML); // BUG: has no <style> tag
    setTimeout(() => {
      console.log("delayed",Host.shadowRoot?.innerHTML); // has the <style> tag
    })
  })
</script>

<button class="btn">Counter</button>

<style >
  .btn {
    width: 123px;
    height: 123px;
  }
</style>
```


<img width="1167" height="76" alt="Image" src="https://github.com/user-attachments/assets/a2b86c1e-1cbf-40ab-bfc6-00a8437a5b4d" />

### Logs

```shell

```

### System Info

```shell
System:
    OS: macOS 15.5
    CPU: (12) arm64 Apple M3 Pro
    Memory: 94.95 MB / 36.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 24.3.0 - ~/.nvm/versions/node/v24.3.0/bin/node
    npm: 11.4.2 - ~/.nvm/versions/node/v24.3.0/bin/npm
    pnpm: 10.13.1 - ~/.nvm/versions/node/v24.3.0/bin/pnpm
  Browsers:
    Chrome: 138.0.7204.158
    Edge: 138.0.3351.95
    Safari: 18.5
  npmPackages:
    svelte: ^5.36.13 => 5.36.13
```

### Severity

annoyance


Discussion:
don't have to put up a PR with a regression test right now, but this... https://github.com/sveltejs/svelte/blob/d0ebd42986c4d3e4db0420f1aaebd337afdb230f/packages/svelte/src/internal/client/dom/css.js#L11

...should just be an `effect` instead, so that it runs before any user effects



Patch:
diff --git a/.changeset/shaggy-comics-fail.md b/.changeset/shaggy-comics-fail.md
new file mode 100644
index 000000000000..981a25c978d6
--- /dev/null
+++ b/.changeset/shaggy-comics-fail.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: `append_styles` in an effect to make them available on mount
diff --git a/.changeset/wise-hairs-pay.md b/.changeset/wise-hairs-pay.md
new file mode 100644
index 000000000000..7d96c1daab15
--- /dev/null
+++ b/.changeset/wise-hairs-pay.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: always inject styles when compiling as a custom element
diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js
index d407b4455639..cd44fd998aed 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/index.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/index.js
@@ -451,6 +451,8 @@ export function analyze_component(root, source, options) {
 		}
 	}
 
+	const is_custom_element = !!options.customElementOptions || options.customElement;
+
 	// TODO remove all the ?? stuff, we don't need it now that we're validating the config
 	/** @type {ComponentAnalysis} */
 	const analysis = {
@@ -500,13 +502,13 @@ export function analyze_component(root, source, options) {
 		needs_props: false,
 		event_directive_node: null,
 		uses_event_attributes: false,
-		custom_element: options.customElementOptions ?? options.customElement,
-		inject_styles: options.css === 'injected' || options.customElement,
-		accessors: options.customElement
-			? true
-			: (runes ? false : !!options.accessors) ||
-				// because $set method needs accessors
-				options.compatibility?.componentApi === 4,
+		custom_element: is_custom_element,
+		inject_styles: options.css === 'injected' || is_custom_element,
+		accessors:
+			is_custom_element ||
+			(runes ? false : !!options.accessors) ||
+			// because $set method needs accessors
+			options.compatibility?.componentApi === 4,
 		reactive_statements: new Map(),
 		binding_groups: new Map(),
 		slot_names: new Map(),
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 124438a9dab0..a56aca9c5f0b 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
@@ -621,8 +621,9 @@ export function client_component(analysis, options) {
 		);
 	}
 
-	if (analysis.custom_element) {
-		const ce = analysis.custom_element;
+	const ce = options.customElementOptions ?? options.customElement;
+
+	if (ce) {
 		const ce_props = typeof ce === 'boolean' ? {} : ce.props || {};
 
 		/** @type {ESTree.Property[]} */
diff --git a/packages/svelte/src/internal/client/dom/css.js b/packages/svelte/src/internal/client/dom/css.js
index 52be36aa1f46..8e6faa0e32dc 100644
--- a/packages/svelte/src/internal/client/dom/css.js
+++ b/packages/svelte/src/internal/client/dom/css.js
@@ -1,6 +1,6 @@
 import { DEV } from 'esm-env';
-import { queue_micro_task } from './task.js';
 import { register_style } from '../dev/css.js';
+import { effect } from '../reactivity/effects.js';
 
 /**
  * @param {Node} anchor
@@ -8,7 +8,7 @@ import { register_style } from '../dev/css.js';
  */
 export function append_styles(anchor, css) {
 	// Use `queue_micro_task` to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results
-	queue_micro_task(() => {
+	effect(() => {
 		var root = anchor.getRootNode();
 
 		var target = /** @type {ShadowRoot} */ (root).host


Test patch:
diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/host-rune-access-injected-css/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/host-rune-access-injected-css/_config.js
new file mode 100644
index 000000000000..99a223492b8b
--- /dev/null
+++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/host-rune-access-injected-css/_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></custom-element>';
+		/** @type {any} */
+		const el = target.querySelector('custom-element');
+
+		/** @type {string} */
+		let html = '';
+		const handle_evt = (e) => (html = e.detail);
+		el.addEventListener('html', handle_evt);
+
+		await tick();
+		await tick();
+		await tick();
+
+		assert.ok(html.includes('<style'));
+	}
+});
diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/host-rune-access-injected-css/main.svelte b/packages/svelte/tests/runtime-browser/custom-elements-samples/host-rune-access-injected-css/main.svelte
new file mode 100644
index 000000000000..ad8027f4d268
--- /dev/null
+++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/host-rune-access-injected-css/main.svelte
@@ -0,0 +1,16 @@
+<svelte:options css="injected" customElement="custom-element"/>
+
+<script lang="ts">
+  $effect(() => {
+    $host().dispatchEvent(new CustomEvent("html", { detail: $host().shadowRoot?.innerHTML }));
+  })
+</script>
+
+<button class="btn">btn</button>
+
+<style >
+  .btn {
+    width: 123px;
+    height: 123px;
+  }
+</style>
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/custom-element-injected-styles/Thing.svelte b/packages/svelte/tests/runtime-runes/samples/custom-element-injected-styles/Thing.svelte
new file mode 100644
index 000000000000..0a2b139274d6
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/custom-element-injected-styles/Thing.svelte
@@ -0,0 +1,9 @@
+<svelte:options customElement={{ tag: 'my-thing' }} />
+
+<h1>hello</h1>
+
+<style>
+	h1 {
+		color: red;
+	}
+</style>
diff --git a/packages/svelte/tests/runtime-runes/samples/custom-element-injected-styles/_config.js b/packages/svelte/tests/runtime-runes/samples/custom-element-injected-styles/_config.js
new file mode 100644
index 000000000000..74597504bd92
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/custom-element-injected-styles/_config.js
@@ -0,0 +1,15 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+	mode: ['client'],
+	async test({ assert, target }) {
+		const thing = /** @type HTMLElement & { object: { test: true }; } */ (
+			target.querySelector('my-thing')
+		);
+
+		await tick();
+
+		assert.include(thing.shadowRoot?.innerHTML, 'red');
+	}
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/custom-element-injected-styles/main.svelte b/packages/svelte/tests/runtime-runes/samples/custom-element-injected-styles/main.svelte
new file mode 100644
index 000000000000..ba5b788da9b3
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/custom-element-injected-styles/main.svelte
@@ -0,0 +1,5 @@
+<script>
+	import './Thing.svelte';
+</script>
+
+<my-thing></my-thing>