lego-dom 2.0.2 → 2.0.4-alpha

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.
package/.md ADDED
@@ -0,0 +1,78 @@
1
+ # LegoDOM Codebase Audit Report
2
+
3
+ ## 1. Component to Block Renaming
4
+ The renaming of "Concept" from `Component` to `Block` appears to be consistent across the core codebase.
5
+
6
+ - **Core Logic (`main.js`)**:
7
+ - `Lego.block()` is correctly defined and used.
8
+ - `registry` and logic maps are clean.
9
+ - No residual `Component` terminology found in core logic.
10
+ - `Lego.define` is safely aliased to `Lego.block` for backward compatibility.
11
+
12
+ - **Vite Plugin (`vite-plugin.js`)**:
13
+ - Uses `virtual:lego-blocks`.
14
+ - Scans for `.lego` files in `src/blocks`.
15
+ - Imports helper functions from `parse-lego.js`.
16
+
17
+ - **Tests**:
18
+ - `tests/naming.test.js` and `tests/parse-lego.test.js` utilize correct `Block` terminology.
19
+ - All tests passed successfully.
20
+
21
+ - **Minor Inconsistencies (Non-Critical)**:
22
+ - `examples/vite-app/src/app.js`: Imports the virtual module as `registerComponents`.
23
+ - `examples/benchmark.html`: User-facing text still refers to "Components".
24
+
25
+ ## 2. `.lego` File Parsing Logic Issues (Critical)
26
+
27
+ A significant fragility was identified in how `.lego` files (Single File Blocks) are parsed in both `main.js` (runtime loader) and `parse-lego.js` (build-time plugin).
28
+
29
+ ### The Issue
30
+ The parsing logic relies on a regex that strictly expects an object literal export:
31
+ ```javascript
32
+ /export\s+default\s+({[\s\S]*})/
33
+ ```
34
+ This fails if a user defines a variable and exports it, or uses any other export syntax.
35
+
36
+ **Example Failure Case:**
37
+ ```javascript
38
+ <script>
39
+ const logic = {
40
+ data: { count: 0 }
41
+ };
42
+ export default logic;
43
+ </script>
44
+ ```
45
+
46
+ **Consequences:**
47
+ 1. **Runtime (`defineLegoFile` in `main.js`)**:
48
+ The fallback mechanism tries to execute the entire script content inside a `new Function('return ' + script)`. This results in a `SyntaxError` because statements like `const` or `export` are not valid in a return statement.
49
+
50
+ 2. **Build-time (`vite-plugin.js`)**:
51
+ The generated code becomes invalid JavaScript:
52
+ ```javascript
53
+ Lego.block('name', `tpl`, const logic = ...; export default logic;, 'styles');
54
+ ```
55
+ This causes the build or runtime execution to crash with a SyntaxError.
56
+
57
+ ### Recommendation
58
+ Refactor the parsing logic to be more robust.
59
+ - **For Build-time**: Parse the script properly or strip the `export default` text and wrap the rest in an IIFE or leave it as a valid expression if possible. A true JS parser would be safest.
60
+ - **For Runtime**: Similar logic needed.
61
+
62
+ ## 3. Code Duplication
63
+ There is significant logic duplication between `main.js` (`defineLegoFile`) and `parse-lego.js`.
64
+ - Both contain identical regex for parsing `<template>`, `<script>`, `<style>`.
65
+ - Both contain identical `deriveBlockName` logic.
66
+ - Both contain the flawed `export default` extraction logic.
67
+
68
+ **Recommendation**: Extract the parsing logic into a shared module that can be used by both `main.js` (if bundled or imported) and `vite-plugin.js`, or verify if `parse-lego.js` can be imported by `main.js` (currently `main.js` is a single file likely for CDN usage, complicating imports).
69
+
70
+ ## 4. Security Warning
71
+ `main.js` line 915 uses `new Function` to evaluate the script block.
72
+ ```javascript
73
+ const logicObj = new Function(`return ${script}`)();
74
+ ```
75
+ While this is standard for some lightweight Runtime compilations, it assumes trusted content. The duplication of this risky logic (duplicated from `parse-lego.js` intent) increases the surface area for bugs.
76
+
77
+ ## Summary
78
+ The renaming is successful. However, the `Single File Block` logic (`.lego` files) is brittle regarding script exports and should be addressed to prevent user frustration.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,31 @@
1
1
  # Changelog
