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.

<!-- EmailField.html -->
<input :value="model" @input="$updateModel($event.target.value)">

<!-- LoginForm.html -->
<input :value="uname" @input="$updateModel('uname', $event.target.value)">
<script :setup>
  let items = await fetchItems()   // the template waits for this
</script>
<ul class="list">
  <li :each="item in items">{{ item }}</li>
</ul>
<script :setup>
  let items = await fetchItems()   // still holds the render

  await $mounted()

  let height = $(".list").offsetHeight   // real DOM access — still reactive
</script>

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.

Put the yield above the slow part when you want chrome or a skeleton on screen straight away — the whole template is what waits, static markup included:

<script :setup>
  let items = []
  await $mounted()                 // the <ul> and its heading go up now
  items = await fetchItems()       // fills in when it lands
</script>

A script that neither returns nor yields holds the template indefinitely. After three seconds the console says so, naming the fix; the render still waits, because painting on a timer would make the first render depend on the machine it runs on.

To defer a whole script until mount, add :mounted to the tag — it behaves as if await $mounted() were its first line, which is also to say it yields immediately and holds nothing back:

<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.

Imports

await import(...) works in a setup script. A .html specifier resolves to a component (fetched and parsed); anything else goes to the native import():

<!-- /components/panel.html -->
<script :setup>
  const Badge = await import("./badge.html")              // /components/badge.html
  const { format } = await import("./format.js")          // /components/format.js
  const { debounce } = await import("lodash-es")          // the import map
</script>

A relative specifier resolves against the component's own file, the same as in any module — ./ means "next to this .html", not next to the page and not next to the library. It works out of a subdirectory, off a CDN, and with no build step. A component built from an inline string has no location of its own, so its relative imports fall back to the page.

A root-absolute one (/x.js) resolves against the page, and both come back as fully absolute URLs. That matters when the library is served from somewhere else — a CDN, most often:

page   http://localhost:8024/craft/app.html
jq79   https://jgermade.github.io/jq79/jq79.js

The non-.html branch is a native import(), and a native import() inside the library resolves anything short of an absolute URL against the library's URL. Left as a path, /craft/services/opfs.service.js would be requested from jgermade.github.io and come back a CORS error naming a host the app never mentioned. Resolving to an absolute URL up front is what keeps fetch and import() pointing at the same file.

A bare specifier (lodash-es) is the exception, left untouched for the import map or the bundler to answer — that is who owns it, and resolving it would quietly turn it into a path.

Under the Vite plugin, literal specifiers are hoisted into real module imports at build time and never reach any of this — the bundler resolves them, so an npm package works whether or not the page has an import map.

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.

Template expressions don't reach devtools on their own — they render empty instead of throwing — so the runtime reports them itself, in two flavours:

Everything a handler throws is left to the browser, with its stack intact.

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:

<script :setup>
  export default async () => {
    await $mounted()          // renders now, binds when this resolves
    return { count: 0 }
  }
</script>