Setup scripts

<script :setup> blocks run against the component's reactive scope, Svelte-style:

<script :setup="{ fname, lname }">
  let count = 0                    // top-level let/var/const become reactive scope vars
  const greeting = `Hi ${fname}`   // initialized once, visible to the template

  $: doubled = count * 2           // re-runs whenever `count` changes

  setInterval(() => { count++ }, 1000)   // assignments from callbacks work too
</script>
<!-- child -->
<script :setup>
  const save = () => $emit("saved", { id: 42 })
</script>
<button @click="save">Save</button>

<!-- parent -->
<div @saved="lastSaved = $event.detail.id">
  <ChildForm />
</div>

From JS, subscribe on the instance itself with on(eventName, (event, payload) => …)payload is the same value as event.detail. on/off are chainable, can be called before mounting, and survive re-renders:

new Component79(src)
  .on("saved", (e, payload) => console.log(payload.id))
  .on("cancelled", () => console.log("cancelled"))
  .mount("#app")

Events emitted before the component is mounted have no ancestors to bubble to, so no DOM listener hears them ($emit is meant for handlers and async code, not synchronous top-level setup) — but instance on() listeners are notified even while detached. on() listeners (which is what @event on the component's tag uses) run before the DOM dispatch — one of them calling stopPropagation() keeps the event off the DOM entirely.

<script :setup>
  let items = await fetchItems()   // runs before render

  await $mounted()

  let height = $(".list").offsetHeight   // real DOM access — still reactive
</script>
<ul class="list">
  <li :each="item in items">{{ item }}</li>
</ul>

Reactivity is unaffected by where a declaration sits: variables declared after the await are pre-declared on the store before the first render (as undefined), so the template can bind to them from the start and updates when the assignment runs. If the component is never mounted, the code after await $mounted() never runs.

To defer a whole script until mount, add :mounted to the tag — it behaves as if await $mounted() were its first line:

<script :setup :mounted>
  $self(".list").focus()   // the component is already in the DOM
</script>

Only top-level code is rewritten; declarations inside callbacks/blocks behave as plain JS. Multi-declarator statements (let a = 1, b = 2) and destructuring declarations work like any other declaration — every binding becomes a reactive store variable:

let { name, email } = draft         // both reactive
const [first, ...rest] = items      // so are these
let { user: { id: userId } = {} } = session   // nested/renamed bind the *variable* (userId)

A $: declaration may span several lines: a line break only ends it if the next line can't continue the expression, the same call JavaScript's semicolon insertion makes. So method chains and operator chains work as written:

$: activeNames = users
  .filter(user => user.active)
  .map(user => user.name)

$: total = subtotal
  + shipping
  - discount

Keeping something out of the store

An effect tracks every scope variable it reads, and re-runs when one of them changes — so an effect that both reads and writes the same variable wakes itself up, forever:

let timer = null                       // top-level → a reactive scope var

const schedule = () => {
  clearTimeout(timer)                  // reads `timer`…
  timer = setTimeout(save, 250)        // …and writes it: the effect below loops
}

$: schedule(draft)

Worth knowing how this one fails, because it doesn't fail where you'd look: an effect's dependencies are recorded after its first run, so that first pass — the one during render — writes timer while the effect is still tracking nothing, and everything looks fine. It's the next change to draft that finds timer in the dependency list and re-runs the effect on repeat. The runtime cuts the loop after 100 rounds and says so in the console (an effect re-woke itself 100 times in a row) — but by then the side effects have run 100 times. A component that renders perfectly can still be carrying this. (Re-writing the same primitive value doesn't count as a change, so a plain normalizing assignment settles on its own.)

Bookkeeping like a timer handle, a cached instance or a "did I already run this" flag isn't state the template renders — it has no business in the store. Since only top-level declarations are rewritten, a closure keeps it plain JS:

const schedule = (() => {
  let timer = null                     // inside a function → not reactive

  return () => {
    clearTimeout(timer)
    timer = setTimeout(save, 250)
  }
})()

$: schedule(draft)                     // re-runs when `draft` changes. Only `draft`

Effects run before the template exists

$: declarations run where they sit, during the script — which is before the component has rendered any DOM. An effect that reaches for an element gets nothing on that first pass, and if none of its dependencies change afterwards it never runs again:

<script :setup>
  let query = ""

  // runs once, immediately, with no DOM to find - and `query` never changes on
  // its own, so this is the only time it ever runs
  $: $self(".search")?.focus()
</script>

The fix is to do the first pass yourself, after the DOM is there:

<script :setup>
  let query = ""

  const highlight = () => {
    const box = $self(".search")
    if (!box) return                 // the setup-time pass, before there's any DOM
    box.classList.toggle("filled", query.length > 0)
  }

  $: highlight(query)                // keeps it in sync from here on

  await $mounted()

  highlight()                        // the first pass that can actually see the DOM
</script>

:mounted on the tag does the same for a whole script (it behaves as if await $mounted() were its first line), which is simpler when nothing in the script needs to run before render.

Debugging a script

Setup scripts are compiled with new Function — they need with, which is a SyntaxError inside an ES module — so they aren't part of any bundle and no bundler source map reaches them. To keep them debuggable, each compiled script is named after the component it came from:

UserCard.html?jq79-script=0

It shows up under that name in the devtools sources tree and in stack traces, breakpoints set in it survive a reload, and a component with two <script> blocks gets one entry per block (…=0, …=1). The name comes from where the component was loaded: the URL for Component79.fetch(url), the path relative to the project root for the Vite plugin. A component built from an inline string has no origin to name, so its scripts stay anonymous.

What devtools shows under that name is the compiled script — the rewritten code ($__effect(…) instead of $:), wrapped in the function the engine built. Its line numbers are the compiled script's own, not the .html file's; the engine's function header shifts everything down and a <script> on line 1 can't be shifted back up. Reporting the component's own lines would need the runtime to emit a source map, which it doesn't do today.

Factory scripts (export default)

A <script> whose top level has an export default runs as a plain lexical module instead of a setup script — for when you want standard JS that editors, linters and type-checkers understand with no configuration:

<script>
import UserCard from "./UserCard.html"

export default ({ step = 1 }, { $data, $effect, $emit }) => {
  $data.count = 0
  $effect(() => { $data.double = $data.count * 2 })

  const inc = () => { $data.count += step }

  return { UserCard, inc }
}
</script>

<button @click="inc">{{ count }} / {{ double }}</button>
<UserCard></UserCard>

The default export is called with the props first and the instance context second, and may be async. The first parameter is the component's prop signature — the runtime reads it from the source, so its defaults reach the template even before an async factory has run. A factory that takes no props writes _ (permissive) or {} (a closed signature) in its place; the slot is where the tooling looks.

The context is everything the library provides — the $ is what says so:

Details worth knowing: