v3.9.1
release: v3.9.1 - browser mode — consolidate node:* gating + fix… (#127)
Slothlet v3.9.1 Changelog
Release Date: May 2026
Release Type: Patch
Branch: release/3.9.1
Overview
Version 3.9.1 hardens browser mode. v3.9.0 introduced the browser / worker target; 3.9.1 is the first round of fixes from exercising it against the major subsystems — context, self, hooks, permissions, metadata, lifecycle events, and api mutation — plus a consolidation of the node:* gating that browser mode depends on. It also folds in a CodeQL quality-alert cleanup (#122).
The headline fix: browser mode previously crashed on the live-binding self / context runtime in some configurations, because node:* gating was scattered and inconsistent. All Node-builtin access now routes through a single platform layer (src/lib/helpers/platform.mjs), so the browser graph never touches node:fs / node:path / node:url / node:async_hooks, and the live runtime no longer throws.
Compatibility. No API breaking changes. One environment change: the minimum Node version is raised to 22.0.0 (see the Environment section below) — shipped as a patch by project decision, but called out explicitly here.
🐛 Bug Fixes
Browser mode — live-binding self / context crash (#123)
node:* gating was duplicated across several modules with subtly different conditions, and one path let a browser graph reach a Node-only branch — crashing the live-binding runtime that browser mode is forced to use (no AsyncLocalStorage). All gating is consolidated into a single platform helper that resolves isNode, the (possibly null) fs / path / url namespaces, and a loadJson shim once. Browser builds no longer load node:*, and the live self / context bindings work across the full mode matrix.
Full-instance reload() no longer throws on the normalized config (#91)
api.slothlet.reload() re-ran config normalization over an already-normalized config. The resolveModuleSpecifier guard rejected the normalized shape (a null it had itself produced), so a full-instance reload threw. The guard now treats null like undefined, so reload is idempotent — it succeeds across the full mode matrix, including with keepInstanceID.
Eager-mode api.remove now deletes the mount and fires impl:removed
In eager + browser mode, removing a path left the property on the api tree and skipped the impl:removed lifecycle event. The root cause was an ownership-attribution bug: re-running the eager builder for a mutation re-emitted child wrappers under the base module's id, so removal rolled the ownership stack back to the base instead of deleting. Child-wrapper ownership now always prefers the parent/build owner, so remove deletes the mount and fires impl:removed in both lazy and eager modes.
Browser shutdown no longer crashes on the TypeScript-cache cleanup
shutdown() cleaned up on-disk TypeScript-cache directories unconditionally. In a browser there is no filesystem (fsp is null via the platform layer), so the cleanup threw. It is now guarded behind isNode, so browser shutdown is clean.
i18n — currentLanguage stays consistent with the active translations
During a pending asynchronous locale load (the browser path, where locale JSON arrives via dynamic import), getLanguage() could report the requested locale while the active translations were still the bundled English defaults. currentLanguage is now kept consistent with the translations actually in effect — it reports en-us until the load resolves, then switches.
🚀 New / Changed APIs
setLanguageAsync(lang) — awaitable locale switching
A new public i18n export that mirrors setLanguage() but awaits the locale load, so it works in a browser where locales arrive via dynamic import(…, { with: { type: "json" } }) rather than the filesystem. In Node the await is a no-op over the synchronous read. Use it when you need to await a locale switch (e.g. in an Electron renderer). A failed load warns and keeps the bundled English default.
⚙️ Environment
Minimum Node raised to 22.0.0
The bundled default-locale i18n now uses import attributes (import … with { type: "json" }) to embed en-us.json into the static module graph — required so a browser bundle ships the default locale without filesystem access. Stable import attributes need Node ≥ 22, so engines.node is raised from >=20.19.0 to >=22.0.0.
Raising the engine floor is technically a breaking change; it ships here as a patch by project decision because the practical impact is narrow (Node 20 reached the relevant boundary and 22 is the active LTS). It is called out explicitly so it isn't a silent surprise. Hosts on Node 20 should pin to v3.9.0 or upgrade their runtime.
🧹 Chore
CodeQL quality-alert cleanup (#122)
- Resolved 14 CodeQL quality alerts (dead code and redundant guards) across the source tree.
- Scoped the CodeQL workflow to source only, excluding generated type declarations (
types/) and test fixtures (api_tests/) from analysis, so alerts track real source paths rather than build output.
Type declarations regenerated
Regenerated types/ to add the new platform helper declarations and the setLanguageAsync export. The #123 consolidation moved node:* access into the platform layer; the declarations now reflect it.
🧪 Tests
- Node-side
platform:"browser"suites for the major systems. New browser suites exercise context,self, hooks, permissions, metadata, lifecycle events, api tree, mutations (add/remove/reload), env-snapshot omission, and loader edge cases underplatform:"browser"— proving browser parity without a real browser, plus a Playwright smoke test that loads slothlet in an actual browser. - Browser matrix collapsed to live-only. Browser mode forces the live context manager regardless of the requested
runtime, so the browser suites' async/live matrix axis was redundant — aruntime:"async"config was silently coerced to live and re-ran the same paths under a misleading name. AgetBrowserMatrixConfigs()helper now spans only the axes that vary browser behavior (mode × hooks), and a single explicit test asserts theasync → livecoercion. - Concurrent-context test corrected to live mode's real guarantee. The browser context suite previously asserted that interleaved concurrent
context.run()calls stay isolated — an invariant the live manager cannot provide withoutAsyncLocalStorage(a single global active-instance field is overwritten across anawait). It now asserts what live mode does guarantee — sequentialrun()isolation with the base context restored between calls. The interleaved-concurrency case remains covered by the Node async (ALS) runtime. - Full coverage gate green: 100% statements / branches / functions / lines, 0 failures.
📚 Documentation
- docs/CONTEXT-PROPAGATION.md — documents the live-bindings / browser-mode concurrency boundary: sequential
run()/scope()calls are isolated; interleaved concurrent calls on the same instance require the Node async (ALS) runtime. LiveContextManagerJSDoc now states the same boundary at the source.
🔧 Internal
- NEW:
src/lib/helpers/platform.mjs— single consolidation point for Node-vs-browser detection andnode:*gating. ExposesisNode, the (null-in-browser)fs/path/url/fspnamespaces, and aloadJsonshim (synchronous in Node, dynamic-import-based in a browser). Replaces the scattered per-module gating that caused #123.
Upgrade notes
- Node ≥ 22 required. Upgrade your runtime, or pin to v3.9.0 if you must stay on Node 20.
- No API or config changes are required. Browser-mode hosts get the live-runtime crash fix, working full-instance
reload(), correct eagerapi.remove, and clean shutdown automatically. - For awaitable locale switching in a browser, use the new
setLanguageAsync(lang).