2
2
 
3
3
  # Changelog
4
+ ## [2.0.4-alpha] - 2026-01-20
5
+
6
+ ### Fixes
7
+
8
+ - **SSR/Node Compatibility:** Added checks for `window`, `document`, and `Node` throughout the core to ensure LegoDOM can be imported and executed in Node.js/SSR environments without crashing.
9
+ - **Global Export:** `Lego` is now correctly exported to `global` in Node.js environments.
10
+
11
+ ## [2.0.3] - 2026-01-19
12
+
13
+ ### Features
14
+
15
+ - **Reactive Persistence (`$db`):** New helper for persisting state to `localStorage` with automatic hydration, debouncing, and cross-tab synchronization.
16
+ ```html
17
+ <user-prefs b-logic="{
18
+ theme: $db('app.theme').default('light'),
19
+ volume: $db('app.vol').default(50).debounce(300)
20
+ }"></user-prefs>
21
+ ```
22
+ - **Auto-Load:** Values are automatically hydrated from `localStorage` on block creation.
23
+ - **Auto-Save:** Changes are automatically persisted (with optional debounce).
24
+ - **Cross-Tab Sync:** Updates in one tab automatically reflect in other tabs.
25
+
26
+ ### Documentation
27
+
28
+ - Added `docs/guide/persistence.md` covering the `$db` API, namespacing best practices, and performance warnings.
4
29
 
5
30
  ## [2.0.2] - 2026-01-19
6
31
 
package/lego.js CHANGED
@@ -1,2 +1,2 @@
1
1
  import './main.js';
