shablon 0.0.1-rc.3 → 0.0.1-rc.5

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
 
@@ -142,7 +142,7 @@ importing it from [npm](https://www.npmjs.com/package/shablon).
142
142
 
143
143
  The keys of an `obj` must be "stringifiable" because they are used internally to construct a path to the reactive value.
144
144
 
145
- The values can be any valid JS value, including nested arrays and objects (aka. it is recursively reactive).
145
+ The values can be any valid JS primitive value, including nested plain arrays and objects (aka. it is recursively reactive).
146
146
 
147
147
  Getters are also supported and can be used as reactive computed properties.
148
148
  The value of a reactive getter is "cached", meaning that even if one of the getter dependency changes, as long as the resulting value is the same there will be no unnecessary watch events fired.
@@ -162,6 +162,9 @@ data.age++
162
162
  data.activity = "rest"
163
163
  ```
164
164
 
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
+
165
168
  </details>
166
169
 
167
170
 
@@ -171,6 +174,8 @@ data.activity = "rest"
171
174
  Watch registers a callback function that fires on initialization and
172
175
  every time any of its evaluated `store` reactive properties change.
173
176
 
177
+ Note that for reactive getters, initially the watch `trackedFunc` will be invoked twice because we register a second internal watcher to cache the getter value.
178
+
174
179
  It returns a "watcher" object that could be used to `unwatch()` the registered listener.
175
180
 
176
181
  _Optionally also accepts a second callback function that is excluded from the evaluated
@@ -204,7 +209,7 @@ const data = store({
204
209
  watch(() => [
205
210
  data.a,
206
211
  data.b,
207
- ], () => {
212
+ ], (_) => { // receive the return result of trackedFunc
208
213
  console.log(data.a)
209
214
  console.log(data.b)
210
215
  console.log(data.c)
@@ -215,8 +220,6 @@ data.b++ // trigger watch update
215
220
  data.c++ // doesn't trigger watch update
216
221
  ```
217
222
 
218
- Note that for reactive getters, initially the watch trackCallback will be invoked twice because we register a second internal watcher to cache the getter value.
219
-
220
223
  </details>
221
224
 
222
225
 
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.1-rc.3",
2
+ "version": "0.0.1-rc.5",
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,12 +218,65 @@ 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
 
231
+ // evicted child?
232
+ if (!obj[skipSym] && obj[parentSym]) {
233
+ let props = [];
234
+ let activeObj = obj;
235
+
236
+ let isEvicted = false;
237
+
238
+ // travel up to the root proxy
239
+ // (aka. x.a.b*.c -> x)
240
+ while (activeObj?.[parentSym]) {
241
+ if (activeObj[evictedSym]) {
242
+ isEvicted = true;
243
+ }
244
+
245
+ props.push(activeObj[parentSym][1]);
246
+ activeObj = activeObj[parentSym][0];
247
+ }
248
+
249
+ // try to access the original path but this time from
250
+ // the root point of view to ensure that we are always accessing
251
+ // an up-to-date store child reference
252
+ // (we want: x.a.b(old).c -> x -> x.a.b(new).c)
253
+ //
254
+ // note: this technically could "leak" but for our case it should be fine
255
+ // because the evicted object will become again garbage collectable
256
+ // once the related watcher(s) are removed
257
+ if (isEvicted) {
258
+ for (let i = props.length - 1; i >= 0; i--) {
259
+ activeObj[skipSym] = true;
260
+ let item = activeObj?.[props[i]];
261
+ activeObj[skipSym] = false;
262
+
263
+ if (i == 0) {
264
+ activeObj = item?.__raw;
265
+ } else {
266
+ activeObj = item;
267
+ }
268
+ }
269
+
270
+ // the original full nested path is no longer available (null/undefined)
271
+ if (activeObj == undefined) {
272
+ return activeObj;
273
+ }
274
+
275
+ // update the current obj with the one from the retraced path
276
+ obj = activeObj;
277
+ }
278
+ }
279
+
194
280
  // getter?
195
281
  let getterProp;
196
282
  if (descriptors[prop]?.get) {
@@ -202,24 +288,30 @@ function createProxy(obj, pathWatcherIds) {
202
288
 
203
289
  getterProp = prop;
204
290
 
205
- // replace with an internal "@prop" property so that
291
+ // replace with an internal "@@prop" property so that
206
292
  // reactive statements can be cached
207
- prop = "@" + prop;
293
+ prop = "@@" + prop;
208
294
  }
209
295
 
210
- // directly return symbols and functions (pop, push, etc.)
211
- if (typeof prop == "symbol" || typeof obj[prop] == "function") {
212
- return obj[prop];
296
+ let propVal = obj[prop];
297
+
298
+ // directly return for functions (pop, push, etc.)
299
+ if (typeof propVal == "function") {
300
+ return propVal;
213
301
  }
214
302
 
215
- // wrap child object or array as sub store
303
+ // wrap child plain object or array as sub store
216
304
  if (
217
- typeof obj[prop] == "object" &&
218
- obj[prop] !== null &&
219
- !obj[prop][parentSym]
305
+ propVal != null &&
306
+ typeof propVal == "object" &&
307
+ !propVal[parentSym] &&
308
+ (propVal.constructor?.name == "Object" ||
309
+ propVal.constructor?.name == "Array" ||
310
+ propVal.constructor?.name == undefined) // e.g. Object.create(null)
220
311
  ) {
221
- obj[prop][parentSym] = [obj, prop];
222
- obj[prop] = createProxy(obj[prop], pathWatcherIds);
312
+ propVal[parentSym] = [receiver, prop];
313
+ propVal = createProxy(propVal, pathWatcherIds);
314
+ obj[prop] = propVal;
223
315
  }
224
316
 
225
317
  // register watch subscriber (if any)
@@ -227,52 +319,36 @@ function createProxy(obj, pathWatcherIds) {
227
319
  let currentPath = getPath(obj, prop);
228
320
  let activeWatcherId = activeWatcher[idSym];
229
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)
230
336
  activeWatcher[pathsSubsSym] = activeWatcher[pathsSubsSym] || new Set();
231
337
 
232
- // If this is a rerun of the watcher function, resets any previous
233
- // tracking paths because after this new run some of the old
234
- // dependencies may no longer be reachable/evaluatable.
235
- //
236
- // For example, in the below code:
237
- //
238
- // ```js
239
- // const data = store({ a: 0, b: 0, c: 0 })
240
- //
241
- // watch(() => {
242
- // if (data.a > 0) {
243
- // data.b
244
- // } else {
245
- // data.c
246
- // }
247
- // })
248
- // ```
249
- //
250
- // initially ONLY "a" and "c" should be trackable because "b"
251
- // is not reachable (aka. its getter is never invoked).
252
- //
253
- // If we increment `a++`, then in the new run ONLY "a" and "b" should be trackable
254
- // because this time "c" is not reachable (aka. its getter is never invoked)
255
- // and its previous tracking should be removed for this watcher.
256
- //
257
- // Note: The below code works because it reuses the same "subs" reference as in pathWatcherIds
258
- // and this is intentional to avoid unnecessary iterations.
259
- if (!activeWatcher[pathsResetedSym]) {
260
- activeWatcher[pathsSubsSym].forEach((subs) => {
261
- subs.delete(activeWatcherId)
262
- })
263
- activeWatcher[pathsResetedSym] = true;
264
- }
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
+ }
265
345
 
266
- let subs = pathWatcherIds.get(currentPath);
267
- if (!subs) {
268
- subs = new Set();
269
- pathWatcherIds.set(currentPath, subs);
270
- }
271
- subs.add(activeWatcherId);
346
+ subs.add(activeWatcherId);
272
347
 
273
- activeWatcher[pathsSubsSym].add(subs);
348
+ activeWatcher[pathsSubsSym].add(subs);
349
+ }
274
350
 
275
- // register a child watcher to update the custom getter prop replacement
351
+ // register an extra child watcher to update the custom getter prop replacement
276
352
  // (should be removed automatically with the removal of the parent watcher)
277
353
  if (
278
354
  getterProp &&
@@ -284,7 +360,7 @@ function createProxy(obj, pathWatcherIds) {
284
360
 
285
361
  let getFunc = descriptors[getterProp].get.bind(obj);
286
362
 
287
- let getWatcher = watch(() => (target[prop] = getFunc()));
363
+ let getWatcher = watch(() => (receiver[prop] = getFunc()));
288
364
 
289
365
  getWatcher[onRemoveSym] = () => {
290
366
  descriptors[getterProp]?.watchers?.delete(watcherId);
@@ -292,7 +368,7 @@ function createProxy(obj, pathWatcherIds) {
292
368
  }
293
369
  }
294
370
 
295
- return obj[prop];
371
+ return propVal;
296
372
  },
297
373
  set(obj, prop, value) {
298
374
  if (typeof prop == "symbol") {
@@ -301,6 +377,17 @@ function createProxy(obj, pathWatcherIds) {
301
377
  }
302
378
 
303
379
  let oldValue = obj[prop];
380
+
381
+ // mark as "evicted" in case a proxy child object/array is being replaced
382
+ if (oldValue?.[parentSym]) {
383
+ oldValue[evictedSym] = true;
384
+ }
385
+
386
+ // update the stored parent reference in case of index change (e.g. unshift)
387
+ if (value?.[parentSym] && Array.isArray(obj) && !isNaN(prop)) {
388
+ value[parentSym][1] = prop;
389
+ }
390
+
304
391
  obj[prop] = value;
305
392
 
306
393
  // trigger only on value change
@@ -316,18 +403,22 @@ function createProxy(obj, pathWatcherIds) {
316
403
  callWatchers(obj, prop, pathWatcherIds);
317
404
 
318
405
  let currentPath = getPath(obj, prop);
319
- if (pathWatcherIds.has(currentPath)) {
320
- pathWatcherIds.delete(currentPath);
406
+
407
+ for (const item of pathWatcherIds) {
408
+ if (
409
+ // exact match
410
+ item[0] == currentPath ||
411
+ // child path
412
+ item[0].startsWith(currentPath + pathSeparator)
413
+ ) {
414
+ pathWatcherIds.delete(item[0]);
415
+ }
321
416
  }
322
417
  }
323
418
 
324
- delete obj[prop];
325
-
326
- return true;
419
+ return delete obj[prop];
327
420
  },
328
- };
329
-
330
- return new Proxy(obj, handler);
421
+ });
331
422
  }
332
423
 
333
424
  function getPath(obj, prop) {
@@ -335,7 +426,7 @@ function getPath(obj, prop) {
335
426
 
336
427
  let parentData = obj?.[parentSym];
337
428
  while (parentData) {
338
- currentPath = parentData[1] + "." + currentPath;
429
+ currentPath = parentData[1] + pathSeparator + currentPath;
339
430
  parentData = parentData[0][parentSym];
340
431
  }
341
432
 
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;