Deferring object loading until first access, so large object graphs don't pay for what they don't touch.
STRVCT's persistence system loads every stored object eagerly on startup: each record in IndexedDB is deserialized, its finalInit() runs, and all slot values (including referenced child objects) are hydrated immediately. For moderate-sized object graphs this is fine. As the graph grows — hundreds of campaigns, thousands of characters, deeply nested location trees — startup cost grows linearly with the total number of stored objects, regardless of how many the user actually navigates to.
Lazy slots would allow a slot to hold a persistent reference (a puuid) rather than the object itself, deferring the actual load to the moment the getter is first called.
Mark a slot as lazy:
{
const slot = this.newSlot("locations", null);
slot.setIsLazy(true);
slot.setShouldStoreSlot(true);
}At persistence time, the slot stores the referenced object's puuid instead of the object value. At runtime, the slot's getter recognizes that the value has not been loaded (represented by undefined) and fetches the object from the store on demand.
Partial infrastructure for lazy slots already exists in the Slot class:
isLazy flag — A boolean slot property. When true, setAllowsUndefinedValue(true) is set so that undefined can represent "not yet loaded" without triggering validation errors.privateNameLazyPid() — Returns a private property name (e.g., _locationsLazyPid) used to store the puuid alongside the slot value.autoLazyGetter() — A generated async getter that checks the lazy pid property and, if present, calls this.defaultStore().asyncObjectForPid(pid) to fetch the object.onInstanceSetValueRef() / onInstanceGetValueRef() — Store and retrieve a reference object in the instance's lazyRefsMap, a per-instance Map that tracks which slots have pending lazy references.onInstanceLoadRef() — Resolves a stored reference back to an object by calling storeRef.unref(), then applies it through the normal setter.copyValueFromInstanceTo() — Special-cases lazy slots to copy the lazy pid rather than forcing a load of the value.onInstanceInitSlotValue(), if isLazy is true, the lazy pid is set to undefined and an assertion prevents combining isLazy with initProto (which would defeat the purpose).None of this machinery is currently activated in any class definition — setIsLazy(true) is never called outside of commented-out code.
The core tension: autoLazyGetter() returns an async function, meaning every caller of that getter must await it. But the rest of the framework — view synchronization, persistence serialization, slot copying, notification dispatch — assumes getters are synchronous. Sprinkling await through the entire call graph is not practical.
Possible approaches:
undefined until the resolve pass runs. Views already need to handle null slot values; undefined just adds a "loading" state.When a lazy slot's object is modified and persisted, the lazy slot on the parent still holds only the puuid. The dirty-tracking system needs to understand that a lazy slot's child can be dirty independently of the parent — the parent's record doesn't change just because the child was modified.
Conversely, when re-persisting the parent, the system must not force-load a lazy slot just to serialize it. The puuid is sufficient.
If object A has a lazy slot pointing to object B, which itself has a lazy slot pointing to object C, a single user navigation can trigger a chain of async loads. Without care, this creates waterfall latency — each hop waits for the previous one.
Options include prefetching (load B and all of B's lazy references in one batch) or breadth-first resolution (resolve one level of the tree at a time).
Views that display a lazy slot's value will initially see undefined. The view layer needs a consistent pattern for:
This is closely related to the Async Final Initialization problem — both require views to handle an "object not yet ready" state.
copyValueFromInstanceTo() already handles lazy slots by copying the pid rather than the value. But duplicate() — which creates a deep copy — would need to decide: duplicate the pid (creating a shared reference) or force-load the object and duplicate it (preserving deep-copy semantics). The right answer likely depends on the use case.
Lazy slots and async final initialization address overlapping problems from different angles:
A lazy slot that resolves its value could trigger the loaded object's asyncFinalInit (if that feature existed), creating a clean two-phase deferred initialization. The two features are complementary.
await node.resolveLazySlot("name"))?