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
90 changes: 90 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,96 @@ This project follows [Semantic Versioning](https://semver.org/).
---
---

## [4.3.0] — 2026-09-07

Two defects where a value of the right *shape* was judged by the wrong test, so
the runtime confidently did the wrong thing and said nothing. Both predate 4.2:
the `instanceof Promise` check dates to the first commit, the island
registration union to the reactive-islands release.

### Fixed

- **An island loader that was never wrapped in `lazyIsland()` was run as a
setup.** A setup and a loader are both plain functions, so nothing can tell
them apart before one is called. Invoked as a setup, the loader ignored its
`ctx` and returned the import promise. The module *was* fetched — `import()`
ran — but nobody awaited it, so the setup it resolved to was discarded and
never ran. `enhance()` then returned normally and the element was stamped
`data-sibu-enhanced="true"`: a marker asserting an enhancement whose real
setup had never executed.

The guard lives in `enhance()`, not in `mountIslands`, because `enhance()` and
`enhanceAll()` are public and reach the same defect directly. A setup
returning a thenable now throws *before* the commit that records ownership and
sets the marker, so the transaction rolls back and the root is left exactly as
unenhanced as it started.

- **A rolled-back enhancement could still be mutated afterwards.** Detecting the
thenable and unwinding was only half of it: the async setup keeps running
after its first `await`, still holding `ctx`, and could register listeners,
bindings and cleanups into an enhancement that no longer existed. The root
carried no marker and the disposer had already drained, so those registrations
could never be released. A setup that queued a microtask and then threw
synchronously escaped the same way.

The context is now closed once its transaction unwinds, and every mutating
method refuses afterwards with a dev warning rather than dropping the call in
silence. Closing happens *after* the teardowns drain, because a teardown may
legitimately register another cleanup while unwinding — documented behaviour
that still works. Disposal closes the context too.

- **`Suspense` decided "is this async?" with `instanceof Promise`.** That asks
which realm built the object, not what it can do. A promise from an iframe, a
`vm` context, a worker bridge or a polyfill failed the test and was treated as
a DOM node: `insertBefore` threw, the boundary rendered its error branch for
work that was about to succeed, and the element the promise resolved to was
never inserted and never disposed — live reactive bindings attached to
nothing.

The check is now by shape (`typeof value.then === "function"`), which is what
`await` itself accepts. Nodes are excluded by a realm-agnostic `nodeType`
test, so a custom element exposing a `then` method is still inserted rather
than awaited.

- **`Suspense` dropped a fallback element from another realm.** The async check
was made realm-agnostic; the fallback check was not, so `instanceof
HTMLElement` silently discarded it and the boundary rendered nothing at all
while its promise stayed pending. Both now use the same `nodeType` test.

### Changed

- **An `async` enhancement setup now throws instead of half-working.**
Previously everything before its first `await` was registered and everything
after it escaped the transaction. It was never supported — `EnhanceSetup`
returns `void | (() => void)` — it simply failed quietly. Make the setup
synchronous and do async work inside an effect or a lifecycle hook.

- **`Suspense`'s props match what it accepts.** `nodes` is typed
`() => HTMLElement | PromiseLike<HTMLElement>`, so the cross-realm and
thenable values the fix exists for no longer need a cast; `fallback` is typed
`(() => HTMLElement) | HTMLElement`, which the runtime already handled.

### Added

- **`LazyIslandLoader`** — the branded type `lazyIsland()` returns, exported for
callers that want to be explicit.

`IslandRegistration` deliberately still accepts an *unbranded* loader.
Requiring the brand would catch a forgotten `lazyIsland(...)` at compile time,
which is where a mistake is cheapest to find, but it rejects code that
compiles today — and this package's contract is that existing public API keeps
working, with a codemod for anything that cannot be widened. There is no
codemod infrastructure to ship one through, so the narrowing is not taken and
the runtime guard carries the fix instead. A test records that decision, so a
later tightening cannot happen by accident.

The enhancement guard's error is thrown in production as well as development — a
check that stops a broken enhancement being reported as successful cannot be
development-only. Only its long explanation is compiled out, leaving a short
message; `tests/dist-artifacts.test.ts` asserts both halves.

---

## [4.2.0] — 2026-09-06

Making the runtime loud where it used to be quiet. Every item below is a case
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.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sibujs",
"version": "4.2.0",
"version": "4.3.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
22 changes: 11 additions & 11 deletions src/core/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,6 @@ export function devAssert(condition: boolean, message: string): void {
}
}

