shablon 0.0.1-rc.4 → 0.0.1-rc.6

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/README.md CHANGED
@@ -6,7 +6,7 @@ Shablon - No-build JavaScript frontend framework
6
6
  >
7
7
  > **Don't use it yet - it hasn't been actually tested in real applications and it may change without notice!**
8
8
 
9
- **Shablon** _("template" in Bulgarian)_ is a ~5KB JS framework that comes with deeply reactive state management, plain JS extendable templates and hash-based router.
9
+ **Shablon** _("template" in Bulgarian)_ is a ~6KB JS framework that comes with deeply reactive state management, plain JS extendable templates and hash-based router.
10
10
 
11
11
  Shablon has very small learning curve (**4 main exported functions**) and it is suitable for building Single-page applications (SPA):
12
12
 
@@ -162,8 +162,8 @@ data.age++
162
162
  data.activity = "rest"
163
163
  ```
164
164
 
165
- > Note that Object values like `Date`, `Set`, `Map`, `WeakRef`, `WeakSet` and `WeakMap` values are not wrapped in a nested `Proxy` and they will be resolved as they are to avoid access errors.
166
- > For other custom object types thay you may want to access without a `Proxy` you can use the special `__raw` key, e.g. `data.myCustomType.__raw.someKey`.
165
+ > Note that only plain objects and arrays are wrapped in a nested `Proxy`! `Date`, `Set`, `Map`, `WeakRef`, `WeakSet`, `WeakMap` or any custom object will be resolved as they are to avoid access errors.
166
+ > You can access the original object without the Proxy trap using the special `__raw` key, e.g. `data.someObj.__raw.someKey`.
167
167
 
168
168
  </details>
169
169
 
@@ -209,7 +209,7 @@ const data = store({
209
209
  watch(() => [
210
210
  data.a,
211
211
  data.b,
212
- ], () => {
212
+ ], (_) => { // receive the return result of trackedFunc
213
213
  console.log(data.a)
214
214
  console.log(data.b)
215
215
  console.log(data.c)
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.1-rc.4",
2
+ "version": "0.0.1-rc.6",
3
3
  "name": "shablon",
4
4
  "description": "No-build JavaScript framework for Single-page applications",
5
5
  "author": "Gani Georgiev",
package/src/router.js CHANGED
@@ -49,7 +49,7 @@ export function router(routes, options = { fallbackPath: "#/", transition: true
49
49
  if (!route) {
50
50
  if (options.fallbackPath != path) {
51
51
  window.location.hash = options.fallbackPath;
52
- return
52
+ return;
53
53
  }
54
54
 
55
55
  console.warn("missing route:", path);
@@ -120,7 +120,8 @@ function prepareRoutes(routes) {
120
120
  parts[i].endsWith("}")
121
121
  ) {
122
122
  // param
123
- parts[i] = "(?<" + parts[i].substring(1, parts[i].length - 1) + ">[^\\/#?]+)";
123
+ parts[i] =
124
+ "(?<" + parts[i].substring(1, parts[i].length - 1) + ">[^\\/#?]+)";
124
125
  } else {
125
126
  // regular path segment
126
127
  parts[i] = RegExp.escape(parts[i]);
package/src/state.js CHANGED
@@ -7,11 +7,14 @@ let cleanTimeoutId;
7
7
 
8
8
  let idSym = Symbol();
9
9
  let parentSym = Symbol();
10
- let pathsResetedSym = Symbol();
11
10
  let childrenSym = Symbol();
12
11
  let pathsSubsSym = Symbol();
13
12
  let unwatchedSym = Symbol();
14
13
  let onRemoveSym = Symbol();
14
+ let skipSym = Symbol();
15
+ let evictedSym = Symbol();
16
+
17
+ let pathSeparator = "/";
15
18
 
16
19
  /**
17
20
  * Watch registers a callback function that fires on initialization and
@@ -68,7 +71,7 @@ let onRemoveSym = Symbol();
68
71
  export function watch(trackedFunc, optUntrackedFunc) {
69
72
  let watcher = {
70
73
  [idSym]: "_" + Math.random(),
71
- }
74
+ };
72
75
 
73
76
  allWatchers.set(watcher[idSym], watcher);
74
77
 
@@ -77,7 +80,7 @@ export function watch(trackedFunc, optUntrackedFunc) {
77
80
 
78
81
  // nested watcher -> register previous watcher as parent
79
82
  if (activeWatcher) {
80
- oldActiveWatcher = activeWatcher
83
+ oldActiveWatcher = activeWatcher;
81
84
  watcher[parentSym] = activeWatcher[idSym];
82
85
 
83
86
  // store immediate children references for quicker cleanup
@@ -85,12 +88,44 @@ export function watch(trackedFunc, optUntrackedFunc) {
85
88
  activeWatcher[childrenSym].push(watcher[idSym]);
86
89
  }
87
90
 
91
+ // On watcher function run, resets any previous tracking paths
92
+ // because after this new run some of the old dependencies
93
+ // may no longer be reachable/evaluatable.
94
+ //
95
+ // For example, in the below code:
96
+ //
97
+ // ```js
98
+ // const data = store({ a: 0, b: 0, c: 0 })
99
+ //
100
+ // watch(() => {
101
+ // if (data.a > 0) {
102
+ // data.b
103
+ // } else {
104
+ // data.c
105
+ // }
106
+ // })
107
+ // ```
108
+ //
109
+ // initially ONLY "a" and "c" should be trackable because "b"
110
+ // is not reachable (aka. its getter is never invoked).
111
+ //
112
+ // If we increment `a++`, then in the new run ONLY "a" and "b" should be trackable
113
+ // because this time "c" is not reachable (aka. its getter is never invoked)
114
+ // and its previous tracking should be removed for this watcher.
115
+ //
116
+ // Note: The below code works because it reuses the same "subs" reference as in pathWatcherIds
117
+ // and this is intentional to avoid unnecessary iterations.
118
+ watcher[pathsSubsSym]?.forEach((subs) => {
119
+ subs.delete(watcher[idSym]);
120
+ });
121
+
88
122
  activeWatcher = watcher;
