Current section
26 Versions
Jump to
Current section
26 Versions
Compare versions
59
files changed
+4080
additions
-455
deletions
| @@ -7,6 +7,10 @@ exported_locals_without_parens = [ | |
| 7 7 | js_import: 1, |
| 8 8 | js_import: 2, |
| 9 9 | |
| 10 | + # Hologram.Middleware.Builder |
| 11 | + middleware: 1, |
| 12 | + middleware: 2, |
| 13 | + |
| 10 14 | # Hologram.Page |
| 11 15 | layout: 1, |
| 12 16 | layout: 2, |
| @@ -0,0 +1,32 @@ | |
| 1 | + "use strict"; |
| 2 | + |
| 3 | + // Trailing-edge debounce keyed by (element, slotKey). Timers live in a WeakMap keyed by the live |
| 4 | + // DOM element, so they survive event-listener recreation across re-renders (the element is patched |
| 5 | + // in place) and are collected when the element leaves the DOM. The inner map keys by a per-binding |
| 6 | + // slot, so several debounced bindings on one element and DOM event each keep an independent timer. |
| 7 | + export default class Debouncer { |
| 8 | + static #timersByElement = new WeakMap(); |
| 9 | + |
| 10 | + // Schedules callback to run after delayMs, canceling any pending run for the same |
| 11 | + // (element, slotKey). Each call restarts the window, so only the final call in a burst fires. |
| 12 | + static run(element, slotKey, delayMs, callback) { |
| 13 | + let timers = $.#timersByElement.get(element); |
| 14 | + |
| 15 | + if (timers === undefined) { |
| 16 | + timers = new Map(); |
| 17 | + $.#timersByElement.set(element, timers); |
| 18 | + } |
| 19 | + |
| 20 | + // clearTimeout(undefined) is a no-op, so the first run in a slot needs no guard. |
| 21 | + clearTimeout(timers.get(slotKey)); |
| 22 | + |
| 23 | + const timerId = setTimeout(() => { |
| 24 | + timers.delete(slotKey); |
| 25 | + callback(); |
| 26 | + }, delayMs); |
| 27 | + |
| 28 | + timers.set(slotKey, timerId); |
| 29 | + } |
| 30 | + } |
| 31 | + |
| 32 | + const $ = Debouncer; |
| @@ -1706,6 +1706,21 @@ const Erlang = { | |
| 1706 1706 | // End error/2 |
| 1707 1707 | // Deps: [] |
| 1708 1708 | |
| 1709 | + // TODO: the third argument carries error_info options used for richer error reporting on BEAM; it is currently accepted and ignored. |
| 1710 | + // Start error/3 |
| 1711 | + "error/3": (reason, args, _options) => { |
| 1712 | + Erlang["error/2"](reason, args); |
| 1713 | + }, |
| 1714 | + // End error/3 |
| 1715 | + // Deps: [:erlang.error/2] |
| 1716 | + |
| 1717 | + // Start exit/1 |
| 1718 | + "exit/1": (reason) => { |
| 1719 | + throw new HologramBoxedError(reason, Type.atom("exit")); |
| 1720 | + }, |
| 1721 | + // End exit/1 |
| 1722 | + // Deps: [] |
| 1723 | + |
| 1709 1724 | // Start float/1 |
| 1710 1725 | "float/1": (number) => { |
| 1711 1726 | if (Type.isInteger(number)) { |
| @@ -2971,6 +2986,21 @@ const Erlang = { | |
| 2971 2986 | // End pid_to_list/1 |
| 2972 2987 | // Deps: [] |
| 2973 2988 | |
| 2989 | + // TODO: support stacktraces; the stacktrace argument is currently accepted and ignored. |
| 2990 | + // Start raise/3 |
| 2991 | + "raise/3": (kind, reason, _stacktrace) => { |
| 2992 | + if ( |
| 2993 | + !Type.isAtom(kind) || |
| 2994 | + !["error", "exit", "throw"].includes(kind.value) |
| 2995 | + ) { |
| 2996 | + return Type.atom("badarg"); |
| 2997 | + } |
| 2998 | + |
| 2999 | + throw new HologramBoxedError(reason, kind); |
| 3000 | + }, |
| 3001 | + // End raise/3 |
| 3002 | + // Deps: [] |
| 3003 | + |
| 2974 3004 | // Start ref_to_list/1 |
| 2975 3005 | "ref_to_list/1": (reference) => { |
| 2976 3006 | if (!Type.isReference(reference)) { |
| @@ -3137,6 +3167,13 @@ const Erlang = { | |
| 3137 3167 | // End system_time/1 |
| 3138 3168 | // Deps: [:os.system_time/1] |
| 3139 3169 | |
| 3170 | + // Start throw/1 |
| 3171 | + "throw/1": (value) => { |
| 3172 | + throw new HologramBoxedError(value, Type.atom("throw")); |
| 3173 | + }, |
| 3174 | + // End throw/1 |
| 3175 | + // Deps: [] |
| 3176 | + |
| 3140 3177 | // Start time_offset/0 |
| 3141 3178 | // See: docs/erlang_time_functions_porting_strategy.md |
| 3142 3179 | "time_offset/0": () => { |
| @@ -1,17 +1,47 @@ | |
| 1 1 | "use strict"; |
| 2 2 | |
| 3 3 | import Interpreter from "../interpreter.mjs"; |
| 4 | + import Type from "../type.mjs"; |
| 4 5 | |
| 5 6 | export default class HologramBoxedError extends Error { |
| 6 | - constructor(struct) { |
| 7 | + constructor(value, kind = Type.atom("error")) { |
| 7 8 | super(""); |
| 8 9 | |
| 9 10 | this.name = "HologramBoxedError"; |
| 10 | - this.struct = struct; |
| 11 11 | |
| 12 | - const boxedType = Interpreter.getErrorType(this); |
| 13 | - const boxedMessage = Interpreter.resolveErrorMessage(struct); |
| 12 | + // kind, value and struct are internal carriers read by the try/rescue/catch |
| 13 | + // machinery. They are defined as non-enumerable because extra enumerable |
| 14 | + // own-properties on a thrown Error blank out the message that the browser's |
| 15 | + // uncaught-error reporting surfaces (and that Wallaby/chromedriver capture). |
| 16 | + Object.defineProperty(this, "kind", { |
| 17 | + value: kind, |
| 18 | + writable: true, |
| 19 | + configurable: true, |
| 20 | + }); |
| 21 | + Object.defineProperty(this, "value", { |
| 22 | + value: value, |
| 23 | + writable: true, |
| 24 | + configurable: true, |
| 25 | + }); |
| 14 26 | |
| 15 | - this.message = `(${boxedType}) ${boxedMessage}`; |
| 27 | + if (kind.value === "error") { |
| 28 | + // value carries the raw reason; struct carries its normalized exception |
| 29 | + // form. Normalizing here means rescue always matches against a real |
| 30 | + // exception struct, even when the reason is a bare term like :badarg. |
| 31 | + const struct = Interpreter.normalizeError(value); |
| 32 | + |
| 33 | + Object.defineProperty(this, "struct", { |
| 34 | + value: struct, |
| 35 | + writable: true, |
| 36 | + configurable: true, |
| 37 | + }); |
| 38 | + |
| 39 | + const boxedType = Interpreter.getErrorType(this); |
| 40 | + const boxedMessage = Interpreter.resolveErrorMessage(struct); |
| 41 | + |
| 42 | + this.message = `(${boxedType}) ${boxedMessage}`; |
| 43 | + } else { |
| 44 | + this.message = `(${kind.value}) ${Interpreter.inspect(value)}`; |
| 45 | + } |
| 16 46 | } |
| 17 47 | } |
| @@ -0,0 +1,90 @@ | |
| 1 | + "use strict"; |
| 2 | + |
| 3 | + // One reconciled listener per (target, key). Window and document event bindings have no DOM node |
| 4 | + // to host them, so the registry installs exactly one real listener per (target, key) and fans it |
| 5 | + // out to every binding for that pair. The desired set is rebuilt each render and reconciled |
| 6 | + // against the live set, per target: a listener gaining its first binding attaches a real listener, |
| 7 | + // losing its last binding detaches it, and one present in both keeps its listener and just swaps |
| 8 | + // its handler list (read lazily at dispatch), so ordinary re-renders cause no listener churn. |
| 9 | + // Targets are reconciled independently of one another. |
| 10 | + // |
| 11 | + // How a listener is actually installed - a DOM addEventListener, a ResizeObserver - is the |
| 12 | + // binding's own concern: each carries an attach(dispatcher) that installs the real listener and |
| 13 | + // returns a detach() teardown (see event_listeners.mjs). The key tells listeners on one target |
| 14 | + // apart - a capture-phase listener from a bubble-phase one, a DOM event from an observer - so |
| 15 | + // each reconciles independently. |
| 16 | + export default class EventListenerRegistry { |
| 17 | + static #entriesByTarget = new Map(); |
| 18 | + |
| 19 | + // Reconciles the live listeners against `bindings`, an array of {target, key, attach, handler} |
| 20 | + // descriptors collected during the current render. Attaches, refreshes, or detaches exactly one |
| 21 | + // real listener per (target, key) so the live set matches current demand. |
| 22 | + static reconcile(bindings) { |
| 23 | + const desiredByTarget = new Map(); |
| 24 | + |
| 25 | + for (const {target, key, attach, handler} of bindings) { |
| 26 | + let desiredByKey = desiredByTarget.get(target); |
| 27 | + |
| 28 | + if (desiredByKey === undefined) { |
| 29 | + desiredByKey = new Map(); |
| 30 | + desiredByTarget.set(target, desiredByKey); |
| 31 | + } |
| 32 | + |
| 33 | + let desired = desiredByKey.get(key); |
| 34 | + |
| 35 | + if (desired === undefined) { |
| 36 | + desired = {attach, handlers: []}; |
| 37 | + desiredByKey.set(key, desired); |
| 38 | + } |
| 39 | + |
| 40 | + desired.handlers.push(handler); |
| 41 | + } |
| 42 | + |
| 43 | + // Attach or refresh: an existing (target, key) entry keeps its real listener and just swaps its |
| 44 | + // handler list; a new one attaches one real listener whose dispatcher reads the entry's |
| 45 | + // handlers lazily, so a later refresh takes effect without re-attaching. |
| 46 | + for (const [target, desiredByKey] of desiredByTarget) { |
| 47 | + let liveByKey = $.#entriesByTarget.get(target); |
| 48 | + |
| 49 | + if (liveByKey === undefined) { |
| 50 | + liveByKey = new Map(); |
| 51 | + $.#entriesByTarget.set(target, liveByKey); |
| 52 | + } |
| 53 | + |
| 54 | + for (const [key, desired] of desiredByKey) { |
| 55 | + const entry = liveByKey.get(key); |
| 56 | + |
| 57 | + if (entry === undefined) { |
| 58 | + const newEntry = {detach: null, handlers: desired.handlers}; |
| 59 | + |
| 60 | + newEntry.detach = desired.attach((event) => |
| 61 | + newEntry.handlers.forEach((handler) => handler(event)), |
| 62 | + ); |
| 63 | + |
| 64 | + liveByKey.set(key, newEntry); |
| 65 | + } else { |
| 66 | + entry.handlers = desired.handlers; |
| 67 | + } |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + // Detach: any live (target, key) absent from this render has lost its last binding, so tear |
| 72 | + // down its real listener. A target left with no listeners drops out of the registry. |
| 73 | + for (const [target, liveByKey] of $.#entriesByTarget) { |
| 74 | + const desiredByKey = desiredByTarget.get(target); |
| 75 | + |
| 76 | + for (const [key, entry] of liveByKey) { |
| 77 | + if (desiredByKey === undefined || !desiredByKey.has(key)) { |
| 78 | + entry.detach(); |
| 79 | + liveByKey.delete(key); |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + if (liveByKey.size === 0) { |
| 84 | + $.#entriesByTarget.delete(target); |
| 85 | + } |
| 86 | + } |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + const $ = EventListenerRegistry; |
Loading more files…