Components

No compiler, no bundler, no dependencies. A component is a .html file parsed at runtime by the browser's own HTML parser. The library ships as a single file — drop it from a CDN, serve your components from any static host, and the page works. The Vite plugin is optional and only adds bundled imports and HMR; the same .html file works in all three delivery modes.

Lifecycle

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

// same thing, shorter name
const jq79 = parseComponent(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.

In VS Code, the extension in this repo reports the same thing before anything renders, on the attribute — along with a prop whose value doesn't match the type the signature gives 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 100 levels with a console error naming the tag: a truncated tree, not a dead page. A real tree never reaches that depth; the limit is there to beat the stack to the punch.

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.

In TypeScript, the declaration can also say what the component must take. The editor extension then checks both sides: the props given to <Button> here, and the component a parent passes as Button:

<script :setup="{ Button }: { Button: Component<{ label: string }> }" lang="ts">
  import type { Component } from "jq79"
</script>

Component<{ label: string }, "pressed", "icon"> also says which events it listens for and which slots it fills: the component passed has to emit and render at least those.

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) (or C79.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"))

C79 is Component79 under a shorter name — the same class, the same API. Both are exported from the library.

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

Parsing from a string

parseComponent(source) is a shorthand for new Component79(source):

import { parseComponent } from "jq79"

const jq79 = parseComponent(`<h1>{{ title }}</h1>`)
jq79.mount("#app", { title: "Hello" })

It does exactly what the constructor does — parse the source, build the AST, prepare the definition. Useful when you want to be explicit about what the expression does, or when the name Component79 feels heavy for inline use.

In VS Code, the extension in this repo colors a literal passed to either one as HTML, and checks it like a .html file — as long as it is a literal with no ${…} in it.

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, scopedNames: true }
Component79.debug({ cloneSkeletons: false })              // turn one off, get the flags back
Component79.debug({ cloneSkeletons: false, scopedNames: false })  // both off

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.

scopedNames

On by default. Resolves template expressions by extracting their free identifiers and reading them from the store with a const prologue, instead of using JavaScript's with ($scope) statement. Worth about 63% of a one-name evaluation and 72% of a two-name one.

It is meant to be invisible: the same expressions, the same dependencies, the same results. If you ever see a page where a template expression reads the wrong value and comes right with

Component79.debug({ scopedNames: false })

that is a bug in the name extractor, and the flag exists to find it. Please report it.