Current section

26 Versions

Jump to

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…