Skip to content

Bugfix/runtime defects - #66

Merged
hexplus merged 4 commits into
mainfrom
bugfix/runtime-defects
Sep 7, 2026
Merged

Bugfix/runtime defects#66
hexplus merged 4 commits into
mainfrom
bugfix/runtime-defects

Conversation

@hexplus

@hexplus hexplus commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Description

Two runtime defects where a value of the right shape was judged by the wrong test, so the framework confidently did the wrong thing and said nothing — plus a third found while fixing the first. All predate 4.2: the instanceof Promise check dates to the first commit, the island registration union to the reactive-islands release.

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. Nothing downstream can recover from the framework reporting success for work it never did.

The guard lives in enhance(), not mountIslands, because enhance() and enhanceAll() are public and reach the same defect directly. A setup returning a thenable 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 nothing could ever release them. A setup that queued a microtask and then threw synchronously escaped identically.

The context is now closed once its transaction unwinds; every mutating method refuses afterwards with a dev warning naming the method the consumer called. Closing happens after the teardowns drain, because a teardown may legitimately register another cleanup while unwinding — documented behaviour that still works, and pinned by a test.

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 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, which is what await itself accepts, with nodes excluded via a realm-agnostic nodeType test so a custom element exposing then is still inserted. The fallback check had the same realm blindness and now uses the same test.

No public type is narrowed. An earlier revision required a branded LazyIslandLoader, which would catch the mistake at compile time — 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 was reverted: IslandRegistration still accepts an unbranded loader, LazyIslandLoader is exported for callers who want it, and the runtime guard carries the fix. A test records that decision so a later tightening can't happen by accident.

Behaviour change worth calling out: an async enhancement setup now throws instead of half-working. It was never supported (EnhanceSetup returns void | (() => void)); it registered whatever ran before the first await and abandoned the rest. Suspense props were widened to match what the runtime accepts (PromiseLike, and an element for fallback) — the test suite proves it by no longer needing a cast.

Diagnostics. The guard's error is thrown in production too — a check that stops a broken enhancement being reported as successful cannot be development-only. Only its long explanation compiles out, leaving a short message; tests/dist-artifacts.test.ts asserts both halves. The four new dev-only strings are in the stripping guards.

Size: core runtime 25,836 → 25,924 bytes gzipped (+88), production CDN 26,224 → 26,306 (+82). That growth is behaviour, not diagnostics.

Related Issue

Closes #

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • I have read CONTRIBUTING.md
  • My code builds without errors
  • I have tested my changes
  • I have updated documentation if needed

@hexplus
hexplus merged commit 6f2a2c7 into main Sep 7, 2026
5 checks passed
@hexplus
hexplus deleted the bugfix/runtime-defects branch September 7, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant