Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,160 @@ This project follows [Semantic Versioning](https://semver.org/).
---
---

## [4.2.0] — 2026-09-06

Making the runtime loud where it used to be quiet. Every item below is a case
where the library did the wrong thing — or the right thing for an unstated
reason — without saying so, and the cost was paid by whoever had to guess. Two
widened signatures, one new runtime behaviour, four new development warnings,
and a production bundle that is *smaller* than 4.1.0 despite all of them. No
breaking changes.

### Fixed

- **Development diagnostics now actually compile out of production builds.**
They did not. The dev gate led with a `globalThis.__SIBU_DEV__` lookup, and a
member expression is not something a bundler can substitute or fold — so
nothing was ever eliminated and every warning string the library can emit
shipped to every consumer, to be tested at runtime and never printed.

The gate is now a statically foldable constant, and the warning helpers test
the build-time define inline so their bodies fold to nothing even in a
published `dist` chunk. A production consumer's bundle no longer contains the
diagnostics at all — so the core runtime is smaller than 4.1.0 despite
everything added below:

| gzipped, minified, `__SIBU_DEV__: false` | 4.1.0 | 4.2.0 |
| --- | --- | --- |
| `sibujs` | 26,895 | **25,836** |
| `sibujs/plugins` | 28,877 | **28,615** |
| `sibujs/ui` | 16,462 | **16,112** |
| `dist/cdn.global.js` | 27,289 | **26,224** |
| application exercising every warning below | 33,658 | **32,958** |

`tests/treeshaking-dev-diagnostics.test.ts` bundles for real and fails if any
diagnostic string survives, and `tests/dist-artifacts.test.ts` asserts the
same thing against the built `dist/` files — the source-level test could not
see the CDN bundle, which is exactly where the leak hid. Development builds
grow, which is where the diagnostics are supposed to be: the same application
is 53,079 bytes gzipped with `__SIBU_DEV__: true`.

- **The CDN bundle shipped every diagnostic and could not be stripped.** The
CDN build applied no `__SIBU_DEV__` define at all, and a
`<script src="…/cdn.global.js">` consumer has no bundler to fold it later —
the published bytes are what runs. Worse, with no define and no `process` in
a browser the dev gate resolves to `false` at runtime, so those thousands of
bytes of warning text were downloaded and parsed to never print.

`dist/cdn.global.js` is now built with `__SIBU_DEV__: false` and contains no
diagnostic text at all.

### Added

- **`dist/cdn.dev.global.js`** — a development CDN bundle, exported as
`sibujs/cdn-dev`. Stripping the production CDN would otherwise have left
no-build users with no diagnostics at all; loading this file instead turns
every warning on without a build step, which is the point of having them in a
no-build workflow. Use `dist/cdn.global.js` in production.

- **`RouterLink` ignored a reactive `class`.** Tag factories honour a getter or
a `{ name: condition }` map; `RouterLink` read the prop with
`typeof classAttr === "string"`, so any other form fell through to `""` and
the attribute was simply never written — no error, no class, no clue. It now
resolves the prop through the same helper the tag factories use, inside the
effect that maintains active-link state, so a getter is genuinely reactive
rather than merely accepted.

- **A focused field lost the caret on every reactive rebuild.** A block that
re-creates its children takes focus,
selection and IME state with it, so typing one character into an input inside
such a block ended the edit. Focus and the selection range are now restored
when the rebuilt subtree contains an element of re-establishable identity
(`data-focus-key`, `id`, or `name`), and the runtime warns in development when
it cannot.

It does not guess. A match must be unique — `name` is shared by every radio in
a group, and moving the caret to a sibling control is worse than losing it —
and focus is only restored when it was genuinely lost, never when the rebuild
moved it somewhere deliberately. An in-progress IME composition cannot be
preserved at all, because the composition belongs to the destroyed node; the
warning says so rather than implying otherwise.

- **The style sanitizer dropped `url()` declarations in silence.** The guard is
correct and stays — `url()` in an inline style is an exfiltration channel —
but a blocked `background-image` reads as a rendering bug, not a security
decision, and images rendered as empty boxes across several renderers with
nothing in any console. Each dropped declaration is now announced in
development, naming the property, the value, the element, why it went, and the
sanctioned alternatives (`<img>` for content, a stylesheet class for
decoration — stylesheet CSS is not sanitized). Exactly one warning per dropped
declaration, de-duplicated per element so a reactive style cannot flood the
console.

- **The duplicate-runtime warning gave obsolete advice.** It told users to
configure `optimizeDeps.exclude`, which the reactive core's global registry
already makes unnecessary — every copy routes through the first one, so signal
writes reach subscribers registered by any of them. Duplication is a size
problem, not a correctness one, and the message now says that.

### Changed

- **`show`, `when` and `match` accept both an element and a factory.** The
shapes disagreed: `show(cond, element)` took an element while
`when(cond, () => el)` took thunks. Passing the wrong one produced an obscure
`TypeError` from inside the directive, or — for `when` — rendered nothing at
all and said nothing. Every form is accepted everywhere now; a factory is
still rebuilt per switch, and a bare element is re-attached as-is.

A re-attached element keeps its own reactive bindings: the directive did not
create it and does not tear it down. Development warns on the reuse, because
the node also brings back whatever state it accumulated while detached.

- **The lone-string class warning is narrower, and reports once.** It now
requires two or more whitespace-separated tokens with at least two
utility-shaped, where before any single token carrying a hyphen, colon, slash
or digit was enough.

The old rule flagged the identifiers applications legitimately render as text
— `item-0`, `home-content`, `user-42`, `v4.1.0`, `src/index.ts`,
`https://example.com`, `N/A` — at a measured 29.8% false-positive rate, and a
list rendering `item-0` through `item-999` produced a thousand warnings. The
new rule measures 0% on the same 131-string corpus, and a repeated mistake is
reported once per tag and string rather than once per element.

Both de-duplication caches are bounded at 100 distinct entries, and reaching
that bound stops reporting rather than merely stopping remembering — the
latter would let every mistake after the hundredth warn on every render, which
is the flood the cache exists to prevent. The suppression announces itself
once, so nothing goes quiet without saying so.

The cost, stated plainly: single-token class lists no longer warn.
`div("space-y-6")` and `div("truncate")` pass silently, where the first used
to be caught. A single hyphen-and-digit token is not distinguishable from an
identifier, and a warning developers learn to ignore protects nobody. The
multi-token form remains both the originally reported bug and the dominant
real-world shape. Measurements live in
`tests/lone-string-heuristic-rate.test.ts`.

- **Sanitizer entry points take an optional context.** `sanitizeCSSValue`,
`sanitizeStyleAttribute` and `sanitizeAttributeString` accept an optional
property/element used only to enrich the development warning. It never changes
a security decision, and every existing call site keeps working.

### Documentation

- Every exported function reachable from an entry point now carries a doc
comment saying what it does and what it returns — the router's navigation and
guard API, the form validators, the tracking primitives, and the widget and
UI factories among them. Signatures that used to require reading
`dist/index.d.ts` are documented at the source.

- Functions that participate in a known trap say so, and name the way out:
`when` and `match` carry the keying pattern that avoids destroying a live
edit; `show` explains when to prefer `when` and what that costs.

---

## [4.1.0] — 2026-09-03

Progressive-enhancement ergonomics, driven by building a complete chess
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,37 @@ Utilities for testing components and reactivity.
### `sibujs/cdn` — Script-tag bundle
Self-registering IIFE build exposing `window.Sibu`, for use without a bundler.

There are two builds. Load **exactly one** — they both define `window.Sibu`, so
loading both leaves whichever came last in charge.

Development, with every diagnostic live:

```html
<script src="https://unpkg.com/sibujs@latest/dist/cdn.dev.global.js"></script>
```

Production, with diagnostics compiled out rather than merely disabled:

```html
<script src="https://unpkg.com/sibujs@latest/dist/cdn.global.js"></script>
```

Either way, the API is on `window.Sibu`:

```html
<script>
const { signal, effect, div, mount } = window.Sibu;
</script>
```

Develop against `cdn.dev.global.js` and swap the one line to ship. The runtime
behaviour is identical; only the warnings differ.

A bundled app needs neither file — import from `"sibujs"` and let your bundler
define `__SIBU_DEV__` per build (the Vite and webpack plugins in `sibujs/build`
do this for you). The two CDN files exist because a `<script>` tag has no
bundler to make that choice.

---

## Ecosystem
Expand Down
5 changes: 5 additions & 0 deletions docs/interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ competing for the DOM, so rules 1–5 are all you need:
No build step, no bundler, no npm. Emit the markup from whatever templating
language you already use; the `data-ref` attributes are just attributes.

> **Development vs production.** The snippet above loads the production bundle,
> where development warnings are compiled out. While building, load
> `dist/cdn.dev.global.js` instead — same runtime, warnings on — and switch back
> to `dist/cdn.global.js` to ship. See the CDN section of the README.

**Per-host notes:**

- **Django / Rails / Laravel** — render the island in a partial/template so the
Expand Down
5 changes: 5 additions & 0 deletions docs/islands.md
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,11 @@ No npm, no transpile — one HTML file:
</script>
```

> **Development vs production.** The snippet above loads the production bundle,
> where development warnings are compiled out. While building, load
> `dist/cdn.dev.global.js` instead — same runtime, warnings on — and switch back
> to `dist/cdn.global.js` to ship. See the CDN section of the README.

A complete, runnable version (multiple islands and strategies) lives in
[`examples/islands.html`](../examples/islands.html).

Expand Down
9 changes: 7 additions & 2 deletions examples/islands.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ <h1>Reactive islands, zero build</h1>
<p>Seconds on screen: <span class="count" data-ref="secs">0</span></p>
</div>

<!-- The ONLY script tags on the page. In production, point src at the CDN:
https://unpkg.com/sibujs@latest/dist/cdn.global.js -->
<!-- The ONLY script tags on the page. Point src at the CDN to run this
without a local build:
production https://unpkg.com/sibujs@latest/dist/cdn.global.js
development https://unpkg.com/sibujs@latest/dist/cdn.dev.global.js
The two are identical at runtime; the development build additionally
emits SibuJS's warnings, which are compiled out of the production one.
Swap the local src below for ../dist/cdn.dev.global.js to see them. -->
<script src="../dist/cdn.global.js"></script>
<script>
// Surface a load failure instead of failing silently. If you see this,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sibujs",
"version": "4.1.0",
"version": "4.2.0",
"description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.",
"keywords": [
"frontend",
Expand Down Expand Up @@ -40,7 +40,7 @@
"typecheck": "tsc --noEmit",
"typecheck:tests": "tsc -p tsconfig.test.json",
"certify:rc": "node scripts/certify/run.mjs",
"build": "tsup index.ts data.ts browser.ts patterns.ts motion.ts ui.ts widgets.ts ssr.ts devtools.ts performance.ts ecosystem.ts plugins.ts build.ts testing.ts extras.ts --dts --format esm,cjs --out-dir dist --clean && tsup cdn.ts --format iife --globalName Sibu --out-dir dist --no-dts --minify",
"build": "tsup index.ts data.ts browser.ts patterns.ts motion.ts ui.ts widgets.ts ssr.ts devtools.ts performance.ts ecosystem.ts plugins.ts build.ts testing.ts extras.ts --dts --format esm,cjs --out-dir dist --clean && tsup --config tsup.cdn.config.ts",
"example:chess:build": "node scripts/build-example-chess.mjs",
"example:serve": "node tests-browser/server.mjs",
"bench": "node bench.mjs",
Expand Down Expand Up @@ -130,6 +130,9 @@
},
"./cdn": {
"default": "./dist/cdn.global.js"
},
"./cdn-dev": {
"default": "./dist/cdn.dev.global.js"
}
},
"publishConfig": {
Expand Down
10 changes: 7 additions & 3 deletions scripts/certify/exports-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,17 @@ for (const subpath of subpaths) {
const entry = pkg.exports[subpath];
const targets = typeof entry === "string" ? { default: entry } : entry;

// `./cdn` is an IIFE global build, not an ESM module — resolve-only.
// The `./cdn*` entries are IIFE global builds, not ESM modules —
// resolve-only. Matched by prefix so the production and development
// bundles are both covered without listing each one, and so a future
// variant does not fail the audit for being a global build.
const isGlobalBuild = subpath === "./cdn" || subpath.startsWith("./cdn-");
const importable = Boolean(targets.import ?? targets.default?.endsWith(".js"));

const timersBefore = timerLog.length;
const readsBefore = listenerLog.length;

if (importable && subpath !== "./cdn") {
if (importable && !isGlobalBuild) {
try {
const ns = await import(spec);
const named = Object.keys(ns).filter((k) => k !== "default");
Expand Down Expand Up @@ -191,7 +195,7 @@ for (const subpath of subpaths) {
if (targets.types) {
if (existsSync(resolve(PKG_DIR, targets.types))) pass(subpath, "types", targets.types);
else fail(subpath, "types", `${targets.types} MISSING`);
} else if (subpath !== "./cdn") {
} else if (!isGlobalBuild) {
fail(subpath, "types", "no `types` condition declared");
} else {
pass(subpath, "types", "n/a (global build)");
Expand Down
7 changes: 6 additions & 1 deletion scripts/certify/node-matrix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,12 @@ for (const target of WANT) {
// so a newly added entry cannot silently escape the matrix (§11).
const pkgPath = join(consumer, "node_modules", "sibujs", "package.json");
const subpaths = Object.keys(JSON.parse(readFileSync(pkgPath, "utf8")).exports ?? {});
const importable = subpaths.filter((s) => s !== "./cdn" && s !== "./package.json");
// `./cdn*` are IIFE global builds with no ESM/CJS shape to import, so
// they are resolve-only here. Prefix match keeps the production and
// development bundles in step without hard-coding each name.
const importable = subpaths.filter(
(s) => s !== "./package.json" && s !== "./cdn" && !s.startsWith("./cdn-"),
);
const specs = importable.map((s) => (s === "." ? "sibujs" : `sibujs${s.slice(1)}`));

writeFileSync(
Expand Down
Loading
Loading