@escape-game-over/atlas 0.1.18 → 0.1.19

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.
@@ -1,23 +1,6 @@
1
1
  /**
2
2
  * A filtered list's state and its URL — not its markup.
3
3
  *
4
- * In `astro/` for the reason `carousel.ts` is: it reaches for `location`,
5
- * `history` and `window`, which the core is type-checked without.
6
- *
7
- * **It draws nothing.** No `hidden`, no `aria-selected`, no counts, no empty
8
- * state. It owns which items match and what the address bar says, and calls
9
- * `onChange` when either moves; a project does every DOM write, and its markup
10
- * contract — however many data attributes that turns out to be — stays where the
11
- * markup is. That is the whole split, and it is why one function can serve a
12
- * game grid with tabs and a two-input directory.
13
- *
14
- * Matching is the one place this cannot copy `carousel`. A carousel's entire
15
- * state is an integer, so it never needs to know anything about the page. A
16
- * predicate needs each item's text, and that text lives in the markup — so the
17
- * caller hands it over once, as plain values, rather than the library reaching
18
- * into the DOM for it. Keys are strings for the same reason: nothing here holds
19
- * an element, and the whole module is testable without a document.
20
- *
21
4
  * ```ts
22
5
  * const list = filters({
23
6
  * fields: {
@@ -28,48 +11,34 @@
28
11
  * key: entry.id,
29
12
  * values: { q: `${entry.title} ${entry.summary}`, category: entry.category },
30
13
  * })),
31
- * onChange: ({ matched }) => {
32
- * for (const [key, element] of elements) element.hidden = !matched.has(key);
33
- * },
14
+ * onChange: ({ matched }) => { /* every DOM write is the project's *\/ },
34
15
  * });
35
16
  *
36
17
  * const detach = list.attach();
37
18
  * ```
38
19
  *
39
- * What it owns is the handful of things identical in every filtered list and
40
- * quietly wrong in most: folding so a query without accents still matches words
41
- * with them, a query and its facets resolved together rather than in two passes,
42
- * and a URL that behaves — replaced while typing so one search does not bury the
43
- * previous page in history, pushed on a deliberate choice so the back button
44
- * undoes it, empty parameters dropped rather than left as `?q=`, and `popstate`
45
- * applied rather than ignored.
20
+ * **It draws nothing** and holds no element: the caller hands over each item's
21
+ * values once, as plain data, which is also why the whole module is testable
22
+ * without a document.
23
+ *
24
+ * What it owns is what every filtered list needs and most get wrong: folding, so
25
+ * a query without accents matches words with them; a query and its facets
26
+ * resolved together; and a URL that behaves — replaced while typing, pushed on a
27
+ * deliberate choice, empty parameters dropped, `popstate` applied.
46
28
  *
47
- * Reading the URL in `attach` is also what lets a list work before this script
48
- * arrives: a `<form method="get">` submits, the server renders the filtered
49
- * page, and the first `onChange` here continues from that state instead of
50
- * resetting it.
29
+ * In `astro/` because it reaches for `location`, `history` and `window`. See
30
+ * docs/client-scripts.md.
51
31
  */
52
32
 
53
33
  /**
54
- * What a field is, as data rather than a constructor.
55
- *
56
- * A plain object because that is all a field is — a kind and, if it belongs in
57
- * the URL, a parameter name. Three exported builder functions would add three
58
- * very general names to a module a project imports by name (`text` above all)
59
- * and would return exactly this.
60
- *
61
- * The kinds are closed, and deliberately few:
34
+ * What a field is, as data rather than a constructor: builder functions would
35
+ * put names as general as `text` into every module that imports this.
62
36
  *
63
37
  * - `text` — free entry, folded and matched word by word. The search box.
64
- * - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group. The
65
- * control picks one; an item may hold several and answer to any — see
66
- * `ItemValue`.
67
- * - `flag` — a narrowing toggle: off matches everything, on keeps only the
68
- * items that carry it. "In stock", "Step-free access".
38
+ * - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group.
39
+ * - `flag` — a narrowing toggle: off keeps everything, on keeps what carries it.
69
40
  *
70
- * `flag` is not a tri-state and should not become one. On, off and *either* is
71
- * a `choice` with two values; folding that into a toggle gives a control with a
72
- * third position nothing can reach.
41
+ * `flag` is not a tri-state: on, off and *either* is a `choice` with two values.
73
42
  */
74
43
  export type Field =
