Repository navigation
Live view (wrap-on-set / api.add): object- and function-valued writes through the view don't reach the underlying object, and shadow it #495
Description
Summary
Since 3.15.3 (#342, fixing #340), self.X = obj and api.slothlet.api.add() are described as giving "a genuinely live, two-way view onto the underlying object" (v3.15.3 changelog). Primitive writes through the view are two-way. Object and function writes are not:
- the value is stored on the wrapper only and never reaches the underlying object;
- from then on the wrapper shadows that key, so later updates to the real object under it are invisible through the view.
Cause
setTrap in src/lib/handlers/unified-wrapper.mjs (around lines 4771–4890 on master/next, unchanged through 3.21.1):
- it forwards with
Reflect.set(liveImpl, prop, value)only when!isObjectOrFunctionValue(~4809/4821); - object and function values fall through to wrap-on-set and
Object.defineProperty(wrapper, prop, { value: stored, writable: false })(~4884) on the wrapper itself.
The #340 regression suite (tests/vitests/suites/handlers/unified-wrapper-live-delegation.test.vitest.mjs) covers only primitive writes.
Repro
Behaviour is identical on 3.15.3 and 3.21.0, in both eager and lazy modes.
// api/mod.mjs
import { self } from "@cldmv/slothlet/runtime";
export function assign() { const held = { n: 1, arr: [], nested: {} }; globalThis.__held = held; self.mod.x = held; }const api = await slothlet({ dir: "./api", mode: "eager" });
api.mod.assign(); const held = globalThis.__held;
api.mod.x.n = 10; held.n // 10 ✓ primitive forwards
api.mod.x.arr = ["cmd"]; held.arr // [] ✗ expected ["cmd"]
api.mod.x.nested.c = { a: 1 }; held.nested.c // undefined ✗ expected { a: 1 }
held.arr = ["later"]; api.mod.x.arr // ["cmd"] ✗ the view now shadows the real keyThe same happens through an add()-mounted leaf:
self.devices.d1.connection = conn; api.devices.d1.connection.deviceFeatures = ["cmd"]leavesconn.deviceFeaturesas[].- A leaf method that reads the real
connstill sees[]. - Function-valued writes (e.g.
connection.onUnhandledPacket = fn) take the same branch.
With 3.15.1 (raw reference), every line gave the expected result.
Expected
On a live-identity wrapper, an object or function write should do what primitive writes already do: Reflect.set onto the live impl, and serve the value back lazily wrapped from the impl on read. The view then stays two-way and never shadows the impl.
This needs to fit with how userAssigned values are preserved across reload and with ownership tracking.
If the intended contract really is "object writes stay on the view", then the v3.15.3 changelog and docs/CONTEXT-PROPAGATION.md should say so, and #338's original case remains open.
Not in scope
api.mod.x !== obj (and nested !==) is expected. The docs say the value gets a UnifiedWrapper, and instanceof, methods, native this substitution and socket I/O all work through the view.
Refs: #338, #340, #342. Surfaced in CLDMV/droidsock:
src/api/device.mjs:177-178assigns the session onto the mounted leaf;- droidsock PR release: v2.3.1 #48 (commit 569531e) had to stop writing
device.connection.deviceFeaturesin its tests.
Activity
Shinrai commentedon Sep 29, 2026
Landed on
nextin #501 (9d9dca34). Ships in the next release (3.22.0); this closes when it reachesmaster.release: v3.22.0 - type the composed api, self, and slothlet() from e…
cldmv-bot commentedon Oct 3, 2026
Closed in v3.22.0 — resolved by #501.