89
- activeWatcher[pathsResetedSym] = false;
90
- watcher.last = trackedFunc();
123
+ const result = trackedFunc();
91
124
 
92
- activeWatcher = null;
93
- optUntrackedFunc?.();
125
+ if (optUntrackedFunc) {
126
+ activeWatcher = null;
127
+ optUntrackedFunc(result);
128
+ }
94
129
 
95
130
  // restore original ref (if any)
96
131
  activeWatcher = oldActiveWatcher;
@@ -135,10 +170,8 @@ function removeWatcher(id) {
135
170
  }
136
171
 
137
172
  if (w?.[pathsSubsSym]) {
138
- for (let subset of w[pathsSubsSym]) {
139
- if (subset.has(id)) {
140
- subset.delete(id);
141
- }
173
+ for (let sub of w[pathsSubsSym]) {
174
+ sub.delete(id);
142
175
  }
143
176
  w[pathsSubsSym] = null;
144
177
  }
@@ -185,9 +218,13 @@ function createProxy(obj, pathWatcherIds) {
185
218
  ? Object.getOwnPropertyDescriptors(obj)
186
219
  : {};
187
220
 
188
- let handler = {
189
- get(obj, prop, target) {
190
- if (prop === "__raw") {
221
+ return new Proxy(obj, {
222
+ get(obj, prop, receiver) {
223
+ if (typeof prop == "symbol") {
224
+ return obj[prop];
225
+ }
226
+
227
+ if (prop == "__raw") {
191
228
  return obj;
192
229
  }
193
230
 
@@ -202,26 +239,79 @@ function createProxy(obj, pathWatcherIds) {
202
239
 
203
240
  getterProp = prop;
204
241
 
205
- // replace with an internal "@prop" property so that
206
- // reactive statements can be cached
207
- prop = "@" + prop;
242
+ // replace with an internal property so that reactive statements can be cached
243
+ prop = "@@" + prop;
244
+ Object.defineProperty(obj, prop, { writable: true, enumerable: false });
245
+ }
246
+
247
+ // evicted child?
248
+ if (!obj[skipSym] && obj[parentSym]) {
249
+ let props = [];
250
+ let activeObj = obj;
251
+
252
+ let isEvicted = false;
253
+
254
+ // travel up to the root proxy
255
+ // (aka. x.a.b*.c -> x)
256
+ while (activeObj?.[parentSym]) {
257
+ if (activeObj[evictedSym]) {
258
+ isEvicted = true;
259
+ }
260
+
261
+ props.push(activeObj[parentSym][1]);
262
+ activeObj = activeObj[parentSym][0];
263
+ }
264
+
265
+ // try to access the original path but this time from
266
+ // the root point of view to ensure that we are always accessing
267
+ // an up-to-date store child reference
268
+ // (we want: x.a.b(old).c -> x -> x.a.b(new).c)
269
+ //
270
+ // note: this technically could "leak" but for our case it should be fine
271
+ // because the evicted object will become again garbage collectable
272
+ // once the related watcher(s) are removed
273
+ if (isEvicted) {
274
+ for (let i = props.length - 1; i >= 0; i--) {
275
+ activeObj[skipSym] = true;
276
+ let item = activeObj?.[props[i]];
277
+ activeObj[skipSym] = false;
278
+
279
+ if (i == 0) {
280
+ activeObj = item?.__raw;
281
+ } else {
282
+ activeObj = item;
283
+ }
284
+ }
285
+
286
+ // the original full nested path is no longer available (null/undefined)
287
+ if (activeObj == undefined) {
288
+ return activeObj;
289
+ }
290
+
291
+ // update the current obj with the one from the retraced path
292
+ obj = activeObj;
293
+ }
208
294
  }
209
295
 
210
- const propVal = obj[prop]
296
+ let propVal = obj[prop];
211
297
 
212
- // directly return symbols and functions (pop, push, etc.)
213
- if (typeof prop == "symbol" || typeof propVal == "function") {
298
+ // directly return for functions (pop, push, etc.)
299
+ if (typeof propVal == "function") {
214
300
  return propVal;
215
301
  }
216
302
 
217
- // wrap child object or array as sub store
303
+ // wrap child plain object or array as sub store
218
304
  if (
219
- propVal !== null && typeof propVal == "object" &&
305
+ propVal != null &&
306
+ typeof propVal == "object" &&
220
307
  !propVal[parentSym] &&
221
- !isExcludedInstance(propVal)
308
+ (propVal.constructor?.name == "Object" ||
309
+ propVal.constructor?.name == "Array" ||
310
+ propVal.constructor?.name == undefined) // e.g. Object.create(null)
222
311
  ) {
223
- propVal[parentSym] = [obj, prop];
224
- obj[prop] = createProxy(propVal, pathWatcherIds);
312
+ propVal[parentSym] = [receiver, prop];
313
+ propVal = createProxy(propVal, pathWatcherIds);
314
+ obj[prop] = propVal;
225
315
  }
226
316
 
227
317
  // register watch subscriber (if any)
@@ -229,52 +319,36 @@ function createProxy(obj, pathWatcherIds) {
229
319
  let currentPath = getPath(obj, prop);
230
320
  let activeWatcherId = activeWatcher[idSym];
231
321
 
322
+ let propPaths = [currentPath];
323
+
324
+ // always construct all parent paths ("x.a.b.c" => ["a", "a.b", "a.b.c"])
325
+ // because a store child object can be passed as argument to a function
326
+ // and in that case the parents proxy get trap will not be invoked,
327
+ // and their path will not be registered
328
+ if (obj[parentSym]) {
329
+ let parts = currentPath.split(pathSeparator);
330
+ while (parts.pop() && parts.length) {
331
+ propPaths.push(parts.join(pathSeparator));
332
+ }
333
+ }
334
+
335
+ // initialize a watcher paths tracking set (if not already)
232
336
  activeWatcher[pathsSubsSym] = activeWatcher[pathsSubsSym] || new Set();
233
337
 
234
- // If this is a rerun of the watcher function, resets any previous
235
- // tracking paths because after this new run some of the old
236
- // dependencies may no longer be reachable/evaluatable.
237
- //
238
- // For example, in the below code:
239
- //
240
- // ```js
241
- // const data = store({ a: 0, b: 0, c: 0 })
242
- //
243
- // watch(() => {
244
- // if (data.a > 0) {
245
- // data.b
246
- // } else {
247
- // data.c
248
- // }
249
- // })
250
- // ```
251
- //
252
- // initially ONLY "a" and "c" should be trackable because "b"
253
- // is not reachable (aka. its getter is never invoked).
254
- //
255
- // If we increment `a++`, then in the new run ONLY "a" and "b" should be trackable
256
- // because this time "c" is not reachable (aka. its getter is never invoked)
257
- // and its previous tracking should be removed for this watcher.
258
- //
259
- // Note: The below code works because it reuses the same "subs" reference as in pathWatcherIds
260
- // and this is intentional to avoid unnecessary iterations.
261
- if (!activeWatcher[pathsResetedSym]) {
262
- activeWatcher[pathsSubsSym].forEach((subs) => {
263
- subs.delete(activeWatcherId)
264
- })
265
- activeWatcher[pathsResetedSym] = true;
266
- }
338
+ // register the paths to watch
339
+ for (let path of propPaths) {
340
+ let subs = pathWatcherIds.get(path);
341
+ if (!subs) {
342
+ subs = new Set();
343
+ pathWatcherIds.set(path, subs);
344
+ }
267
345
 
268
- let subs = pathWatcherIds.get(currentPath);
269
- if (!subs) {
270
- subs = new Set();
271
- pathWatcherIds.set(currentPath, subs);
272
- }
273
- subs.add(activeWatcherId);
346
+ subs.add(activeWatcherId);
274
347
 
275
- activeWatcher[pathsSubsSym].add(subs);
348
+ activeWatcher[pathsSubsSym].add(subs);
349
+ }
276
350
 
277
- // register a child watcher to update the custom getter prop replacement
351
+ // register an extra child watcher to update the custom getter prop replacement
278
352
  // (should be removed automatically with the removal of the parent watcher)
279
353
  if (
280
354
  getterProp &&
@@ -286,15 +360,18 @@ function createProxy(obj, pathWatcherIds) {
286
360
 
287
361
  let getFunc = descriptors[getterProp].get.bind(obj);
288
362
 
289
- let getWatcher = watch(() => (target[prop] = getFunc()));
363
+ let getWatcher = watch(getFunc, (result) => (receiver[prop] = result));
290
364
 
291
365
  getWatcher[onRemoveSym] = () => {
292
366
  descriptors[getterProp]?.watchers?.delete(watcherId);
293
367
  };
368
+
369
+ // update with the cached get value after the above watch initialization
370
+ propVal = obj[prop]
294
371
  }
295
372
  }
296
373
 
297
- return obj[prop];
374
+ return propVal;
298
375
  },
299
376
  set(obj, prop, value) {
300
377
  if (typeof prop == "symbol") {
@@ -303,6 +380,17 @@ function createProxy(obj, pathWatcherIds) {
303
380
  }
304
381
 
305
382
  let oldValue = obj[prop];
383
+
384
+ // mark as "evicted" in case a proxy child object/array is being replaced
385
+ if (oldValue?.[parentSym]) {
386
+ oldValue[evictedSym] = true;
387
+ }
388
+
389
+ // update the stored parent reference in case of index change (e.g. unshift)
390
+ if (value?.[parentSym] && Array.isArray(obj) && !isNaN(prop)) {
391
+ value[parentSym][1] = prop;
392
+ }
393
+
306
394
  obj[prop] = value;
307
395
 
308
396
  // trigger only on value change
@@ -318,18 +406,22 @@ function createProxy(obj, pathWatcherIds) {
318
406
  callWatchers(obj, prop, pathWatcherIds);
319
407
 
320
408
  let currentPath = getPath(obj, prop);
321
- if (pathWatcherIds.has(currentPath)) {
322
- pathWatcherIds.delete(currentPath);
409
+
410
+ for (const item of pathWatcherIds) {
411
+ if (
412
+ // exact match
413
+ item[0] == currentPath ||
414
+ // child path
415
+ item[0].startsWith(currentPath + pathSeparator)
416
+ ) {
417
+ pathWatcherIds.delete(item[0]);
418
+ }
323
419
  }
324
420
  }
325
421
 
326
- delete obj[prop];
327
-
328
- return true;
422
+ return delete obj[prop];
329
423
  },
330
- };
331
-
332
- return new Proxy(obj, handler);
424
+ });
333
425
  }
334
426
 
335
427
  function getPath(obj, prop) {
@@ -337,24 +429,13 @@ function getPath(obj, prop) {
337
429
 
338
430
  let parentData = obj?.[parentSym];
339
431
  while (parentData) {
340
- currentPath = parentData[1] + "." + currentPath;
432
+ currentPath = parentData[1] + pathSeparator + currentPath;
341
433
  parentData = parentData[0][parentSym];
342
434
  }
343
435
 
344
436
  return currentPath;
345
437
  }
346
438
 
347
- function isExcludedInstance(val) {
348
- return (
349
- (val instanceof Date) ||
350
- (val instanceof Set) ||
351
- (val instanceof Map) ||
352
- (val instanceof WeakRef) ||
353
- (val instanceof WeakMap) ||
354
- (val instanceof WeakSet)
355
- )
356
- }
357
-
358
439
  function callWatchers(obj, prop, pathWatcherIds) {
359
440
  let currentPath = getPath(obj, prop);
360
441
 
package/src/template.js CHANGED
@@ -97,7 +97,9 @@ function tag(tagName, attrs = {}, ...children) {
97
97
  attr = attr.substring(5);
98
98
  }
99
99
 
100
- if (
100
+ if (typeof val === "undefined") {
101
+ el.removeAttribute(attr);
102
+ } else if (
101
103
  // JS property or regular HTML attribute
102
104
  typeof val != "function" ||
103
105
  // event
@@ -192,6 +194,8 @@ function setChildren(el, children) {
192
194
  }
193
195
  }
194
196
 
197
+ // Note: Direct nested reactive functions or direct nested arrays are not supported,
198
+ // aka. childrenFunc must return a single element or plain array of elements.
195
199
  function initChildrenFuncWatcher(el, childrenFunc) {
196
200
  let endPlaceholder = document.createComment("");
197
201
  el.appendChild(endPlaceholder);
@@ -398,7 +402,11 @@ function toArray(val) {
398
402
 
399
403
  function normalizeNode(child) {
400
404
  // wrap as TextNode so that it can be "tracked" and used with appendChild or other similar methods
401
- if (typeof child == "string" || typeof child == "number" || typeof child == "boolean") {
405
+ if (
406
+ typeof child == "string" ||
407
+ typeof child == "number" ||
408
+ typeof child == "boolean"
409
+ ) {
402
410
  let childNode = document.createTextNode(child);
403
411
  childNode.rid = child;
404
412
  return childNode;
@@ -406,7 +414,7 @@ function normalizeNode(child) {
406
414
 
407
415
  // in case child is DOM Proxy element/array loaded from a store object
408
416
  if (typeof child?.__raw != "undefined") {
409
- return child.__raw
417
+ return child.__raw;
410
418
  }
411
419
 
412
420
  return child;