@escape-game-over/atlas 0.1.1 → 0.1.3

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.
@@ -0,0 +1,259 @@
1
+ import { reportDevError } from "./dev-log.ts";
2
+ import { within } from "./dom.ts";
3
+
4
+ /**
5
+ * A base for custom elements, holding the parts every one of them gets wrong.
6
+ *
7
+ * In `astro/` because it touches the DOM, which the core is type-checked
8
+ * without — this, `consent.ts`, `carousel.ts` and `dom.ts` are the folder
9
+ * allowed it.
10
+ *
11
+ * ```ts
12
+ * class Thing extends AtlasElement {
13
+ * #count = 0;
14
+ *
15
+ * protected setup(): void { … } // once, ever
16
+ * protected connect(): void { // every time it enters the document
17
+ * this.require("form").addEventListener("submit", this.#send, {
18
+ * signal: this.signal,
19
+ * });
20
+ * }
21
+ * }
22
+ * customElements.define("atlas-thing", Thing);
23
+ * ```
24
+ *
25
+ * **Two lifetimes, and confusing them is the whole reason this class exists.**
26
+ * An element is constructed once and may be connected many times: moving it in
27
+ * the DOM runs `disconnectedCallback` then `connectedCallback` again. So
28
+ * `setup` is for what the element *is* — state, defaults, anything a reader
29
+ * would be annoyed to lose — and `connect` is for what a connection *owns* —
30
+ * listeners, timers, observers. Anything registered with `signal` is torn down
31
+ * on every disconnect and re-registered on every connect, so a re-entry rebinds
32
+ * rather than duplicating.
33
+ *
34
+ * **Subclasses implement `setup`, `connect` and `disconnect` — never
35
+ * `connectedCallback` or `disconnectedCallback`.** Overriding those replaces
36
+ * this class's, the controller is never created, and every listener leaks on
37
+ * each disconnect. `connect` is abstract, so forgetting it is a compile error;
38
+ * the adjacent mistake is caught at construction in development, below.
39
+ *
40
+ * **The script that defines a subclass must stay a deferred module.** Astro
41
+ * emits `<script>` and `<script src="./…">` as `type="module"`, which runs after
42
+ * the document is parsed, so an element has its children by the time it is
43
+ * upgraded. `is:inline` opts out and runs the script where it sits — usually
44
+ * above the element it wires — and every lookup then finds nothing. The
45
+ * protection comes from the bundling, not from custom elements.
46
+ *
47
+ * **`attributeChangedCallback` runs before `connectedCallback`** for attributes
48
+ * present in the initial markup, so `signal` is not available there and reading
49
+ * it throws. A subclass with `observedAttributes` should record what changed and
50
+ * act on it in `connect`.
51
+ *
52
+ * What is deliberately *not* modelled is destruction. `disconnect` fires on
53
+ * every move, so it is connection teardown and nothing else — it is not the
54
+ * place to flush state or release something genuinely scarce, because the
55
+ * element may well be back a microtask later.
56
+ */
57
+ export abstract class AtlasElement extends HTMLElement {
58
+ /**
59
+ * Cancels this connection's listeners, and only this connection's.
60
+ *
61
+ * Remade on every connect rather than held in a field initializer, because
62
+ * an `AbortController` is single-use: a controller that outlived the first
63
+ * connection would come back already aborted, `addEventListener` with an
64
+ * aborted signal silently adds nothing, and the element would return looking
65
+ * perfectly normal and be inert forever.
66
+ */
67
+ #ac?: AbortController;
68
+
69
+ /** Whether `setup` has run. See the two-lifetimes note above. */
70
+ #ready = false;
71
+
72
+ constructor() {
73
+ super();
74
+ // Written as the bare expression Vite substitutes: `import.meta.env.DEV`
75
+ // is replaced with a literal, so this collapses to `if (false)` and the
76
+ // method below is dropped from the bundle. An optional chain would be
77
+ // replaced as `import.meta.env` instead — an object literal, whose
78
+ // `.DEV` a minifier has to fold rather than simply delete.
79
+ if (import.meta.env.DEV) this.#assertHooks();
80
+ }
81
+
82
+ /**
83
+ * Refuses a subclass that overrode the wrong lifecycle method.
84
+ *
85
+ * An override is an *own* property of the subclass prototype, where the
86
+ * inherited one is not — so walking up to this class's prototype finds it.
87
+ * The loop rather than a single check, because an intermediate base class
88
+ * could be the one that got it wrong.
89
+ */
90
+ #assertHooks(): void {
91
+ // A tuple array rather than an object: `Object.entries` widens its keys
92
+ // back to `string`, so the pairing would stop being checked exactly
93
+ // where a typo would matter.
94
+ const wrong = [
95
+ ["connectedCallback", "connect"],
96
+ ["disconnectedCallback", "disconnect"],
97
+ ] as const;
98
+
99
+ for (
100
+ let proto = Object.getPrototypeOf(this);
101
+ proto && proto !== AtlasElement.prototype;
102
+ proto = Object.getPrototypeOf(proto)
103
+ ) {
104
+ for (const [callback, hook] of wrong) {
105
+ if (Object.hasOwn(proto, callback)) {
106
+ const error = new Error(
107
+ `override "${hook}", not "${callback}" — see AtlasElement`
108
+ );
109
+ // Reported before throwing: this runs during upgrade, so
110
+ // `connectedCallback`'s catch has not been entered and the
111
+ // throw would surface only as an uncaught error in the
112
+ // console — invisible, for the one mistake this whole
113
+ // apparatus exists to catch.
114
+ reportDevError(this.constructor.name, error);
115
+ throw error;
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Pass to `addEventListener`, observers, anything that should stop when the
123
+ * element leaves the document.
124
+ *
125
+ * Throws when read outside a connection, which is a programming error
126
+ * rather than a state to handle: there is nothing sensible to return.
127
+ */
128
+ protected get signal(): AbortSignal {
129
+ if (!this.#ac) {
130
+ throw new Error(
131
+ `${this.localName}: signal read outside a connection`
132
+ );
133
+ }
134
+ return this.#ac.signal;
135
+ }
136
+
137
+ connectedCallback(): void {
138
+ // Insurance. The spec pairs the callbacks, so this should never find a
139
+ // live controller — but if it ever did, overwriting one would orphan
140
+ // every listener it owned, silently, which is the failure this class
141
+ // exists to make impossible.
142
+ this.#ac?.abort();
143
+ this.#ac = new AbortController();
144
+
145
+ // Caught here so one broken element logs and sits inert rather than
146
+ // taking its siblings with it: the browser calls this once per element,
147
+ // so the blast radius is already one. That is what lets `require`
148
+ // throw instead of returning something every call site has to check.
149
+ try {
150
+ if (!this.#ready) {
151
+ this.setup();
152
+ // Only once it returned. Setting the flag first would mean a
153
+ // `setup` that threw was never retried, and the next connect
154
+ // would run `connect` against state that was never initialized
155
+ // — a component that renders perfectly and does nothing, which
156
+ // is the failure this class exists to make impossible.
157
+ this.#ready = true;
158
+ }
159
+ this.connect();
160
+ } catch (error) {
161
+ this.#report(error);
162
+ }
163
+ }
164
+
165
+ disconnectedCallback(): void {
166
+ // Idempotent, so `adoptedCallback` can delegate here without running a
167
+ // subclass's teardown twice.
168
+ if (!this.#ac) return;
169
+ this.#ac.abort();
170
+
171
+ // Caught for the same reason as `connect`, and it matters more: a
172
+ // throw here escapes into whatever is swapping the DOM — a router, a
173
+ // view transition — rather than staying in the component.
174
+ try {
175
+ // Before `#ac` is cleared, so `this.signal` is readable and already
176
+ // aborted: an async continuation can check `signal.aborted` and
177
+ // bail rather than finishing against a detached element.
178
+ this.disconnect();
179
+ } catch (error) {
180
+ this.#report(error);
181
+ }
182
+
183
+ this.#ac = undefined;
184
+ }
185
+
186
+ /**
187
+ * Fires when the element moves to another document.
188
+ *
189
+ * Delegates, because the controller belongs to the connection in the old
190
+ * document and nothing else would tear it down. Exotic — `adoptNode` and
191
+ * iframe work — and one line either way.
192
+ */
193
+ adoptedCallback(): void {
194
+ this.disconnectedCallback();
195
+ }
196
+
197
+ #report(error: unknown): void {
198
+ if (import.meta.env.DEV) {
199
+ reportDevError(this.localName, error);
200
+ return;
201
+ }
202
+ console.error(`${this.localName}:`, error);
203
+ }
204
+
205
+ /**
206
+ * Runs once per element, before its first `connect`.
207
+ *
208
+ * Where state belongs. A move re-runs `connect` but never this, so anything
209
+ * initialised here survives one — which is the difference between a reader
210
+ * coming back to the slide they left and coming back to the first one.
211
+ */
212
+ protected setup(): void {}
213
+
214
+ /** Runs on every connect, with `signal` and the children both available. */
215
+ protected abstract connect(): void;
216
+
217
+ /**
218
+ * Runs on every disconnect, once the signal has been aborted.
219
+ *
220
+ * `signal` is still readable here and reports `aborted` — which is what an
221
+ * async continuation should check before touching a now-detached element.
222
+ * What it is *not* is a destructor: a move fires this and then `connect`
223
+ * again a moment later, so it is connection teardown and nothing else.
224
+ * Flushing state or releasing something scarce does not belong here.
225
+ *
226
+ * Empty by default: anything registered with `signal` is already gone, and
227
+ * most elements have nothing else to undo.
228
+ */
229
+ protected disconnect(): void {}
230
+
231
+ /** The first match inside this element, or `null`. */
232
+ protected one<T extends Element = HTMLElement>(selector: string): T | null {
233
+ return within(this).one<T>(selector);
234
+ }
235
+
236
+ /** Every match inside this element, as an array. */
237
+ protected all<T extends Element = HTMLElement>(selector: string): T[] {
238
+ return within(this).all<T>(selector);
239
+ }
240
+
241
+ /**
242
+ * The first match, or a thrown error naming what was missing.
243
+ *
244
+ * Throws rather than returning `T | null`, because a nullable return puts an
245
+ * `?.` at every call site — and that optional chain swallows the failure
246
+ * just as thoroughly as the missing element did, which is the thing worth
247
+ * avoiding. `connectedCallback` catches it, so one element with broken
248
+ * markup logs and stops while every other element on the page is untouched.
249
+ */
250
+ protected require<T extends Element = HTMLElement>(selector: string): T {
251
+ const found = this.one<T>(selector);
252
+ if (!found) {
253
+ throw new Error(
254
+ `nothing matched "${selector}" — is the script still a deferred module?`
255
+ );
256
+ }
257
+ return found;
258
+ }
259
+ }