# 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`](../../../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 under `platform:"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 โ€” a `runtime:"async"` config was silently coerced to live and re-ran the same paths under a misleading name. A `getBrowserMatrixConfigs()` helper now spans only the axes that vary browser behavior (mode ร— hooks), and a single explicit test asserts the `async โ†’ live` coercion. - **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 without `AsyncLocalStorage` (a single global active-instance field is overwritten across an `await`). It now asserts what live mode does guarantee โ€” **sequential** `run()` 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](../../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. - `LiveContextManager` JSDoc now states the same boundary at the source. --- ## ๐Ÿ”ง Internal - **NEW:** [`src/lib/helpers/platform.mjs`](../../../src/lib/helpers/platform.mjs) โ€” single consolidation point for Node-vs-browser detection and `node:*` gating. Exposes `isNode`, the (null-in-browser) `fs` / `path` / `url` / `fsp` namespaces, and a `loadJson` shim (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 eager `api.remove`, and clean shutdown automatically. - For awaitable locale switching in a browser, use the new `setLanguageAsync(lang)`.