/**
* Warn in dev mode only. No-op in production.
*
* Because the body is guarded by {@link DEV}, a production bundler folds this
* function to an empty one, inlines it at every call site, and drops the
* message literals with it — so a `devWarn` call costs nothing in production
* even when the call site itself is unguarded.
*
* @param message Warning text, printed to `console.warn` prefixed `[SibuJS]`.
* @returns Nothing.
*/
/**
* Warn in dev only, composing the message lazily.
*
Expand Down Expand Up @@ -141,6 +130,17 @@ export function devWarnLazy(build: () => string): void {
}
}

/**
* Warn in dev mode only. No-op in production.
*
* Because the body is guarded by {@link DEV}, a production bundler folds this
* function to an empty one, inlines it at every call site, and drops the
* message literals with it — so a `devWarn` call costs nothing in production
* even when the call site itself is unguarded.
*
* @param message Warning text, printed to `console.warn` prefixed `[SibuJS]`.
* @returns Nothing.
*/
export function devWarn(message: string): void {
// The `__SIBU_DEV__` test is repeated INLINE here rather than reusing `DEV`,
// and that redundancy is the point.
Expand Down
98 changes: 90 additions & 8 deletions src/platform/enhance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// and ties every binding to disposal — so static content never re-paints.
// ---------------------------------------------------------------------------

import { devAssert, isDev } from "../core/dev";
import { DEV, devAssert, devWarnLazy, isDev } from "../core/dev";
import {
MAX_DRAIN_TEARDOWNS,
registerDisposer,
Expand All @@ -27,6 +27,8 @@ import { setSafeAttribute } from "../utils/setSafeAttribute";
/** Attribute marking a root that *currently* owns an active enhancement.
* Added on commit, removed on disposal — see the lifecycle notes on
* {@link enhance}. */
declare const __SIBU_DEV__: boolean | undefined;

const ENHANCED_ATTR = "data-sibu-enhanced";

/**
Expand Down Expand Up @@ -398,7 +400,33 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
const owner = Symbol("sibujs.enhance");
let disposed = false;

const bind = (target_: string | Element | null, fn: (el: HTMLElement) => void): void => {
// Whether this context's transaction has been unwound. A setup that returned
// a thenable — or one that queued a microtask and then threw — keeps running
// after `enhance()` has rolled everything back, and it still holds `ctx`.
// Without this flag those continuations register listeners, bindings and
// cleanups into an enhancement nobody owns: the root carries no marker, the
// disposer has already drained, and the registration can never be released.
//
// Set AFTER the teardowns drain, never before: a teardown may legitimately
// call `ctx.cleanup` while unwinding, and the drain loops until the list is
// stable. Closing early would break that documented reentrancy.
let closed = false;

/** True when the context is dead; warns in dev so the drop is not silent. */
const isClosed = (method: string): boolean => {
if (!closed) return false;
devWarnLazy(
() =>
`enhance: ctx.${method}() was called after this enhancement was rolled back or disposed, so it was ignored. ` +
"The setup is still running past the point where its transaction ended — usually an async setup continuing " +
"after an await, or a callback it queued before it threw. Registrations made now would belong to nothing: " +
"the root carries no enhancement marker and the disposer has already run, so nothing could ever release them.",
);
return true;
};

const bind = (method: string, target_: string | Element | null, fn: (el: HTMLElement) => void): void => {
if (isClosed(method)) return;
const el = resolveTarget(root, target_);
if (!el) {
if (typeof console !== "undefined") {
Expand All @@ -421,14 +449,14 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
}
},
on: (t, event, handler, options) => {
bind(t, (el) => {
bind("on", t, (el) => {
const wrapped = (e: Event) => handler(e as never, el);
el.addEventListener(event, wrapped as EventListener, options);
teardowns.push(() => el.removeEventListener(event, wrapped as EventListener, options));
});
},
text: (t, value) => {
bind(t, (el) => {
bind("text", t, (el) => {
teardowns.push(
bindNode(el, () => {
const v = value();
Expand All @@ -442,7 +470,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
});
},
attr: (t, name, value) => {
bind(t, (el) => {
bind("attr", t, (el) => {
teardowns.push(
bindNode(el, () => {
const v = value();
Expand Down Expand Up @@ -476,7 +504,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
});
},
classed: (t, name, on) => {
bind(t, (el) => {
bind("classed", t, (el) => {
teardowns.push(
bindNode(el, () => {
el.classList.toggle(name, Boolean(on()));
Expand All @@ -485,7 +513,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
});
},
show: (t, when) => {
bind(t, (el) => {
bind("show", t, (el) => {
// Toggle the standard `hidden` property — this both reveals an element
// the server rendered with the `hidden` attribute (the common
// progressive-enhancement case) and hides one that wasn't. Using
Expand All @@ -502,7 +530,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
});
},
model: (t, state, options) => {
bind(t, (el) => {
bind("model", t, (el) => {
const control = el as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
const [get, set] = state;
const evt =
Expand All @@ -524,6 +552,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
});
},
each: (target_, describe) => {
if (isClosed("each")) return;
devAssert(typeof describe === "function", "ctx.each: second argument must be a function.");
const elements =
typeof target_ === "string" ? ctx.refs<HTMLElement>(target_) : (Array.from(target_) as HTMLElement[]);
Expand All @@ -537,6 +566,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
}
},
cleanup: (fn) => {
if (isClosed("cleanup")) return;
teardowns.push(fn);
},
};
Expand All @@ -551,8 +581,59 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
let extra: void | (() => void);
try {
extra = setup(ctx);
// A setup that returns a thenable did not finish inside this transaction,
// so treating it as successful is a lie the marker would then carry.
//
// Two distinct mistakes arrive as the same shape, and neither is
// detectable before the call — an island setup and a lazy loader are both
// plain functions:
//
// - a loader that was never wrapped in `lazyIsland()`. Invoked as a
// setup it ignores `ctx` and returns the import promise. The module IS
// fetched — `import()` ran — but nobody awaits it, so the setup it
// resolves to is discarded and never runs.
// - an `async` setup. Everything after its first `await` registers
// outside this try block: past the rollback, past the disposer, and
// past the commit below.
//
// Throwing rather than warning is what makes this safe: the commit that
// records ownership and sets `data-sibu-enhanced` is below, so an
// exception here leaves the root exactly as unenhanced as it started.
const returned: unknown = extra;
if (returned && typeof (returned as PromiseLike<unknown>).then === "function") {
// The thenable is about to be discarded with nobody left to observe it.
// An unwrapped loader whose `import()` 404s would otherwise raise an
// unhandled rejection on top of the error we are throwing — and that
// rejection is the more confusing of the two, because it names a module
// the developer did not knowingly ask anyone to load. Report it and mark
// it handled.
(returned as PromiseLike<unknown>).then(undefined, (reason: unknown) => {
// The handler itself must exist in every build — its job is to mark the
// rejection handled — but the explanation is a diagnostic and compiles
// out with the rest of them.
devWarnLazy(() => `enhance: the promise returned by the setup also rejected: ${String(reason)}`);
});
// The THROW ships in both builds — a guard that stops a broken
// enhancement being reported as successful cannot be development-only.
// Only the prose is traded away, via the inline define test rather than
// `DEV`: the published `dist` chunk defeats `DEV`'s inlining (see
// `src/core/dev.ts`), and a helper taking a message-building callback
// does not fold either, because its own body stays live and the argument
// is therefore still evaluated. A ternary on the define folds, and the
// dead branch's literals go with it.
throw new Error(
(typeof __SIBU_DEV__ !== "undefined" ? __SIBU_DEV__ : DEV)
? "[SibuJS enhance] the setup returned a promise, so its work did not complete inside the enhancement " +
"transaction. If it is a lazy import, register it as lazyIsland(() => import(…)): an unwrapped loader " +
"is called as a setup, so its module is fetched but the setup it resolves to is discarded. If it is " +
"an async setup, make it synchronous — this enhancement has been rolled back, and ctx registrations " +
"made after this point are ignored."
: "[SibuJS enhance] setup returned a promise",
);
}
} catch (err) {
drainTeardowns(teardowns, "enhance");
closed = true;
throw err;
}
if (typeof extra === "function") teardowns.push(extra);
Expand All @@ -570,6 +651,7 @@ export function enhance(target: Element | string, setup: EnhanceSetup): () => vo
// enhance/dispose cycles on a long-lived root don't accumulate closures.
unregisterDisposer(root, dispose);
drainTeardowns(teardowns, "enhance");
closed = true;
};

// Commit. Ownership is recorded and only *then* is the root marked, so the
Expand Down
Loading
Loading