Components

Lifecycle

// src: string, or { template, scripts, styles }
const jq79 = new Component79(src)

// subscribe to the component's $emit events
jq79.on("submit", (e, payload) => {})  
    .off("submit", listener) // unsubscribe

// render (reactive DOM, setup scripts, styles) + attach
// el: Element or selector string; data is optional
jq79.mount(el, data)

// detach, keeping state — mount(el) re-attaches, with
// any updates that happened while detached applied
jq79.detach()                          
   .destroy() // dispose all effects and remove injected styles

Props

A component declares the props it takes as a destructuring pattern, written where each script mode already puts its inputs — the :setup attribute's value, or the factory's first parameter:

<!-- setup mode -->
<script :setup="{ label = 'Total', step = 1 }">
  let count = 0
  const inc = () => { count += step }
</script>
<!-- factory mode: props first, context second -->
<script>
export default ({ label = "Total", step = 1 }, { $data }) => {
  $data.count = 0
  const inc = () => { $data.count += step }
  return { inc }
}
</script>

One rule explains where any name comes from, and the codebase follows it without exception: what carries a $ comes from the library; what doesn't comes from the parent.

The signature declares the prop names, pre-declares them on the store (so the template can bind to them even when the parent passes nothing), and seeds their defaults before the first render. That last part is why the runtime reads the pattern from the source rather than leaving it to JS: in factory mode a default JS applies would only exist inside the function body, and the template would still see undefined.

A default fills a prop that is undefined — the parent's value always wins — and it is applied once, at setup. If the parent later sets the prop to undefined, the default does not come back.

A signature is a contract both ways

A component that declares a signature takes only what it declared. A prop it never named is dropped at the usage site rather than added to its store:

<!-- Field.html -->
<script :setup="{ label }"></script>
<p>[{{ label }}][{{ extra }}]</p>
<Field :label="'kept'" :extra="'dropped'" />   <!-- renders [kept][] -->

That makes a wrong name fail where it was written: {{ extra }} renders empty, and {{ extra.id }} throws on the member access. It applies to updates too, not just the first render, and it narrows a spread to the props the component actually takes:

<Card ...sdk />   <!-- sdk has a dozen keys; Card gets the ones it declared -->

A prop you wrote and the component doesn't declare is warned, once per usage site:

jq79: :extra is not declared by <Field> - add it to the :setup signature, or drop it

A spread's extra keys are not — narrowing ...sdk to the few a component takes is the normal case, and the reason to write it.

Two things are never filtered:

The empty slot is part of the declaration

Position is fixed, so a factory that takes no props still leaves the hole — and which hole it leaves means something:

export default (_,  { $effect }) => {}   // declares nothing: permissive, takes whatever the parent passes
export default ({}, { $effect }) => {}   // a closed signature: this component has no props
export default ({ user })        => {}   // only props — don't write a ctx you don't use

Setup mode reads the same three, in the attribute's value:

<script :setup="_">          <!-- declares nothing: permissive -->
<script :setup="{}">         <!-- a closed signature: this component has no props -->
<script :setup>              <!-- the same closed signature, written short -->
<script :setup="{ user }">   <!-- takes one prop -->

A bare :setup is closed, like {} — the difference between "takes nothing" and "takes anything" shouldn't be a pair of braces somebody didn't type. Permissive is still there, it just has to be asked for with _.

Destructuring copies (the one asymmetry)

In setup mode a prop name is never a lexical binding — it's a store entry, and with re-resolves it on every read, so it stays live.

In factory mode the pattern creates real JS bindings, and destructuring copies:

export default ({ cart, discount = 0 }, { $props, $effect }) => {
  cart.items.push(item)                 // ✅ live: you copied a reference to the proxy
  $effect(() => log(discount))          // ❌ frozen: you copied a number
  $effect(() => log($props.discount))   // ✅ live: read through the object
}

Objects survive it (every read of cart.x still goes through the proxy). Only a primitive the parent reassigns goes stale — the parent writes the store key, and your local copy never hears about it. $props is the same store under a different name: the live view, for when you need one.

Making the destructured names themselves reactive is what Svelte 5 and Vue 3.5 do, and it needs what they have — a compiler. jq79 doesn't ship a JS parser to the browser, so it doesn't pretend to.

Slots

Props parameterise a component by data; slots parameterise it by markup. A tag's children render inside the component, where it wrote a <slot>:

<Card :title="title">
  <template :slot.header><h2>{{ title }}</h2></template>

  <p>{{ body }}</p>
</Card>

The syntax is in template syntax. What matters here is whose the content is:

Styles

A <style> block goes into document.head as-is, shared globally. Add scoped and its rules only reach the elements this component rendered:

<div class="card">
  <span class="title">{{ title }}</span>
</div>

<style scoped>
  .card .title { color: rebeccapurple; }
</style>

Every element of the component's template is stamped with a data-jq79="<hash>" attribute, and the CSS is rewritten to require it:

.card .title[data-jq79="1a2b3c"] { color: rebeccapurple; }

The hash comes from the component source, so all instances of a definition share one scope and one refcounted <style> in the head. The rewrite happens at parse time in the browser, so it works the same whether the component was bundled by the Vite plugin or fetched at runtime.

Notes:

Several components in one file

A .html file is one component — the markup at its top level. A <template name="…"> block declares another component of the same file, which every component in the file can use by name, with no import:

<!-- list.html -->
<script :setup>
  let rows = ["a", "b"]
</script>

<ul class="list"><Row :each="label in rows" :label="label" /></ul>