75
44
  | { readonly kind: "text"; readonly param?: string }
@@ -79,36 +48,17 @@ export type Field =
79
48
  /** The fields of one list, named by the caller. */
80
49
  export type FieldMap = Readonly<Record<string, Field>>;
81
50
 
82
- /**
83
- * What one field contributes, in the state.
84
- *
85
- * Always one value per field, whatever the kind: a `choice` is the one category
86
- * asked for, a `text` is what someone typed, a `flag` is whether the toggle is
87
- * on. This is what `set` takes and what reaches the address bar.
88
- */
51
+ /** What one field contributes in the state: what was typed, picked or toggled. */
89
52
  export type StateValue<F extends Field> = F["kind"] extends "flag"
90
53
  ? boolean
91
54
  : string;
92
55
 
93
56
  /**
94
- * What one field contributes, on an item.
57
+ * What one field contributes on an item: what it *is*, where the state holds
58
+ * what was *asked for*. A `text` value is everything that field searches.
95
59
  *
96
- * The two roles read alike but are not the same, and `choice` is where they part
97
- * company. On an item a `text` value is everything that field searches — a name
98
- * and a blurb joined — while in the state it is what someone typed; a `flag` is
99
- * what the item *is* on one side and what was *asked for* on the other.
100
- *
101
- * **A `choice` item may hold several values, and then it answers to any of
102
- * them.** The asymmetry is the point: an escape room is adventure *and* sci-fi,
103
- * a film is a comedy *and* a drama, but the tab strip above them still picks
104
- * one, and the URL still carries one. Widened here rather than given a kind of
105
- * its own, because a second kind would carry the same state, the same
106
- * parameter, the same `set` semantics and the same control, and differ by one
107
- * operator — two kinds doing one job, which is exactly what `Field` refuses
108
- * when it declines to let `flag` become a tri-state.
109
- *
110
- * A list whose items each sit in one category writes a bare string and is
111
- * untouched by this.
60
+ * **A `choice` item may hold several values, and then answers to any of them** —
61
+ * a room is adventure *and* sci-fi, while the chips still pick one.
112
62
  */
113
63
  export type ItemValue<F extends Field> = F["kind"] extends "flag"
114
64
  ? boolean
@@ -135,13 +85,7 @@ export type FilterState<F extends FieldMap> = {
135
85
  };
136
86
 
137
87
  export interface FilterItem<F extends FieldMap> {
138
- /**
139
- * How the caller finds this item again.
140
- *
141
- * Opaque here and unique across the list — a duplicate is rejected at
142
- * construction, because two items answering to one key make `matched`
143
- * unable to say which of them matched.
144
- */
88
+ /** Unique across the list: a duplicate makes `matched` ambiguous, and is refused. */
145
89
  readonly key: string;
146
90
  readonly values: ItemValues<F>;
147
91
  }
