Index
# `src/js` — review, fixes and simplification plan A full read of the ~16 000 lines under `src/js`, the bugs it turned up, and where the design could be pared back. Everything in the **Fixed** section is already applied and covered by tests; everything in **Simplification** is a proposal, not a change. --- ## 1. What the library is Sixteen thousand lines, no build-time module system: every file calls `goog.provide` and hangs its exports off one global `MMO` namespace, which Google Closure links into a single bundle. There is no framework underneath it — the reactive layer, the templating, the schema validator and the gesture recogniser are all written here. Five layers, each built on the one below: ``` elements/ CustomElement · CustomShadowElement · CustomAPIElement · Widget · View · Entity └─ the public surface: custom elements and .jhs views core/ Map · Template · APIRequester · Printer · Window · Date · Color · File · HTMLSearch · URLSearchParams · history · Storage └─ the reactive model, the dom builder, the server client schemas/ Schema · Annotation · schemas └─ a JSON-Schema draft 2020-12 implementation, used to coerce and validate inputs/ Input · Mouse · Touch · Pointer · TouchMouse · Gesture · Press · Drag └─ pointer normalisation and gesture recognition core/functions/ core · dom · strings · path · server · svg · markdown · transitions └─ leaf utilities, no state ``` ### The proxy data model `MMO.core.Map` extends the native `Map` with change notification, and `MMO.elements.Entity` extends it again with server identity. It is the spine of the whole library and it is genuinely good design. Four ideas carry it: **Values become Maps as they go in.** `set` runs any plain object or array through `From`, so a tree of data is a tree of observable Maps with no opt-in from the caller. The rest of the model can then assume every container notifies. **Children know their parents, plurally.** `parents` is a `Map`, not a single reference, so the same entity can sit in three collections at once and each parent still learns about a change. Events walk up every edge, with a `WeakSet` guard for cycles. **Notification is asynchronous and de-duplicated.** Changes queue on `MMO.core.Window.run`, which drops a `(callback, args)` pair already queued, and drains on one animation frame. A hundred writes in a loop repaint once. `emitAsyncLock` / `emitAsyncUnlock` bracket the compound operations (`clear`, `setValue`, `delete` on an array) so an observer never sees a half-applied state. The exception is deliberate and correct: `MAP_CONNECT` / `MAP_DISCONNECT` fire *synchronously*, because a watch on `a/b/c` has to attach to the newly created `b` before the queued change on `c` is delivered. **Watches survive the structure they watch.** `watch('a/b/c')` on a Map where `a` does not exist yet does not fail — it records the watch as disconnected and listens for `MAP_CONNECT`. Destroy the branch and rebuild it and the watch reattaches itself. This is what lets a view bind to a path that the server has not filled in yet, and it is the part of the design that is hardest to get right. `Entity` then adds one idea: an **embedded** entity — nested, with no `id` of its own — keeps a `parent`/`parentKey` link and delegates `save()` upward, so an address saves with its user and never on its own. ### The view layer `View extends Template` and binds it to a `Map`. A view has a `contentRoot` and a `basepath`; `$text`, `$attr`, `$html`, `loop` and `test` each open a watch and rewrite the dom when it fires. Two details are worth knowing: - a view **follows its own key**. Rename `a` to `b` in the model and the view rewrites its `basepath` and emits `path`; delete the value and the view removes itself. A `loop` is built entirely on this: it does not diff, it lets each row view react to its own key. - `$$` and `$#` at the end of a path address the **key** and the **index** rather than the value, so a template can bind to, and write, a row's position. --- ## 2. Fixed 31 defects, each now covered by a test. Ordered by how much damage they do. ### Data corruption and hangs | Where | What | |---|---| | `core/Map.js` `set` | `if (previous === value)` also matched *"key absent, value undefined"*, so `set(k, undefined)` could never insert. `length = n` grows by `push(undefined)` — **it looped forever**. Now `(previous === value) && this.has(key)`. | | `core/Map.js` `unshift` | `old.parents.set(this, key)` — `key` is not in scope. Every child shifted by an `unshift` recorded `undefined` as its key, so its change events stopped reaching the parent. | | `core/Map.js` `emitIndexChange` | emitted `MAP_CHANGED` with `(map)` instead of `(type, map)`, unlike every other emit, so listeners read the map as the type. | | `schemas/Schema.js` `_setContent` | `(Object.hasOwn(data, key)) && (!!data[key])` silently **dropped every falsy keyword**: `const: null`, `const: 0`, `minimum: 0`, `items: false`… `{const: null}` validated `0` as valid. | | `schemas/Schema.js` `$defs` | definitions were filled in declaration order, so `$defs.item` referencing `#/$defs/sub-item` threw when `sub-item` came later. Now two passes: instantiate every definition, then fill them. | | `core/EventEmitter.js` `emit` | a listener that threw left `binds.index` set, and **every later emit of that event was silently ignored** as a recursion. Now in a `finally`. | | `core/functions/dom.js` `loopInputs` | `willValidate` is false for a *disabled* control, and `readonly(node, true)` disables checkboxes — so `readonly(node, false)` could never find them again. A one-way door on any form with a read-only mode. | ### Wrong results | Where | What | |---|---| | `core/functions/core.js` `isSameArray` | `for (let value in a)` iterates indexes, not values: `isSameArray(['a','b'], ['0','1'])` returned true. | | `core/functions/core.js` `merge` | `[Math.max(a.length, b.length)]` builds a one-slot array *holding the length*. `merge([], [])` returned `[0]`. | | `core/functions/path.js` `simplify` | `result[result.length]` is always `undefined` (off by one), so a `..` following a `..` was mis-tracked. | | `core/functions/path.js` `dirname` | tested `i === false`; `lastIndexOf` returns `-1`. `dirname('/a')` returned `''` instead of `'/'`. | | `core/functions/strings.js` `bytesize` | clamped the unit index to `units.length` instead of `length - 1`: anything past a terabyte rendered as `"0.89 undefined"`. | | `core/functions/strings.js` `serialize` | pushed a literal `'i: '` instead of the key name, and appended to `hashValues` *after* recursing, so the circular-reference table drifted out of alignment with `hash`. | | `core/functions/server.js` `updateQuery` | `url += '#' . hash` — that is php. In js it reads a property off a string, so every url with a fragment got `"undefined"` appended. | | `core/functions/server.js` `extractLink` | `data` and `m` were assigned without declaration (implicit globals, and a `ReferenceError` under strict mode). The shared global regex is now reset before use. | | `core/functions/server.js` `encodeQuery` | `typeof null === 'object'`, and `Object.entries(null)` throws. | | `core/functions/dom.js` `hasAttributes` | `for (const name of attrs)` over a plain object throws *"not iterable"*, so `isWrapped(node, tag, attrs)` and `toggleRangeTextWrapping` failed whenever attributes were passed. | | `core/functions/dom.js` `getEmValue` | arguments to `decodeUnit` were swapped, and `getPropertyValue` was given `'fontSize'` where it needs `'font-size'`. Every `em` conversion was wrong. | | `core/functions/dom.js` `decodeUnit` | `value * em \|\| getEmValue(node)` parses as `(value*em) \|\| …`, so `0em` silently became the whole font size. | | `core/functions/dom.js` `encodeUnit` | the `%` branch divided by an undeclared `percent` → `ReferenceError`. Now a parameter. | | `core/functions/dom.js` `loopAppendChildren` | the `HTMLTemplateElement` branch sat *after* `instanceof Node`, which a template also satisfies — so a `