<template name="Row">
  <script :setup="{ label }"></script>
  <li class="row">{{ label }}</li>
</template>

They come out of the file too, so the shape is default plus named — the shape of a JS module:

import List, { Row } from "./list.html"        // through the Vite plugin

const List = await Component79.fetch("./list.html")   // at runtime
const { Row } = List

The file's own component has no name of its own: it's the default, and a default is named by whoever imports it. A component that has to be referenced by name inside the file is a <template name>.

Inside a <template> everything works as it does in a file of its own: <script> in either mode, <style>, props, a signature. Only the top level of the file declares — a <template> nested in the markup is left alone — and names must be PascalCase, which is what lets a tag reference them at all. A <template> that declares nothing usable is ignored with a warning rather than a throw.

It can render itself

A named template sees every component in its file, itself included, so a component can recurse — which a component in a file of its own can't do:

<template name="TreeNode">
  <script :setup="{ node }"></script>
  <li>
    {{ node.label }}
    <ul :if="node.children">
      <TreeNode :each="child in node.children" :node="child" />
    </ul>
  </li>
</template>

Recursion stops where the data stops. A cycle in that data would recurse until the JS stack gave out, so the runtime cuts it at 200 levels with a console error naming the tag: a truncated tree, not a dead page.

The signature decides where a name comes from

If two things could answer to <Button> — the file's own, and one the parent passes — the signature says which, and you can read it off the source:

<script :setup></script>              <!-- not declared: the file's own -->
<script :setup="{ Button }">          <!-- declared: the parent's -->

Not declaring it doesn't lock the parent out: a prop it passes still wins, so that's the substitutable form, with the file's own as the default. Declaring it is a contract — the component is saying this one comes from outside. Use it as a tag and pass nothing, and that's an error on the console (and nothing rendered), because nothing can arrive later to fill it. A declared name that is never used as a tag is nobody's business, and says nothing.

Misspell the tag, though, and there is no name at all to fill — <Buton> matches neither the file's own nor anything the parent passed, so it throws and names the components that were in scope.

Styles stop at each template

A named template is a shadow root inside a shadow root: the file is a container, not a stylesheet. Its <style scoped> reaches its own elements only, and the file's scoped rules don't reach into it — a li { … } at the top of the file does not style a Row that renders an <li>, even though both are in one file that reads like one document.

Hot reload treats the file as the unit: an edit anywhere in it re-renders every live component that came from it, each with its own new parts.

Loading remote components

Component79.fetch(url) downloads a component and mounts it, no build step in sight — a whole page in one expression:

import { C79 } from "https://jgermade.github.io/jq79/jq79.js"

C79.fetch("./app.html").mount(document.querySelector("main"))

What it hands back is a pending component: mount, mountShadow, render, renderShadow, on, off, detach and destroy all queue onto the download and run in the order they were written, so a listener registered before mount is in place by the time the component renders:

C79.fetch("/components/user-card.html")
  .on("save", (event, user) => persist(user))
  .mount("#app", { userId: 42 })

It's also awaitable, resolving to the component itself — awaiting the chain gives you the mounted component:

const jq79 = await Component79.fetch("/components/user-card.html")
jq79.mount("#app", { userId: 42 })

const card = await Component79.fetch("/components/user-card.html").mount("#app")

Which you'll want sooner or later, because on a pending component mount() returns the pending component — there is no component yet for it to return. That's the one asymmetry with new Component79(src).mount(el), which hands back the component itself. It's also why the whole lifecycle is on the handle: you should never have to await just to destroy() something.

A chain nobody awaits reports a failed download the way a dropped .then() chain does, as an unhandled rejection. catch and finally are there for when you'd rather handle it:

C79.fetch("./app.html")
  .mount("main")
  .catch(failure => showOfflineNotice(failure))

Several at once

fetchAll takes an array of URLs, fetches them all at once, and resolves to the components in the same order, so one await destructures them:

const [Header, UserCard] = await Component79.fetchAll([
  "/components/header.html",
  "/components/user-card.html",
])

It's a plain promise, not a chainable handle: mounting a list of components has no single meaning. Like Promise.all, the first failure rejects the whole call — fetch the URLs separately if you want one 404 to leave the rest usable.

Which build am I running?

Component79.version is the version of the package the class came from:

Component79.version // "0.4.15"

It's baked in at build time, so it's the same string in every build — the ESM and CJS bundles, and window.jq79 from the CDN <script>, where nothing else says which release the page pulled. Running the library straight from src/ (the tests do) reports "0.0.0-dev", since there is no build to stamp it.

Switching off an optimization: Component79.debug()

Reads the renderer's debug flags, and sets the ones you pass:

Component79.debug()                            // { cloneSkeletons: true }
Component79.debug({ cloneSkeletons: false })   // turn it off, get the flags back

Global to the page, not per component — these change how the renderer works, and a page rendering two ways at once is the one state nobody can debug. Call it before mounting: components already on the page keep the DOM they were built with.

cloneSkeletons

On by default. Where a subtree's shape never varies — the elements, their static attributes and their nesting are fixed, and only the values bound into them change — jq79 builds that shape once per component definition and gives each instance a cloneNode of it, instead of walking the template again for every instance. Worth about a fifth of the time it takes to build a 1,000-row list, and more on a row with more markup in it.

It is meant to be invisible: the same DOM, the same bindings, in the same order. If you ever see a page that renders wrong and comes right with

Component79.debug({ cloneSkeletons: false })

that is a bug in jq79 and a very good bug report — say so in the issue, because it names the file.