lightview 2.2.2 → 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -622,12 +622,16 @@ const elementsFromSelector = (selector, element) => {
622
622
  }
623
623
  };
624
624
 
625
- const updateTargetContent = (el, elements, raw, loc, contentHash, { element, setupChildren }, targetHash = null) => {
625
+ const updateTargetContent = (el, elements, raw, loc, contentHash, options, targetHash = null) => {
626
+ const { element, setupChildren, saveScrolls, restoreScrolls } = { ...options, ...globalThis.Lightview?.internals };
626
627
  const markerId = `${loc}-${contentHash.slice(0, 8)}`;
627
628
  let track = getOrSet(insertedContentMap, el.domEl, () => ({}));
628
629
  if (track[loc]) removeInsertedContent(el.domEl, `${loc}-${track[loc].slice(0, 8)}`);
629
630
  track[loc] = contentHash;
630
631
 
632
+ // Snapshot scroll positions document-wide before updating
633
+ const scrollMap = saveScrolls ? saveScrolls() : null;
634
+
631
635
  const performScroll = (root) => {
632
636
  if (!targetHash) return;
633
637
  requestAnimationFrame(() => {
@@ -642,18 +646,25 @@ const updateTargetContent = (el, elements, raw, loc, contentHash, { element, set
642
646
  });
643
647
  };
644
648
 
649
+ const runRestore = (root) => {
650
+ if (restoreScrolls && scrollMap) restoreScrolls(scrollMap, root);
651
+ };
652
+
645
653
  if (loc === 'shadow') {
646
654
  if (!el.domEl.shadowRoot) el.domEl.attachShadow({ mode: 'open' });
647
655
  setupChildren(elements, el.domEl.shadowRoot);
648
656
  executeScripts(el.domEl.shadowRoot);
649
657
  performScroll(el.domEl.shadowRoot);
658
+ runRestore(el.domEl.shadowRoot);
650
659
  } else if (loc === 'innerhtml') {
651
660
  el.children = elements;
652
661
  executeScripts(el.domEl);
653
662
  performScroll(document);
663
+ runRestore(el.domEl);
654
664
  } else {
655
665
  insert(elements, el.domEl, loc, markerId, { element, setupChildren });
656
666
  performScroll(document);
667
+ runRestore(el.domEl);
657
668
  }
658
669
  };
659
670
 
@@ -1573,6 +1584,114 @@ const customElementWrapper = (Component, config = {}) => {
1573
1584
  };
1574
1585
  };
1575
1586
 
1587
+
1588
+ /**
1589
+ * JSON Schema Lite Validator (Draft 7 Compatible)
1590
+ * Implements a lightweight validation engine for LightviewX.
1591
+ */
1592
+ const validateJSONSchema = (value, schema) => {
1593
+ if (!schema) return true;
1594
+ const errors = [];
1595
+ const internals = globalThis.Lightview?.internals;
1596
+
1597
+ const check = (val, sch, path = '') => {
1598
+ if (!sch) return true;
1599
+
1600
+ // Resolve named schemas
1601
+ if (typeof sch === 'string') {
1602
+ const registered = internals?.schemas?.get(sch);
1603
+ if (registered) return check(val, registered, path);
1604
+ return true; // Unknown named schema passes by default or could throw
1605
+ }
1606
+
1607
+ const type = sch.type;
1608
+ const getType = (v) => {
1609
+ if (v === null) return 'null';
1610
+ if (Array.isArray(v)) return 'array';
1611
+ return typeof v;
1612
+ };
1613
+ const currentType = getType(val);
1614
+
1615
+ // 1. Type Validation
1616
+ if (type && type !== currentType) {
1617
+ if (type === 'integer' && Number.isInteger(val)) { /* OK */ }
1618
+ else if (!(type === 'number' && typeof val === 'number')) {
1619
+ errors.push({ path, message: `Expected type ${type}, got ${currentType}`, keyword: 'type' });
1620
+ return false;
1621
+ }
1622
+ }
1623
+
1624
+ // 2. String Validation
1625
+ if (currentType === 'string') {
1626
+ if (sch.minLength !== undefined && val.length < sch.minLength) errors.push({ path, keyword: 'minLength' });
1627
+ if (sch.maxLength !== undefined && val.length > sch.maxLength) errors.push({ path, keyword: 'maxLength' });
1628
+ if (sch.pattern !== undefined && !new RegExp(sch.pattern).test(val)) errors.push({ path, keyword: 'pattern' });
1629
+ if (sch.format === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) errors.push({ path, keyword: 'format' });
1630
+ }
1631
+
1632
+ // 3. Number Validation
1633
+ if (currentType === 'number') {
1634
+ if (sch.minimum !== undefined && val < sch.minimum) errors.push({ path, keyword: 'minimum' });
1635
+ if (sch.maximum !== undefined && val > sch.maximum) errors.push({ path, keyword: 'maximum' });
1636
+ if (sch.multipleOf !== undefined && val % sch.multipleOf !== 0) errors.push({ path, keyword: 'multipleOf' });
1637
+ }
1638
+
1639
+ // 4. Object Validation
1640
+ if (currentType === 'object') {
1641
+ if (sch.required && Array.isArray(sch.required)) {
1642
+ for (const key of sch.required) {
1643
+ if (!(key in val)) errors.push({ path: path ? `${path}.${key}` : key, keyword: 'required' });
1644
+ }
1645
+ }
1646
+ if (sch.properties) {
1647
+ for (const key in sch.properties) {
1648
+ if (key in val) check(val[key], sch.properties[key], path ? `${path}.${key}` : key);
1649
+ }
1650
+ }
1651
+ if (sch.additionalProperties === false) {
1652
+ for (const key in val) {
1653
+ if (!sch.properties || !(key in sch.properties)) errors.push({ path: path ? `${path}.${key}` : key, keyword: 'additionalProperties' });
1654
+ }
1655
+ }
1656
+ }
1657
+
1658
+ // 5. Array Validation
1659
+ if (currentType === 'array') {
1660
+ if (sch.minItems !== undefined && val.length < sch.minItems) errors.push({ path, keyword: 'minItems' });
1661
+ if (sch.maxItems !== undefined && val.length > sch.maxItems) errors.push({ path, keyword: 'maxItems' });
1662
+ if (sch.uniqueItems && new Set(val).size !== val.length) errors.push({ path, keyword: 'uniqueItems' });
1663
+ if (sch.items) {
1664
+ val.forEach((item, i) => check(item, sch.items, `${path}[${i}]`));
1665
+ }
1666
+ }
1667
+
1668
+ // 6. Const & Enum
1669
+ if (sch.const !== undefined && val !== sch.const) errors.push({ path, keyword: 'const' });
1670
+ if (sch.enum && !sch.enum.includes(val)) errors.push({ path, keyword: 'enum' });
1671
+
1672
+ return errors.length === 0;
1673
+ };
1674
+
1675
+ const valid = check(value, schema);
1676
+ return valid || errors; // Return true or the array of errors
1677
+ };
1678
+
1679
+ // Hook into Lightview core validation
1680
+ const lvInternals = globalThis.__LIGHTVIEW_INTERNALS__ || globalThis.Lightview?.internals;
1681
+ if (lvInternals) {
1682
+ const hooks = lvInternals.hooks || (globalThis.Lightview?.hooks);
1683
+ if (hooks) {
1684
+ hooks.validate = (value, schema) => {
1685
+ const result = validateJSONSchema(value, schema);
1686
+ if (result === true) return true;
1687
+ // If validation fails, we throw to prevent state update
1688
+ const msg = result.map(e => `${e.path || 'root'}: failed ${e.keyword}${e.message ? ' (' + e.message + ')' : ''}`).join(', ');
1689
+ throw new Error(`Lightview Validation Error: ${msg}`);
1690
+ };
1691
+ }
1692
+ if (globalThis.Lightview) globalThis.Lightview.validate = validateJSONSchema;
1693
+ }
1694
+
1576
1695
  // Export for module usage
1577
1696
  const LightviewX = {
1578
1697
  state,
@@ -1591,6 +1710,7 @@ const LightviewX = {
1591
1710
  preloadComponentCSS,
1592
1711
  createCustomElement,
1593
1712
  customElementWrapper,
1713
+ validate: validateJSONSchema,
1594
1714
  internals: {
1595
1715
  handleSrcAttribute,
1596
1716
  parseElements
package/src/lightview.js CHANGED
@@ -1,4 +1,5 @@
1
- import { signal, effect, computed, getRegistry } from './reactivity/signal.js';
1
+ import { signal, effect, computed, getRegistry, internals } from './reactivity/signal.js';
2
+ import { state, getState } from './reactivity/state.js';
2
3
 
3
4
  const core = {
4
5
  get currentEffect() {
@@ -13,6 +14,58 @@ const nodeStateFactory = () => ({ effects: [], onmount: null, onunmount: null })
13
14
 
14
15
  const registry = getRegistry();
15
16
 
17
+ /**
18
+ * Persistent scroll memory - tracks scroll positions continuously via event listeners.
19
+ * Much more reliable than point-in-time snapshots.
20
+ */
21
+ const scrollMemory = new Map();
22
+
23
+ const initScrollMemory = () => {
24
+ if (typeof document === 'undefined') return;
25
+
26
+ // Use event delegation on document for scroll events
27
+ document.addEventListener('scroll', (e) => {
28
+ const el = e.target;
29
+ if (el === document || el === document.documentElement) return;
30
+
31
+ const key = el.id || (el.getAttribute && el.getAttribute('data-preserve-scroll'));
32
+ if (key) {
33
+ scrollMemory.set(key, { top: el.scrollTop, left: el.scrollLeft });
34
+ }
35
+ }, true); // Capture phase to catch all scroll events
36
+ };
37
+
38
+ // Initialize when DOM is ready
39
+ if (typeof document !== 'undefined') {
40
+ if (document.readyState === 'loading') {
41
+ document.addEventListener('DOMContentLoaded', initScrollMemory);
42
+ } else {
43
+ initScrollMemory();
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Returns the current scroll memory (a snapshot of all tracked positions).
49
+ */
50
+ const saveScrolls = () => new Map(scrollMemory);
51
+
52
+ /**
53
+ * Restores scroll positions from a saved map.
54
+ */
55
+ const restoreScrolls = (map, root = document) => {
56
+ if (!map || map.size === 0) return;
57
+ requestAnimationFrame(() => {
58
+ map.forEach((pos, key) => {
59
+ const node = document.getElementById(key) ||
60
+ document.querySelector(`[data-preserve-scroll="${key}"]`);
61
+ if (node) {
62
+ node.scrollTop = pos.top;
63
+ node.scrollLeft = pos.left;
64
+ }
65
+ });
66
+ });
67
+ };
68
+
16
69
  /**
17
70
  * Assocates an effect with a DOM node for automatic cleanup when the node is removed.
18
71
  */
@@ -305,6 +358,25 @@ const makeReactiveAttributes = (attributes, domNode) => {
305
358
  domNode.setAttribute(key, value);
306
359
  }
307
360
  reactiveAttrs[key] = value;
361
+ } else if (typeof value === 'object' && value !== null && Lightview.hooks.processAttribute) {
362
+ const processed = Lightview.hooks.processAttribute(domNode, key, value);
363
+ if (processed !== undefined) {
364
+ reactiveAttrs[key] = processed;
365
+ } else if (key === 'style') {
366
+ // Style object support (merged from below)
367
+ Object.entries(value).forEach(([styleKey, styleValue]) => {
368
+ if (typeof styleValue === 'function') {
369
+ const runner = effect(() => { domNode.style[styleKey] = styleValue(); });
370
+ trackEffect(domNode, runner);
371
+ } else {
372
+ domNode.style[styleKey] = styleValue;
373
+ }
374
+ });
375
+ reactiveAttrs[key] = value;
376
+ } else {
377
+ setAttributeValue(domNode, key, value);
378
+ reactiveAttrs[key] = value;
379
+ }
308
380
  } else if (typeof value === 'function') {
309
381
  // Reactive binding
310
382
  const runner = effect(() => {
@@ -317,19 +389,6 @@ const makeReactiveAttributes = (attributes, domNode) => {
317
389
  });
318
390
  trackEffect(domNode, runner);
319
391
  reactiveAttrs[key] = value;
320
- } else if (key === 'style' && typeof value === 'object') {
321
- // Handle style object which may contain reactive values
322
- Object.entries(value).forEach(([styleKey, styleValue]) => {
323
- if (typeof styleValue === 'function') {
324
- const runner = effect(() => {
325
- domNode.style[styleKey] = styleValue();
326
- });
327
- trackEffect(domNode, runner);
328
- } else {
329
- domNode.style[styleKey] = styleValue;
330
- }
331
- });
332
- reactiveAttrs[key] = value;
333
392
  } else {
334
393
  // Static attribute - handle undefined/null/boolean properly
335
394
  setAttributeValue(domNode, key, value);
@@ -595,6 +654,9 @@ const tags = new Proxy({}, {
595
654
  });
596
655
 
597
656
  const Lightview = {
657
+ state,
658
+ getState,
659
+ registerSchema: (name, definition) => internals.schemas.set(name, definition),
598
660
  signal,
599
661
  get: signal.get,
600
662
  computed,
@@ -608,14 +670,24 @@ const Lightview = {
608
670
  hooks: {
609
671
  onNonStandardHref: null,
610
672
  processChild: null,
611
- validateUrl: null
673
+ processAttribute: null,
674
+ validateUrl: null,
675
+ validate: (value, schema) => internals.hooks.validate(value, schema)
612
676
  },
613
677
  // Internals exposed for extensions
614
678
  internals: {
615
679
  core,
616
680
  domToElement,
617
681
  wrapDomElement,
618
- setupChildren
682
+ setupChildren,
683
+ trackEffect,
684
+ saveScrolls,
685
+ restoreScrolls,
686
+ localRegistries: internals.localRegistries,
687
+ futureSignals: internals.futureSignals,
688
+ schemas: internals.schemas,
689
+ parents: internals.parents,
690
+ hooks: internals.hooks
619
691
  }
620
692
  };
621
693
 
@@ -6,33 +6,48 @@
6
6
  // Global Handshake: Ensures all bundles share the same reactive engine
7
7
  const _LV = (globalThis.__LIGHTVIEW_INTERNALS__ ||= {
8
8
  currentEffect: null,
9
- registry: new Map(),
10
- dependencyMap: new WeakMap() // Tracking signals -> subscribers
9
+ registry: new Map(), // Global name -> Signal/Proxy
10
+ localRegistries: new WeakMap(), // Object/Element -> Map(name -> Signal/Proxy)
11
+ futureSignals: new Map(), // name -> Set of (signal) => void
12
+ schemas: new Map(), // name -> Schema (Draft 7+ or Shorthand)
13
+ parents: new WeakMap(), // Proxy -> Parent (Proxy/Element)
14
+ helpers: new Map(), // name -> function (used for transforms and expressions)
15
+ hooks: {
16
+ validate: (value, schema) => true // Hook for extensions (like JPRX) to provide full validation
17
+ }
11
18
  });
12
19
 
20
+ /**
21
+ * Resolves a named signal/state using up-tree search starting from scope.
22
+ */
23
+ export const lookup = (name, scope) => {
24
+ let current = scope;
25
+ while (current && typeof current === 'object') {
26
+ const registry = _LV.localRegistries.get(current);
27
+ if (registry && registry.has(name)) return registry.get(name);
28
+ current = current.parentElement || _LV.parents.get(current);
29
+ }
30
+ return _LV.registry.get(name);
31
+ };
32
+
13
33
  /**
14
34
  * Creates a reactive signal.
15
35
  */
16
36
  export const signal = (initialValue, optionsOrName) => {
17
- let name = typeof optionsOrName === 'string' ? optionsOrName : optionsOrName?.name;
37
+ const name = typeof optionsOrName === 'string' ? optionsOrName : optionsOrName?.name;
18
38
  const storage = optionsOrName?.storage;
39
+ const scope = optionsOrName?.scope;
19
40
 
20
41
  if (name && storage) {
21
42
  try {
22
43
  const stored = storage.getItem(name);
23
- if (stored !== null) {
24
- initialValue = JSON.parse(stored);
25
- }
26
- } catch (e) { /* Ignore storage errors */ }
44
+ if (stored !== null) initialValue = JSON.parse(stored);
45
+ } catch (e) { /* Ignore */ }
27
46
  }
28
47
 
29
48
  let value = initialValue;
30
49
  const subscribers = new Set();
31
-
32
- const f = (...args) => {
33
- if (args.length === 0) return f.value;
34
- f.value = args[0];
35
- };
50
+ const f = (...args) => args.length === 0 ? f.value : (f.value = args[0]);
36
51
 
37
52
  Object.defineProperty(f, 'value', {
38
53
  get() {
@@ -46,24 +61,24 @@ export const signal = (initialValue, optionsOrName) => {
46
61
  if (value !== newValue) {
47
62
  value = newValue;
48
63
  if (name && storage) {
49
- try {
50
- storage.setItem(name, JSON.stringify(value));
51
- } catch (e) { /* Ignore storage errors */ }
64
+ try { storage.setItem(name, JSON.stringify(value)); } catch (e) { /* Ignore */ }
52
65
  }
53
- // Copy subscribers to avoid infinite loop when effect re-subscribes during iteration
54
66
  [...subscribers].forEach(effect => effect());
55
67
  }
56
68
  }
57
69
  });
58
70
 
59
71
  if (name) {
60
- if (_LV.registry.has(name)) {
61
- // Already registered - could be a name collision or re-registration
62
- if (_LV.registry.get(name) !== f) {
63
- throw new Error(`Lightview: A signal or state with the name "${name}" is already registered.`);
64
- }
65
- } else {
66
- _LV.registry.set(name, f);
72
+ const registry = (scope && typeof scope === 'object') ? (_LV.localRegistries.get(scope) || _LV.localRegistries.set(scope, new Map()).get(scope)) : _LV.registry;
73
+ if (registry && registry.has(name) && registry.get(name) !== f) {
74
+ throw new Error(`Lightview: A signal or state with the name "${name}" is already registered.`);
75
+ }
76
+ if (registry) registry.set(name, f);
77
+
78
+ // Resolve future signal waiters
79
+ const futures = _LV.futureSignals.get(name);
80
+ if (futures) {
81
+ futures.forEach(resolve => resolve(f));
67
82
  }
68
83
  }
69
84
 
@@ -71,13 +86,37 @@ export const signal = (initialValue, optionsOrName) => {
71
86
  };
72
87
 
73
88
  /**
74
- * Gets a named signal from the registry.
89
+ * Gets a named signal, or a 'future' signal if not found.
75
90
  */
76
- export const getSignal = (name, defaultValue) => {
77
- if (!_LV.registry.has(name) && defaultValue !== undefined) {
78
- return signal(defaultValue, name);
79
- }
80
- return _LV.registry.get(name);
91
+ export const getSignal = (name, defaultValueOrOptions) => {
92
+ const options = typeof defaultValueOrOptions === 'object' && defaultValueOrOptions !== null ? defaultValueOrOptions : { defaultValue: defaultValueOrOptions };
93
+ const { scope, defaultValue } = options;
94
+
95
+ const existing = lookup(name, scope);
96
+ if (existing) return existing;
97
+
98
+ if (defaultValue !== undefined) return signal(defaultValue, { name, scope });
99
+
100
+ // Return a "Future Signal" that will track the real one once registered
101
+ const future = signal(undefined);
102
+ const handler = (realSignal) => {
103
+ // When the real signal appears, sync the future one
104
+ // If it's a signal (has .value), track its value. If it's a state proxy or object, it IS the value.
105
+ const hasValue = realSignal && (typeof realSignal === 'object' || typeof realSignal === 'function') && 'value' in realSignal;
106
+ if (hasValue) {
107
+ future.value = realSignal.value;
108
+ effect(() => {
109
+ future.value = realSignal.value;
110
+ });
111
+ } else {
112
+ future.value = realSignal;
113
+ }
114
+ };
115
+
116
+ if (!_LV.futureSignals.has(name)) _LV.futureSignals.set(name, new Set());
117
+ _LV.futureSignals.get(name).add(handler);
118
+
119
+ return future;
81
120
  };
82
121
 
83
122
  // Map .get to signal for backwards compatibility
@@ -131,3 +170,8 @@ export const computed = (fn) => {
131
170
  * Returns the global registry.
132
171
  */
133
172
  export const getRegistry = () => _LV.registry;
173
+
174
+ /**
175
+ * Returns the global internals (private use).
176
+ */
177
+ export const internals = _LV;
@@ -3,12 +3,53 @@
3
3
  * Provides deeply reactive state by wrapping objects/arrays in Proxies.
4
4
  */
5
5
 
6
- import { signal as signalFactory, effect, getRegistry } from './signal.js';
6
+ import { signal as signalFactory, effect, getRegistry, internals, lookup } from './signal.js';
7
7
 
8
- // Internal helpers and caches
8
+ // Internal caches
9
9
  const stateCache = new WeakMap();
10
10
  const stateSignals = new WeakMap();
11
- const parents = new WeakMap();
11
+ const stateSchemas = new WeakMap();
12
+ const { parents, schemas, hooks } = internals;
13
+
14
+ /**
15
+ * Core Validation & Coercion
16
+ */
17
+ const validate = (target, prop, value, schema) => {
18
+ const current = target[prop];
19
+ const type = typeof current;
20
+ const isNew = !(prop in target);
21
+
22
+ // 1. Resolve schema behavior
23
+ let behavior = schema;
24
+ if (typeof schema === 'object' && schema !== null) behavior = schema.type;
25
+
26
+ if (behavior === 'auto' && isNew) throw new Error(`Lightview: Cannot add new property "${prop}" to fixed 'auto' state.`);
27
+
28
+ // 2. Perform Validation/Coercion
29
+ if (behavior === 'polymorphic' || (typeof behavior === 'object' && behavior?.coerce)) {
30
+ if (type === 'number') return Number(value);
31
+ if (type === 'boolean') return Boolean(value);
32
+ if (type === 'string') return String(value);
33
+ } else if (behavior === 'auto' || behavior === 'dynamic') {
34
+ if (!isNew && typeof value !== type) {
35
+ throw new Error(`Lightview: Type mismatch for "${prop}". Expected ${type}, got ${typeof value}.`);
36
+ }
37
+ }
38
+
39
+ // 3. Perform Transformations (Lightview Extension)
40
+ if (typeof schema === 'object' && schema !== null && schema.transform) {
41
+ const trans = schema.transform;
42
+ const transformFn = typeof trans === 'function' ? trans : (internals.helpers.get(trans) || globalThis.Lightview?.helpers?.[trans]);
43
+ if (transformFn) value = transformFn(value);
44
+ }
45
+
46
+ // 4. Delegation to hooks (for full JSON Schema support in JPRX/Lightview-X)
47
+ if (hooks.validate(value, schema) === false) {
48
+ throw new Error(`Lightview: Validation failed for "${prop}".`);
49
+ }
50
+
51
+ return value;
52
+ };
12
53
 
13
54
  // Build method lists dynamically from prototypes
14
55
  const protoMethods = (proto, test) => Object.getOwnPropertyNames(proto).filter(k => typeof proto[k] === 'function' && test(k));
@@ -48,12 +89,15 @@ const proxyGet = (target, prop, receiver, signals) => {
48
89
  };
49
90
 
50
91
  const proxySet = (target, prop, value, receiver, signals) => {
92
+ const schema = stateSchemas.get(receiver);
93
+ const validatedValue = schema ? validate(target, prop, value, schema) : value;
94
+
51
95
  if (!signals.has(prop)) {
52
96
  signals.set(prop, signalFactory(Reflect.get(target, prop, receiver)));
53
97
  }
54
- const success = Reflect.set(target, prop, value, receiver);
98
+ const success = Reflect.set(target, prop, validatedValue, receiver);
55
99
  const signal = signals.get(prop);
56
- if (success && signal) signal.value = value;
100
+ if (success && signal) signal.value = validatedValue;
57
101
  return success;
58
102
  };
59
103
 
@@ -154,6 +198,8 @@ export const state = (obj, optionsOrName) => {
154
198
 
155
199
  const name = typeof optionsOrName === 'string' ? optionsOrName : optionsOrName?.name;
156
200
  const storage = optionsOrName?.storage;
201
+ const scope = optionsOrName?.scope;
202
+ const schema = optionsOrName?.schema;
157
203
 
158
204
  if (name && storage) {
159
205
  try {
@@ -162,7 +208,7 @@ export const state = (obj, optionsOrName) => {
162
208
  const loaded = JSON.parse(item);
163
209
  Array.isArray(obj) && Array.isArray(loaded) ? (obj.length = 0, obj.push(...loaded)) : Object.assign(obj, loaded);
164
210
  }
165
- } catch (e) { /* Storage access denied or corrupted JSON */ }
211
+ } catch (e) { /* Ignore */ }
166
212
  }
167
213
 
168
214
  let proxy = stateCache.get(obj);
@@ -183,20 +229,25 @@ export const state = (obj, optionsOrName) => {
183
229
  } else return obj;
184
230
  }
185
231
 
232
+ if (schema) stateSchemas.set(proxy, schema);
233
+
186
234
  if (name && storage) {
187
235
  effect(() => {
188
- try { storage.setItem(name, JSON.stringify(proxy)); } catch (e) { /* Persistence failed */ }
236
+ try { storage.setItem(name, JSON.stringify(proxy)); } catch (e) { /* Ignore */ }
189
237
  });
190
238
  }
191
239
 
192
240
  if (name) {
193
- const registry = getRegistry();
194
- if (registry.has(name)) {
195
- if (registry.get(name) !== proxy) {
196
- throw new Error(`Lightview: A signal or state with the name "${name}" is already registered.`);
197
- }
198
- } else {
199
- registry.set(name, proxy);
241
+ const registry = (scope && typeof scope === 'object') ? (internals.localRegistries.get(scope) || internals.localRegistries.set(scope, new Map()).get(scope)) : getRegistry();
242
+ if (registry && registry.has(name) && registry.get(name) !== proxy) {
243
+ throw new Error(`Lightview: A signal or state with the name "${name}" is already registered.`);
244
+ }
245
+ if (registry) registry.set(name, proxy);
246
+
247
+ // Resolve future signal waiters
248
+ const futures = internals.futureSignals.get(name);
249
+ if (futures) {
250
+ futures.forEach(resolve => resolve(proxy));
200
251
  }
201
252
  }
202
253
 
@@ -204,14 +255,26 @@ export const state = (obj, optionsOrName) => {
204
255
  };
205
256
 
206
257
  /**
207
- * Gets a named state from the registry.
258
+ * Gets a named state using up-tree search starting from scope.
208
259
  */
209
- export const getState = (name, defaultValue) => {
210
- const registry = getRegistry();
211
- if (!registry.has(name) && defaultValue !== undefined) {
212
- return state(defaultValue, name);
213
- }
214
- return registry.get(name);
260
+ export const getState = (name, defaultValueOrOptions) => {
261
+ const options = typeof defaultValueOrOptions === 'object' && defaultValueOrOptions !== null ? defaultValueOrOptions : { defaultValue: defaultValueOrOptions };
262
+ const { scope, defaultValue } = options;
263
+
264
+ const existing = lookup(name, scope);
265
+ if (existing) return existing;
266
+
267
+ if (defaultValue !== undefined) return state(defaultValue, { name, scope });
268
+
269
+ // Future State Resolution (similar to getSignal)
270
+ const future = signalFactory(undefined);
271
+ const handler = (realState) => {
272
+ future.value = realState;
273
+ };
274
+ if (!internals.futureSignals.has(name)) internals.futureSignals.set(name, new Set());
275
+ internals.futureSignals.get(name).add(handler);
276
+
277
+ return future;
215
278
  };
216
279
 
217
280
  state.get = getState;