2
- export const Lego = window.Lego;
2
+ export const Lego = (typeof window !== 'undefined' ? window.Lego : global.Lego);
package/main.js CHANGED
@@ -5,7 +5,47 @@ const Lego = (() => {
5
5
 
6
6
  const legoFileLogic = new Map();
7
7
  const sharedStates = new Map();
8
- const expressionCache = new Map(); // Cache for compiled expressions
8
+
9
+ // LRU Cache for compiled expressions (prevents unbounded memory growth)
10
+ class LRUCache {
11
+ constructor(limit = 1000) {
12
+ this.limit = limit;
13
+ this.cache = new Map();
14
+ }
15
+
16
+ get(key) {
17
+ if (!this.cache.has(key)) return undefined;
18
+
19
+ // Move to end (most recently used) by deleting and re-inserting
20
+ const value = this.cache.get(key);
21
+ this.cache.delete(key);
22
+ this.cache.set(key, value);
23
+ return value;
24
+ }
25
+
26
+ set(key, value) {
27
+ // If key exists, delete it first so we can re-insert at end
28
+ if (this.cache.has(key)) {
29
+ this.cache.delete(key);
30
+ } else if (this.cache.size >= this.limit) {
31
+ // Evict oldest (first item in Map)
32
+ const firstKey = this.cache.keys().next().value;
33
+ this.cache.delete(firstKey);
34
+ }
35
+
36
+ this.cache.set(key, value);
37
+ }
38
+
39
+ get size() {
40
+ return this.cache.size;
41
+ }
42
+
43
+ clear() {
44
+ this.cache.clear();
45
+ }
46
+ }
47
+
48
+ const expressionCache = new LRUCache(1000); // Max 1000 cached expressions
9
49
 
10
50
  const styleRegistry = new Map();
11
51
  let styleConfig = {};
@@ -98,10 +138,13 @@ const Lego = (() => {
98
138
 
99
139
  const createBatcher = () => {
100
140
  let queued = false;
101
- const componentsToUpdate = new Set();
141
+ const blocksToUpdate = new Set();
102
142
  let isProcessing = false;
103
143
 
104
- // Optimization: Single timer for all updated hooks instead of one per component
144
+ // FIX: Queue for updates arriving during processing (prevents drops)
145
+ const pendingQueue = new Set();
146
+
147
+ // Optimization: Single timer for all updated hooks instead of one per block
105
148
  let updatedTimer = null;
106
149
  const pendingUpdated = new Set();
107
150
 
@@ -119,41 +162,200 @@ const Lego = (() => {
119
162
  }, 50); // Global debounce
120
163
  };
121
164
 
165
+ const processPendingQueue = () => {
166
+ if (pendingQueue.size > 0) {
167
+ // Move pending to main queue
168
+ pendingQueue.forEach(el => blocksToUpdate.add(el));
169
+ pendingQueue.clear();
170
+
171
+ // Trigger next batch if not already queued
172
+ if (!queued && blocksToUpdate.size > 0) {
173
+ queued = true;
174
+ requestAnimationFrame(processBatch);
175
+ }
176
+ }
177
+ };
178
+
179
+ const processBatch = () => {
180
+ isProcessing = true;
181
+ const batch = Array.from(blocksToUpdate);
182
+ blocksToUpdate.clear();
183
+ queued = false;
184
+
185
+ // Simple synchronous render loop (faster for small batches, no overhead)
186
+ batch.forEach(el => {
187
+ if (el.isConnected) render(el);
188
+ });
189
+
190
+ // Batch complete: Queue updated hooks efficiently
191
+ batch.forEach(el => pendingUpdated.add(el));
192
+ scheduleUpdatedHooks();
193
+ isProcessing = false;
194
+
195
+ // FIX: Process pending queue after batch completes
196
+ processPendingQueue();
197
+ };
198
+
122
199
  return {
123
200
  add: (el) => {
124
- if (!el || isProcessing) return;
125
- componentsToUpdate.add(el);
201
+ if (!el) return;
202
+
203
+ // FIX: Queue during processing instead of dropping
204
+ if (isProcessing) {
205
+ pendingQueue.add(el);
206
+ return;
207
+ }
208
+
209
+ blocksToUpdate.add(el);
126
210
  if (queued) return;
127
211
  queued = true;
128
212
 
129
- requestAnimationFrame(() => {
130
- isProcessing = true;
131
- const batch = Array.from(componentsToUpdate);
132
- componentsToUpdate.clear();
133
- queued = false;
213
+ requestAnimationFrame(processBatch);
214
+ }
215
+ };
216
+ };
217
+
218
+ const globalBatcher = createBatcher();
219
+
220
+ // --- Persistence Layer ($db) ---
221
+ const dbBindings = new Map(); // storageKey -> Set<{target, prop}>
222
+ const dbTimers = new Map(); // storageKey -> timerId
223
+
224
+ const registerDbBinding = (target, prop, key) => {
225
+ if (!dbBindings.has(key)) dbBindings.set(key, new Set());
226
+ dbBindings.get(key).add({ target, prop });
227
+ };
228
+
229
+ // Storage quota management (FIFO eviction)
230
+ const storageKeys = new Map(); // key -> { timestamp, size }
231
+
232
+ const evictOldestKeys = (bytesNeeded) => {
233
+ const entries = Array.from(storageKeys.entries())
234
+ .filter(([key]) => key.startsWith('lego:')) // Only evict Lego keys
235
+ .sort((a, b) => a[1].timestamp - b[1].timestamp); // Oldest first
236
+
237
+ let freedBytes = 0;
238
+ const evicted = [];
134
239
 
135
- // Simple synchronous render loop (faster for small batches, no overhead)
136
- batch.forEach(el => render(el));
240
+ for (const [key, meta] of entries) {
241
+ if (freedBytes >= bytesNeeded) break;
242
+
243
+ try {
244
+ localStorage.removeItem(key);
245
+ freedBytes += meta.size;
246
+ evicted.push(key);
247
+ storageKeys.delete(key);
248
+ } catch (e) {
249
+ console.error(`[Lego] Failed to evict ${key}:`, e);
250
+ }
251
+ }
137
252
 
138
- // Batch complete: Queue updated hooks efficiently
139
- batch.forEach(el => pendingUpdated.add(el));
140
- scheduleUpdatedHooks();
141
- isProcessing = false;
253
+ return evicted;
254
+ };
255
+
256
+ const scheduleSave = (key, value, debounce) => {
257
+ if (dbTimers.has(key)) clearTimeout(dbTimers.get(key));
258
+ const save = () => {
259
+ try {
260
+ const serialized = JSON.stringify(value);
261
+ const sizeBytes = new Blob([serialized]).size;
262
+
263
+ // Attempt write
264
+ localStorage.setItem(key, serialized);
265
+
266
+ // Track for eviction (prefix with 'lego:' for identification)
267
+ const storageKey = key.startsWith('lego:') ? key : `lego:${key}`;
268
+ storageKeys.set(storageKey, {
269
+ timestamp: Date.now(),
270
+ size: sizeBytes
142
271
  });
272
+
273
+ dbTimers.delete(key);
274
+ } catch (e) {
275
+ if (e.name === 'QuotaExceededError') {
276
+ console.warn(`[Lego] Storage quota exceeded for key: ${key}`);
277
+
278
+ // Attempt eviction and retry
279
+ try {
280
+ const serialized = JSON.stringify(value);
281
+ const sizeBytes = new Blob([serialized]).size;
282
+ const evicted = evictOldestKeys(sizeBytes * 2); // Free 2x needed space
283
+
284
+ if (evicted.length > 0) {
285
+ console.log(`[Lego] Evicted ${evicted.length} old keys, retrying save`);
286
+ localStorage.setItem(key, serialized);
287
+
288
+ const storageKey = key.startsWith('lego:') ? key : `lego:${key}`;
289
+ storageKeys.set(storageKey, {
290
+ timestamp: Date.now(),
291
+ size: sizeBytes
292
+ });
293
+ } else {
294
+ config.onError(new Error(`Storage quota exceeded and no keys available for eviction`), 'quota', key);
295
+ }
296
+ } catch (retryErr) {
297
+ config.onError(new Error(`Critical: Could not save ${key} even after eviction`), 'quota-critical', key);
298
+ }
299
+ } else {
300
+ console.error(`[Lego] Storage Error (${key}):`, e);
301
+ }
143
302
  }
144
303
  };
304
+ if (debounce > 0) {
305
+ dbTimers.set(key, setTimeout(save, debounce));
306
+ } else {
307
+ save();
308
+ }
145
309
  };
146
310
 
147
- const globalBatcher = createBatcher();
311
+ // Cross-tab Synchronization
312
+ if (typeof window !== 'undefined') {
313
+ window.addEventListener('storage', (e) => {
314
+ if (!e.key || !dbBindings.has(e.key)) return;
315
+ try {
316
+ const newValue = JSON.parse(e.newValue);
317
+ dbBindings.get(e.key).forEach(({ target, prop, el, batcher }) => {
318
+ target[prop] = newValue; // Update raw data
319
+ if (el && batcher) batcher.add(el); // Trigger re-render
320
+ });
321
+ } catch (e) { }
322
+ });
323
+ }
324
+
325
+ const dbMetadata = new WeakMap(); // target -> { prop: { key, debounce } }
326
+
327
+ const isNode = (val) => typeof Node !== 'undefined' && val instanceof Node;
148
328
 
149
329
  const reactive = (obj, el, batcher = globalBatcher) => {
150
- if (obj === null || typeof obj !== 'object' || obj instanceof Node) return obj;
330
+ if (obj === null || typeof obj !== 'object' || isNode(obj)) return obj;
151
331
  if (proxyCache.has(obj)) return proxyCache.get(obj);
152
332
 
333
+ // Hydrate $db descriptors
334
+ for (const k in obj) {
335
+ const desc = obj[k];
336
+ if (desc && desc.__type === 'lego-db') {
337
+ let val = desc._default;
338
+ try {
339
+ const item = localStorage.getItem(desc.key);
340
+ if (item !== null) val = JSON.parse(item);
341
+ } catch (e) { }
342
+
343
+ obj[k] = val; // Replace descriptor with value
344
+
345
+ // Register metadata
346
+ if (!dbMetadata.has(obj)) dbMetadata.set(obj, {});
347
+ dbMetadata.get(obj)[k] = { key: desc.key, debounce: desc._debounce };
348
+
349
+ // Register for cross-tab updates
350
+ if (!dbBindings.has(desc.key)) dbBindings.set(desc.key, new Set());
351
+ dbBindings.get(desc.key).add({ target: obj, prop: k, el, batcher });
352
+ }
353
+ }
354
+
153
355
  const handler = {
154
356
  get: (t, k) => {
155
357
  const val = Reflect.get(t, k);
156
- if (val !== null && typeof val === 'object' && !(val instanceof Node)) {
358
+ if (val !== null && typeof val === 'object' && !isNode(val)) {
157
359
  return reactive(val, el, batcher);
158
360
  }
159
361
  return val;
@@ -161,7 +363,14 @@ const Lego = (() => {
161
363
  set: (t, k, v) => {
162
364
  const old = t[k];
163
365
  const r = Reflect.set(t, k, v);
164
- if (old !== v) batcher.add(el);
366
+ if (old !== v) {
367
+ batcher.add(el);
368
+ // Check for DB binding
369
+ const meta = dbMetadata.get(t);
370
+ if (meta && meta[k]) {
371
+ scheduleSave(meta[k].key, v, meta[k].debounce);
372
+ }
373
+ }
165
374
  return r;
166
375
  },
167
376
  deleteProperty: (t, k) => {
@@ -618,15 +827,69 @@ const Lego = (() => {
618
827
  };
619
828
 
620
829
  const unsnap = (el) => {
830
+ // 1. Call unmounted lifecycle hook first
621
831
  if (el._studs && typeof el._studs.unmounted === 'function') {
622
832
  try { el._studs.unmounted.call(el._studs); } catch (e) { console.error(`[Lego] Error in unmounted:`, e); }
623
833
  }
624
834
 
835
+ // 2. Recursively unsnap children in shadowRoot
625
836
  if (el.shadowRoot) {
626
837
  [...el.shadowRoot.children].forEach(unsnap);
627
838
  }
628
839
 
840
+ // 3. Remove from active blocks tracking
629
841
  activeBlocks.delete(el);
842
+
843
+ // 4. Break circular references
844
+ if (el._studs) {
845
+ // Clear element references in state object
846
+ el._studs.$element = null;
847
+
848
+ // Clear $vars references to DOM elements
849
+ if (el._studs.$vars) {
850
+ Object.keys(el._studs.$vars).forEach(key => {
851
+ el._studs.$vars[key] = null;
852
+ });
853
+ el._studs.$vars = null;
854
+ }
855
+
856
+ // Break the proxy -> element reference
857
+ el._studs = null;
858
+ }
859
+
860
+ if (el.hasOwnProperty('state')) delete el.state;
861
+ // 6. Clear private data (bindings, flags, etc.)
862
+ // This removes references to DOM nodes in bindings array
863
+ const data = privateData.get(el);
864
+ if (data) {
865
+ // Clear bindings that hold node references
866
+ if (data.bindings) {
867
+ data.bindings.forEach(binding => {
868
+ binding.node = null;
869
+ binding.anchor = null;
870
+ });
871
+ data.bindings = null;
872
+ }
873
+ data.anchor = null;
874
+ privateData.delete(el);
875
+ }
876
+
877
+ // 7. Clear from for-loop pools (prevents pooled nodes from keeping refs)
878
+ if (forPools.has(el)) {
879
+ const pool = forPools.get(el);
880
+ pool.forEach((node, key) => {
881
+ if (node && node._studs) {
882
+ node._studs = null;
883
+ }
884
+ });
885
+ pool.clear();
886
+ forPools.delete(el);
887
+ }
888
+
889
+ // 8. Note: proxyCache is a WeakMap so we can't delete from it,
890
+ // but breaking the circular refs above allows GC to clean it up
891
+
892
+ // 9. Recursively unsnap light DOM children
630
893
  [...el.children].forEach(unsnap);
631
894
  };
632
895
 
@@ -665,14 +928,25 @@ const Lego = (() => {
665
928
 
666
929
  resolvedElements.forEach(el => {
667
930
  if (el) {
668
- const component = document.createElement(match.tagName);
931
+ const block = document.createElement(match.tagName);
669
932
  // Atomic swap: MutationObserver in init() will pick this up
670
- el.replaceChildren(component);
933
+ el.replaceChildren(block);
671
934
  }
672
935
  });
673
936
  };
674
937
 
938
+ // $db Factory
939
+ const dbFactory = (key) => ({
940
+ __type: 'lego-db',
941
+ key,
942
+ _default: undefined,
943
+ _debounce: 0,
944
+ default(v) { this._default = v; return this; },
945
+ debounce(ms) { this._debounce = ms; return this; }
946
+ });
947
+
675
948
  const publicAPI = {
949
+ db: dbFactory,
676
950
  snap,
677
951
  unsnap,
678
952
  init: async (root = document.body, options = {}) => {
@@ -740,7 +1014,7 @@ const Lego = (() => {
740
1014
  document.head.appendChild(script);
741
1015
  }
742
1016
  Lego.route('/_/studio', 'lego-studio');
743
- Lego.route('/_/studio/:component', 'lego-studio');
1017
+ Lego.route('/_/studio/:block', 'lego-studio');
744
1018
  }
745
1019
 
746
1020
  if (routes.length > 0) {
@@ -772,15 +1046,16 @@ const Lego = (() => {
772
1046
  },
773
1047
  globals: reactive({
774
1048
  $route: {
775
- url: window.location.pathname,
1049
+ url: typeof window !== 'undefined' ? window.location.pathname : '/',
776
1050
  route: '',
777
1051
  params: {},
778
1052
  query: {},
779
1053
  method: 'GET',
780
1054
  body: null
781
1055
  },
782
- $go: (path, ...targets) => _go(path, ...targets)(document.body)
783
- }, document.body),
1056
+ $go: (path, ...targets) => _go(path, ...targets)(document.body),
1057
+ $db: dbFactory
1058
+ }, typeof document !== 'undefined' ? document.body : null),
784
1059
  defineLegoFile: (content, filename = 'block.lego') => {
785
1060
  let template = '';
786
1061
  let script = '{}';
@@ -828,8 +1103,7 @@ const Lego = (() => {
828
1103
  }
829
1104
 
830
1105
  const name = deriveBlockName(filename);
831
- // We must eval the script to get the object.
832
- // Safe-ish because it's coming from the "Server" (trusted source in this architecture)
1106
+ // Inject $db helper into the scope
833
1107
  const logicObj = new Function(`return ${script}`)();
834
1108
 
835
1109
  if (style) {
@@ -883,4 +1157,8 @@ const Lego = (() => {
883
1157
 
884
1158
  if (typeof window !== 'undefined') {
885
1159
  window.Lego = Lego;
1160
+ } else if (typeof global !== 'undefined') {
1161
+ global.Lego = Lego;
886
1162
  }
1163
+
1164
+
@@ -9,7 +9,7 @@ export const Monitoring = {
9
9
  renders: 0,
10
10
  errors: 0,
11
11
  slowRenders: 0,
12
- components: new Map(), // tagName -> { count, avgTime }
12
+ blocks: new Map(), // tagName -> { count, avgTime }
13
13
  },
14
14
 
15
15
  // Configuration options
@@ -83,7 +83,7 @@ export const Monitoring = {
83
83
  this.metrics.renders = 0;
84
84
  this.metrics.errors = 0;
85
85
  this.metrics.slowRenders = 0;
86
- this.metrics.components.clear();
86
+ this.metrics.blocks.clear();
87
87
  }
88
88
  };
89
89
 
@@ -99,11 +99,11 @@ export const Monitoring = {
99
99
  }
100
100
  }
101
101
 
102
- if (!this.metrics.components.has(tagName)) {
103
- this.metrics.components.set(tagName, { count: 0, totalTime: 0, avg: 0 });
102
+ if (!this.metrics.blocks.has(tagName)) {
103
+ this.metrics.blocks.set(tagName, { count: 0, totalTime: 0, avg: 0 });
104
104
  }
105
105
 
106
- const stats = this.metrics.components.get(tagName);
106
+ const stats = this.metrics.blocks.get(tagName);
107
107
  stats.count++;
108
108
  stats.totalTime += duration;
109
109
  stats.avg = stats.totalTime / stats.count;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lego-dom",
3
- "version": "2.0.2",
3
+ "version": "2.0.4-alpha",
4
4
  "license": "MIT",
5
5
  "description": "A feature-rich web components + SFC frontend framework",
6
6
  "main": "main.js",