| name | tinybubble |
| description | TinyBubble component/page authoring, props-emits wiring, router usage, pub-sub topic lifecycle, JobManager, globals, and plugin integration (especially bubble-translate). Always apply for files ending in .bub.js or .bubble.js, and for any TinyBubble UI task including reactivity, signals, templating, routing, or events. |
| license | MIT |
| metadata | {"author":"antocorr","tags":"tinybubble, components, props, emits, pubsub, router, signals, reactivity, globals, i18n, bubble-translate"} |
TinyBubble
When To Apply
Apply when the request involves:
- New TinyBubble component/page creation or refactor (
.bub.js, .bubble.js, any component file)
- Signal/effect/computed/watch usage
- Child component registration with props/emits
- Fixing props and template binding mistakes
- Event topic wiring, listener cleanup, or pub-sub refactor
- JobManager usage
- TinyBubble router setup or route updates
- Globals wiring (
globals.t, globals.$lang, $route, custom $-signals)
- TinyBubble translation wiring (
globals.t, i18nLang, language switch)
- Work inside
plugins/bubble-translate/
Auto-trigger: always activate for files ending .bub.js or .bubble.js.
Source Of Truth For This Repo
When in doubt, align to these runtime files:
src/lib/Reactivity.js — createComponent, html, globals, bindNode
src/lib/Signals.js — Signal, effect, watch, computed, tick, collectEffects
src/lib/Router.js — createRouter, RouterLink, RouterView
src/lib/EventTopic.js — EventTopic (used by bubble.events)
src/lib/Bubble.js — bubble singleton, getJobManager
src/lib/JobManager.js — JobManager
plugins/bubble-translate/bubble.js
plugins/bubble-translate/index.js
1. Bundles
TinyBubble ships as three separate entry points:
| Bundle | Includes | Gzipped |
|---|
tinybubble / dist/bubble.js | Signals, reactivity, router | ~5 kb |
tinybubble/events / dist/bubble-events.js | Pub/sub, EventTopic, JobManager | +1 kb |
tinybubble/full / dist/bubble-full.js | Everything | ~6 kb |
Caveat: pub/sub (bubble) is NOT included in the core bundle. Import it separately.
import { createComponent, createRouter, Signal, effect, watch, computed, tick,
collectEffects, html, importComponent, watchProp, globals } from 'tinybubble'
import { bubble } from 'tinybubble/events'
2. Component Shape
export default {
name: "MyComponent",
props: ["title"],
emits: ["select"],
components: {
"child-comp": ChildComp,
},
template() {
return `
<section>
<h2>{{ title }}</h2>
<button type="button" @click="increment">+1</button>
<p>{{ count }}</p>
</section>
`;
},
data() {
return { count: 0 };
},
increment() {
this.data.count.value += 1;
},
init() { },
mounted() { },
beforeDestroy() { },
destroy() { },
};
Rules:
template() returns one root element
- State writes:
this.data.key.value = next
- Methods are auto-bound to
this
/*html*/ before the template literal enables editor syntax highlighting
3. Reactive Bindings
| Syntax | Use for |
|---|
{{ expr }} | Text interpolation |
:attr="expr" | Reactive attribute |
:class="{ active: cond }" | Class object |
:class="['a', cond ? 'b' : '']" | Class array |
:style="{ color: tone, fontSize: size + 'px' }" | Object style |
:style="'color:' + tone" | String style |
:disabled="isLoading" | Boolean attr (removed when falsy) |
@click="method" | Event handler (bare method ref) |
@click="doSomething(item)" | Event handler with args |
@submit-prevent="onSubmit" | Event + preventDefault |
@input="onInput($event)" | Pass native event explicitly |
x-if="expr" | Conditional mount/unmount |
x-show="expr" | Toggle display:none |
x-hide="expr" | Inverse of x-show |
x-model="field" | Two-way input binding |
x-model="obj.field" | Two-way nested binding |
x-for="item in items" | Loop |
x-for="(item, index) in items" | Loop with index |
x-for="(value, key) in object" | Object loop with key |
x-for="item in items" :key="item.id" | Keyed loop — reconcile by key, reuse nodes |
ref="name" | DOM ref → this.refs.name |
Critical caveats:
- Use
:attr="expr" for reactive attributes
- In templates, read props and state directly by name, such as
{{ foo }}
- Template bindings run later in the component scope. Local variables declared inside
template() are not available to {{ }} or directive expressions; use data, props, methods, globals, or x-for locals.
- JavaScript getters are not template methods. Prefer methods such as
vendorKey() when template code must call derived logic.
class and style accumulate: static base is preserved, :class/:style appends on re-render
- For
@input and @change, bare handlers receive (newValue, oldValue) — use $event for the DOM event
x-model must point to a writable signal name or object path, not a function call result
x-show and x-hide only toggle display; child components still mount and run lifecycle hooks
{{ }} in prose HTML: if a template contains {{ }} as literal display text (not a binding), TinyBubble will try to evaluate it. Use {<wbr>{ expr }<wbr>} to break the pattern without changing visual output
- Use
<template x-if="..."> to conditionally render multiple sibling elements without a wrapper div
x-for without :key rebuilds the whole list on every change (loses focus, input state, child component state). Add :key="item.id" to reconcile by key: nodes are reused and moved, the item becomes a signal so bindings update in place. Use a stable, unique key — index is a poor key when the list reorders. :key is array-only; object loops already key by their property name
4. Signals API
import { Signal, createSignal, effect, watch, computed, tick, collectEffects, untrack } from 'tinybubble'
const count = Signal(0)
count.value = 1
count.value
const [get, set] = createSignal(0)
effect(() => { el.textContent = count.value })
watch(count, (next, prev) => fetchData(next))
const double = computed(() => count.value * 2)
untrack(() => count.value)
tick()
const disposers = collectEffects(() => {
effect(() => { ... })
})
disposers.forEach(d => d())
Object signals: assigning an object wraps it in a Proxy — direct property mutations are tracked:
const user = Signal({ name: 'Alice' })
user.value.name = 'Bob'
5. Props And Emits
Child component:
export default {
name: "ChildCard",
props: ["userId", "label"],
emits: ["save"],
template() {
return `
<article>
<h3>{{ label }}</h3>
<p>User: {{ userId }}</p>
<button type="button" @click="onSave">Save</button>
</article>
`;
},
onSave() {
this.emit("save", { id: this.props.userId });
}
};
Parent wiring:
<child-card :user-id="activeUserId" :label="'User: ' + name" @save="handleSave"></child-card>
Rules:
this.props.foo is already unwrapped
- In templates, read props directly by name, such as
{{ foo }}
- Declare every emitted event in
emits
- Prefer kebab-case listeners for camelCase emits:
emits: ["reservationUpdate"] pairs with @reservation-update="handler"
- Listener matching normalizes camelCase/kebab-case the same way props do
- Passing
:prop="sigName" where sigName is a SignalObject passes the signal itself — child updates reactively
To react to prop changes:
import { watchProp } from "tinybubble";
export default {
props: ["userId"],
init() {
watchProp(this, "userId", (newVal, oldVal) => {
this.loadUser(newVal);
});
this.loadUser(this.props.userId);
},
};
6. Globals
globals is merged into every component's template scope — no per-component import needed.
import { globals, Signal } from 'tinybubble'
globals.t = translationFn
globals.formatDate = (d) => d.toISOString()
globals.$theme = Signal('light')
Precedence: component data, props, and methods shadow globals with the same name.
7. Router
import { createRouter } from "tinybubble";
import Home from "./pages/Home.js";
import About from "./pages/About.js";
import NotFound from "./pages/NotFound.js";
export const router = createRouter({
mode: "hash",
base: "/",
srcBase: import.meta.url,
routes: [
{ path: "/", component: Home },
{ path: "/about", component: About },
{ path: "/user/:id", component: UserPage },
{ path: "/docs/:slug?", component: DocsPage },
{ path: "/dash", component: Dash, persistent: true },
{ path: "/lazy", src: "./pages/Lazy.js" },
{ path: "*", component: NotFound }
]
});
App registration:
components: {
"router-link": router.RouterLink,
"router-view": router.RouterView
}
<router-link to="/about">About</router-link>
<router-link to="/user/42" :class="{ active: $route.path === '/user/42' }">Profile</router-link>
<router-view></router-view>
$route is a reactive global owned and updated by createRouter:
{{ $route.path }}
{{ $route.params.id }}
{{ $route.query.tab }}
Dynamic routes (function form):
routes can be a function instead of an array. It is called fresh on every route resolution, so route visibility can react to app state (e.g. auth) without any router API beyond this:
export const router = createRouter({
routes: () => isLoggedIn.value
? [...publicRoutes, ...privateRoutes]
: publicRoutes
});
Because the function runs inside the router's own reactive effect, any signal it reads (isLoggedIn.value above) is auto-tracked — route resolution re-runs whenever that signal changes, even with no navigation. Keep the function synchronous and cheap: it can be called more than once per resolution (it's read both when $route is recomputed and when RouterView resolves the component to mount).
Caveats:
- Do not assign
globals.$route = router.route; router.route is not part of the API
$route updates when the router destination changes, even before RouterView mounts
- Register
router-link and router-view in the components map of every component that uses them
persistent: true keeps the component alive in memory when navigating away
- Programmatic navigation:
router.navigate("/path")
- Vite builds +
src: routes: Vite cannot analyze runtime-computed import() URLs. Use static import + component: when building with Vite. The src: approach works fine in CDN/no-build setups.
- Function-form
routes: when routes is a function, router.routes (the returned reference) is that function, not a live array — the in-place mutation trick (router.routes.push(...)) only works when routes was passed as a plain array.
8. PubSub Lifecycle
import { bubble } from "tinybubble/events";
export default {
init() {
this._onItem = (payload) => this.handleItem(payload);
bubble.events.topic("cart").on("item:added", this._onItem);
},
beforeDestroy() {
bubble.events.topic("cart").off("item:added", this._onItem);
this._onItem = null;
},
handleItem(payload) { }
};
EventTopic full API:
const t = bubble.events.topic("name")
t.on(event, cb, id?)
t.off(event, cb?, id?)
t.emit(event, ...args)
t.one(event, cb)
t.once(event)
t.removeAllListeners()
t.remove("childTopic")
t.bubble(event)
bubble.state — plain shared object, no reactivity:
bubble.state.user = { id: 42 }
Rules:
- Topic API is
bubble.events.topic("name")
- Store handler refs on
this — anonymous callbacks cannot be removed with .off()
- Detach before re-register in repeated
init() paths (prevents duplicate callbacks)
- Prefer structured payloads:
topic.emit("event", { ... })
9. JobManager
import { bubble } from "tinybubble/events";
const job = bubble.getJobManager("upload")
job.add(10)
job.start()
job.done()
job.done(3)
job.reset()
job.flushCallbacks()
job.addCallback("finished", () => showSuccess())
job.addCallback("done", (pct, info) => updateBar(pct))
job.addCallback("check", (pct) => console.log(pct + "%"))
10. bubble-translate Plugin
Use plugins/bubble-translate/bubble.js for TinyBubble apps.
Bootstrap once
import { createComponent } from "tinybubble";
import { createTranslate } from "./plugins/bubble-translate/bubble.js";
import App from "./components/App.bubble.js";
const t = createTranslate({
defaultLang: "en_EN",
storageKey: "app.lang",
url: (lang) => `./localization/${lang}.json`
});
await t.setLang(t.lang);
const app = createComponent(App);
app.appendTo(document.getElementById("app"));
What this gives you:
globals.t registered automatically
globals.i18nLang signal kept in sync on t.setLang(...)
{{ t('key') }} in any template updates reactively on language change
In components
import { globals } from "tinybubble";
export default {
data() { return { currentLang: "en_EN" } },
async switchLanguage(lang) {
await globals.t.setLang(lang);
this.data.currentLang.value = globals.t.lang;
},
template() {
return `
<section>
<h2>{{ t('Welcome') }}</h2>
<button @click="switchLanguage('it_IT')">{{ t('Italian') }}</button>
</section>
`;
}
};
Guidelines:
- Initialize translate once at app bootstrap, not per component
- In JS methods use
globals.t(...) — bare t(...) only works inside templates
%s placeholders are positional: t("Hi %s", userName)
- Fallback is the key itself when translation is missing
Current Usage Checklist
- Read props as
this.props.foo in JS and {{ foo }} in templates
- Use
bubble.events.topic("x") for pub/sub topics
- Use reactive attributes such as
:src="imageUrl"
- Make every
template() return one root element
- Use
data, props, methods, globals, or x-for locals in template bindings
- Listen to camelCase emits with kebab-case HTML listeners, such as
@reservation-update for emit("reservationUpdate")
- Call
watchProp() from init()
- Use static
import plus component: for Vite route builds
Do / Don't
| Do | Don't |
|---|
this.props.foo | this.props.foo.value |
{{ foo }} | {{ props.foo }} |
:src="imageUrl" | src="{{ imageUrl }}" |
bubble.events.topic("cart") | bubble.topic("cart") |
return /*html*/"<div></div>" from template() | return "" from template() |
@reservation-update="handler" for emit("reservationUpdate") | @reservationUpdate="handler" in HTML |
watchProp(this, "userId", cb) inside init() | registering prop watchers in mounted() |
x-model="form.email" | x-model="getEmail()" |
Quality Gate
Before finalizing TinyBubble-related edits:
Problem → Reference Mapping
| Problem | Reference |
|---|
| New component/page scaffold | references/components-core.md |
| Child props not updating / wrong value shape | references/props-emits.md |
| Camel emit not received from parent listener | references/props-emits.md |
| Cross-component communication without prop drilling | references/pubsub-lifecycle.md |
| Duplicate pub-sub callbacks after re-init | references/pubsub-lifecycle.md |
| Progress tracking for async batches | references/pubsub-lifecycle.md (JobManager section) |
| Bubble i18n setup, language switch, or plugin edits | references/plugins-bubble-translate.md |