@@ -149,11 +93,8 @@ export interface FilterItem<F extends FieldMap> {
149
93
  export interface FilterChange<F extends FieldMap> {
150
94
  readonly state: FilterState<F>;
151
95
  /**
152
- * The keys that survive every field at once.
153
- *
154
- * A set rather than a filtered list, so a caller renders by asking about the
155
- * items it already holds instead of diffing two arrays — and `size` is the
156
- * count a "showing N" line wants, with no second pass.
96
+ * The keys that survive every field at once. A set, so a caller asks about
97
+ * the items it already holds, and `size` is the count a "showing N" wants.
157
98
  */
158
99
  readonly matched: ReadonlySet<string>;
159
100
  }
@@ -161,38 +102,24 @@ export interface FilterChange<F extends FieldMap> {
161
102
  export interface FiltersOptions<F extends FieldMap> {
162
103
  readonly fields: F;
163
104
  /**
164
- * The full list, every time — this filters, it does not paginate.
165
- *
166
- * Fixed for the lifetime of the instance. A list whose contents change is a
167
- * new instance, which is one line at the call site and avoids this owning a
168
- * second lifecycle it would have to keep in step with `attach`.
105
+ * The full list, every time — this filters, it does not paginate. Fixed for
106
+ * the instance's lifetime; a list whose contents change is a new instance.
169
107
  */
170
108
  readonly items: readonly FilterItem<F>[];
171
109
  /**
172
110
  * Called whenever the state moves, and once on `attach` with whatever the
173
- * URL already said.
174
- *
175
- * Never called for a `set` that changes nothing: re-rendering an unchanged
176
- * list on every keystroke that did not alter the query is work a caller
177
- * cannot skip on its own, because by then it has been told the state
178
- * changed.
111
+ * URL already said. Never called for a `set` that changed nothing.
179
112
  */
180
113
  onChange(change: FilterChange<F>): void;
181
114
  }
182
115
 
183
116
  export interface SetOptions {
184
117
  /**
185
- * Whether this move is worth a step in history.
186
- *
187
- * Defaults from the kind — `text` replaces, `choice` and `flag` push —
188
- * because that is the pairing almost every control wants: typing emits an
189
- * event per keystroke and would otherwise fill history with a word being
190
- * spelled, while picking a category is one deliberate act the back button
191
- * should undo.
192
- *
193
- * It is an override rather than a fixed rule because the default is really
194
- * a fact about the *input*, not the field. A `<select>` that sets a `text`
195
- * field, or a preset link, is a deliberate choice and wants `"push"`.
118
+ * Whether this move is worth a step in history. Defaults from the kind —
119
+ * `text` replaces, `choice` and `flag` push — because typing would otherwise
120
+ * fill history with a word being spelled. An override, because the default
121
+ * is really a fact about the input: a `<select>` setting a `text` field is a
122
+ * deliberate choice and wants `"push"`.
196
123
  */
197
124
  readonly history?: "push" | "replace";
198
125
  }
@@ -201,26 +128,13 @@ export interface Filters<F extends FieldMap> {
201
128
  readonly state: FilterState<F>;
202
129
  readonly matched: ReadonlySet<string>;
203
130
  /**
204
- * The keys that survive every field *except* this one.
131
+ * The keys that survive every field *except* this one: what a facet asks of
132
+ * its own options, so each can say what picking it would give. Counted
133
+ * against `matched` instead, a facet that is already set could only ever
134
+ * offer its own value.
205
135
  *
206
- * The question a facet asks of its own options: how many results would
207
- * picking each of them give, with everything else as it stands. Counted
208
- * against `matched` instead, a dropdown whose value is already set could
209
- * only ever offer that value — every other option would read zero, since
210
- * the items holding it have just been filtered out by the very field being
211
- * asked about.
212
- *
213
- * The same matcher as `matched`, folding and tokens included, rather than a
214
- * second one in the caller. A facet count computed by a hand-written
215
- * predicate drifts from the list it is counting the moment either changes —
216
- * a count of four above a list of three — and nothing reports it.
217
- *
218
- * Computed on first ask and kept until the state next moves, so a render
219
- * that asks once per option pays one pass per field rather than one per
220
- * option. A field that is not narrowing anything answers with `matched`
221
- * itself: without it, the rest is exactly what already matched.
222
- *
223
- * Safe to call from `onChange`, which never runs during construction.
136
+ * The same matcher as `matched`, so a count cannot drift from the list it
137
+ * counts. Computed on first ask and kept until the state moves.
224
138
  */
225
139
  matchedWithout(field: keyof F): ReadonlySet<string>;
226
140
  set<K extends keyof F>(
@@ -233,17 +147,10 @@ export interface Filters<F extends FieldMap> {
233
147
  /**
234
148
  * Reads the URL, applies it, and starts listening — returns the undo.
235
149
  *
236
- * One lifecycle rather than two, as `carousel.attach` is, and for a reason
237
- * that bites harder here: with view transitions on, a module bound at top
238
- * level executes once per session rather than once per navigation, so the
239
- * incoming page gets a live list and dead controls. Driving this from
240
- * `astro:page-load` and calling the returned function on teardown is what
241
- * makes that correct — and the listener is aborted by its own undo, so it
242
- * cannot outlive the page that added it.
243
- *
244
- * Nothing writes to the URL before this runs. `attach` is what reads it, and
245
- * a `set` on an unattached instance would otherwise overwrite state it never
246
- * loaded.
150
+ * One lifecycle, which matters with view transitions: a module bound at top
151
+ * level runs once per session, not once per navigation, so the incoming page
152
+ * would get a live list and dead controls. Nothing writes to the URL before
153
+ * this runs.
247
154
  */
248
155
  attach(): () => void;
249
156
  }
@@ -252,30 +159,17 @@ export interface Filters<F extends FieldMap> {
252
159
  const DIACRITICS = /[̀-ͯ]/g;
253
160
 
254
161
  /**
255
- * A string reduced to what a query should match against.
256
- *
257
- * Decompose, drop the marks, lowercase, trim. This is the part every list needs
258
- * and few have: without it a reader typing `malaga` is told there is no
259
- * *Málaga*, and a Romanian catalogue hides every entry spelled with `ă`, `ș` or
260
- * `ț` from anyone whose keyboard does not carry them.
162
+ * A string reduced to what a query should match against. Without it, a reader
163
+ * typing `malaga` is told there is no *Málaga*.
261
164
  */
262
165
  function fold(value: string): string {
263
166
  return value.normalize("NFD").replace(DIACRITICS, "").toLowerCase().trim();
264
167
  }
265
168
 
266
169
  /**
267
- * How a set flag is spelled in the query string, and the only spelling read
268
- * back as set.
269
- *
270
- * An unset flag is *absent* rather than `=0`. One way to say off keeps the URL
271
- * short and means there is a single form to handle; two would both have to be
272
- * understood forever, and a reader could not tell which of them a link was
273
- * carrying. The cost is that a hand-written `?featured=0` reads as off, which is
274
- * the answer it would get anyway.
275
- *
276
- * `1` and not `true` because that is what the existing lists already emit:
277
- * links people have shared or bookmarked resolve to the view they named, and a
278
- * spelling change would quietly reset every one of them.
170
+ * How a set flag is spelled in the query string, and the only spelling read back
171
+ * as set: an unset flag is *absent* rather than `=0`. `1` and not `true`
172
+ * because that is what shared links already carry.
279
173
  */
280
174
  const FLAG_ON = "1";
281
175
 
@@ -285,10 +179,9 @@ export function filters<const F extends FieldMap>(
285
179
  const { fields, items, onChange } = options;
286
180
  const names = Object.keys(fields) as (keyof F & string)[];
287
181
 
288
- // Checks that types cannot express, run once. Both failures are silent
289
- // otherwise: a duplicate key makes `matched` ambiguous about which item it
290
- // meant, and two fields sharing a parameter means each URL write erases the
291
- // other's value, which reads as a filter that will not stay set.
182
+ // Checks types cannot express, and both failures are otherwise silent: a
183
+ // duplicate key makes `matched` ambiguous, and two fields sharing a
184
+ // parameter means each URL write erases the other's value.
292
185
  const keys = new Set<string>();
293
186
  for (const item of items) {
294
187
  if (keys.has(item.key)) {
@@ -308,13 +201,8 @@ export function filters<const F extends FieldMap>(
308
201
  params.add(param);
309
202
  }
310
203
 
311
- /**
312
- * Every item's searchable text, folded once at construction.
313
- *
314
- * Folding is four string operations, and doing it per item per keystroke is
315
- * the difference between a list that filters as you type and one that
316
- * stutters. Only the query is folded per render, and there is one of those.
317
- */
204
+ // Folded once at construction: doing it per item per keystroke is the
205
+ // difference between filtering as you type and stuttering.
318
206
  const haystacks = new Map<string, Map<string, string>>();
319
207
  for (const item of items) {
320
208
  const perField = new Map<string, string>();
@@ -338,32 +226,22 @@ export function filters<const F extends FieldMap>(
338
226
  let state = blank();
339
227
  let matched: ReadonlySet<string> = new Set(items.map((item) => item.key));
340
228
 
341
- /**
342
- * Each `text` field's query, folded and split, for the current state.
343
- *
344
- * Folded once per state rather than once per item. The other kinds compare
345
- * values as they are, so there is nothing to prepare for them.
346
- */
229
+ /** Each `text` field's query, folded and split once per state. */
347
230
  let queries = new Map<string, readonly string[]>();
348
231
 
349
232
  /** `matchedWithout` answers for the current state. Emptied when it moves. */
350
233
  const without = new Map<string, ReadonlySet<string>>();
351
234
 
352
235
  /**
353
- * Whether the URL is this instance's to write.
354
- *
355
- * Gates every write, for the reason `carousel` gates autoplay on the same
356
- * flag: an instance that was built but never attached has not read the URL,
357
- * so writing to it would replace state that nobody here has loaded.
236
+ * Whether the URL is this instance's to write. An instance that was built
237
+ * but never attached has not read the URL, so writing would replace state
238
+ * nobody here loaded.
358
239
  */
359
240
  let attached = false;
360
241
 
361
242
  /**
362
- * Whether a field is narrowing anything in the current state.
363
- *
364
- * An empty query, no choice and a flag that is off all keep every item —
365
- * the flag above all, which keeps everything rather than keeping the items
366
- * that are *not* flagged. See `Field`.
243
+ * Whether a field is narrowing anything now. An empty query, no choice and
244
+ * a flag that is off all keep every item — the flag above all.
367
245
  */
368
246
  const narrows = (name: keyof F & string): boolean => {
369
247
  const kind = fields[name]?.kind;
@@ -373,34 +251,26 @@ export function filters<const F extends FieldMap>(
373
251
  };
374
252
 
375
253
  /**
376
- * Whether one item survives every field but `except`, in the current state.
377
- *
378
- * The one matcher. `matched` is this with nothing excepted and
379
- * `matchedWithout` is this with one field excepted, so a facet count and
380
- * the list it counts cannot disagree about what a match is.
254
+ * Whether one item survives every field but `except`. The one matcher, so a
255
+ * facet count and the list it counts cannot disagree about what a match is.
381
256
  */
382
257
  function passes(item: FilterItem<F>, except?: keyof F & string): boolean {
383
258
  for (const name of names) {
384
259
  if (name === except || !narrows(name)) continue;
385
260
  const kind = fields[name]?.kind;
386
261
  if (kind === "text") {
387
- // Every token must appear, not one run that must appear whole.
388
- // A haystack is several things joined (a title, plus its
389
- // category, plus its summary), and the order they were joined
390
- // in is an accident of whoever wrote the template. Matching
391
- // the query as one run made that accident load-bearing:
392
- // against "Copper Kettle" in the Kitchen category, "kettle
393
- // kitchen" found nothing while "kettle" alone worked, and the
394
- // first is what someone narrowing a list types.
262
+ // Every token must appear, rather than the query as one run: a
263
+ // haystack is several things joined, and matching it whole made
264
+ // the joining order load-bearing — "kettle kitchen" found
265
+ // nothing while "kettle" worked.
395
266
  const tokens = queries.get(name) ?? [];
396
267
  const haystack = haystacks.get(item.key)?.get(name) ?? "";
397
268
  if (!tokens.every((token) => haystack.includes(token))) {
398
269
  return false;
399
270
  }
400
271
  } else if (kind === "choice") {
401
- // An item may sit in several categories while the control
402
- // still picks one — see `ItemValue`. One value is the same
403
- // question asked of a set of one.
272
+ // An item may sit in several categories while the control picks
273
+ // one — see `ItemValue`.
404
274
  const wanted = state[name] as string;
405
275
  const held = item.values[name];
406
276
  const holds = Array.isArray(held)
@@ -438,11 +308,8 @@ export function filters<const F extends FieldMap>(
438
308
  if (field === undefined) continue;
439
309
  if (field.param === undefined) {
440
310
  // A state-only field is not in the URL, so the URL has nothing
441
- // to say about it — and a back button is the URL speaking.
442
- // Clearing it here read "absent from the URL" as "empty", which
443
- // wiped a search the reader was in the middle of the moment any
444
- // other parameter on the page moved. Left as it is instead: this
445
- // function applies the URL, and this field is not in it.
311
+ // to say about it. Clearing it here wiped a search mid-typing
312
+ // whenever another parameter on the page moved.
446
313
  next[name] = state[name];
447
314
  continue;
448
315
  }
@@ -455,18 +322,11 @@ export function filters<const F extends FieldMap>(
455
322
  function writeUrl(history: "push" | "replace"): void {
456
323
  if (!attached) return;
457
324
 
458
- // Started from the URL that is there, not from an empty set. A list owns
459
- // the parameters it declared and nothing else, and the page around it is
460
- // full of parameters that are not its business — `utm_*` on a link
461
- // someone shared, a page number, another widget's state. Building from
462
- // scratch published a URL with all of them gone, and the loss only
463
- // showed up somewhere else: a campaign that stopped being attributed the
464
- // first time a visitor typed in the search box.
465
- //
466
- // Its own are deleted first, then rewritten, so a parameter this list
467
- // owns and no longer needs is dropped rather than surviving because
468
- // nobody thought to remove it. Deletion and writing both run in
469
- // declaration order, so one state still produces one URL.
325
+ // Started from the URL that is there: the page is full of parameters
326
+ // that are not this list's business (`utm_*`, a page number), and
327
+ // building from scratch dropped them — a campaign stopped being
328
+ // attributed the first time someone typed in the search box. Its own
329
+ // are deleted first, then rewritten, so one state gives one URL.
470
330
  const search = new URLSearchParams(window.location.search);
471
331
  for (const name of names) {
472
332
  const param = fields[name]?.param;
@@ -484,21 +344,13 @@ export function filters<const F extends FieldMap>(
484
344
  }
485
345
 
486
346
  const query = search.toString();
487
- // The bare path when nothing is left, rather than a trailing `?`: the
488
- // two are the same page, and only one of them is worth sharing. Read
489
- // from the merged set, so a page carrying somebody else's parameter
490
- // keeps it instead of being reduced to its path.
347
+ // The bare path when nothing is left, rather than a trailing `?`.
491
348
  const url = query === "" ? window.location.pathname : `?${query}`;
492
349
  if (history === "push") window.history.pushState({}, "", url);
493
350
  else window.history.replaceState({}, "", url);
494
351
  }
495
352
 
496
- /**
497
- * One field's value as the matcher and the URL will read it.
498
- *
499
- * Only `text` has a form that differs from what was handed over — the other
500
- * kinds compare and publish exactly what they hold.
501
- */
353
+ /** One field's value as the matcher and the URL will read it. */
502
354
  const normalized = (name: keyof F & string, value: unknown): unknown =>
503
355
  fields[name]?.kind === "text" ? (value as string).trim() : value;
504
356
 
@@ -530,18 +382,12 @@ export function filters<const F extends FieldMap>(
530
382
 
531
383
  set(field, value, setOptions) {
532
384
  const name = field as keyof F & string;
533
- // Compared as everything downstream will read it, not as it arrived.
534
- // Both the matcher and the URL trim a query, so "sol" and "sol "
535
- // filter the same list and publish the same address — but comparing
536
- // them raw called that a change, and a trailing space cost a
537
- // recompute, a `replaceState` to a URL identical to the current one,
538
- // and a full re-render of every item.
539
- //
540
- // The *raw* value is what gets stored, though, and that asymmetry is
541
- // deliberate. A search box mirrors the state back into the input
542
- // (see `filters-view`), so normalising here would delete the space a
543
- // reader had just typed, from under the caret, every time they
544
- // reached for the second word.
385
+ // Compared as everything downstream will read it: "sol" and "sol "
386
+ // filter the same list, and comparing them raw cost a recompute, a
387
+ // `replaceState` to the same URL and a full re-render. The *raw*
388
+ // value is stored, though — the search box mirrors the state back
389
+ // into the input, and normalising here would delete the space a
390
+ // reader just typed.
545
391
  if (normalized(name, state[name]) === normalized(name, value)) {
546
392
  return;
547
393
  }
@@ -555,11 +401,8 @@ export function filters<const F extends FieldMap>(
555
401
 
556
402
  reset(field) {
557
403
  if (field === undefined) {
558
- // Short-circuited like the single-field arm below, which it was
559
- // not. A "clear all" on a list nobody has filtered yet is a
560
- // no-op, and pushing for it puts a step in history that goes
561
- // back to the state it is already in — so the back button looks
562
- // broken to the one reader who pressed clear twice.
404
+ // A "clear all" on an unfiltered list is a no-op; pushing for it
405
+ // puts a history step that goes back to where it already is.
563
406
  if (names.every((name) => state[name] === empty(name))) return;
564
407
  state = blank();
565
408
  } else {
@@ -575,10 +418,8 @@ export function filters<const F extends FieldMap>(
575
418
  attach() {
576
419
  const listeners = new AbortController();
577
420
 
578
- // On `window`, and with the same signal as everything else, so a
579
- // detach takes it with them. A `popstate` listener that outlives its
580
- // page is the one leak here that has no visible symptom: it keeps
581
- // rendering into markup that has been replaced.
421
+ // A `popstate` listener that outlives its page is the leak with no
422
+ // visible symptom: it keeps rendering into markup that was replaced.
582
423
  window.addEventListener(
583
424
  "popstate",
584
425
  () => {
@@ -590,10 +431,9 @@ export function filters<const F extends FieldMap>(
590
431
  );
591
432
 
592
433
  attached = true;
593
- // Read before the first announcement, not after: the URL may already
594
- // carry a state — from a shared link, a reload, or a no-script form
595
- // submission the server rendered — and announcing the empty one
596
- // first would flash the unfiltered list over it.
434
+ // Read before the first announcement: the URL may already carry a
435
+ // state, and announcing the empty one first flashes the unfiltered
436
+ // list over it.
597
437
  readUrl();
598
438
  recompute();
599
439
  announce();