@mailwoman/react 9.0.0 → 9.2.0

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.
@@ -13,7 +13,7 @@ import { matchPOISubject } from "@mailwoman/kind-classifier"
13
13
  import { emitOverpassQL } from "@mailwoman/poi-taxonomy/overpass"
14
14
  import type { OverpassIntentLike } from "@mailwoman/poi-taxonomy/overpass"
15
15
  import { computeQueryShape } from "@mailwoman/query-shape"
16
- import { useCallback, useEffect, useRef, useState } from "react"
16
+ import { useCallback, useEffect, useEffectEvent, useState } from "react"
17
17
 
18
18
  import { useDebouncedValue } from "../common/useDebouncedValue.ts"
19
19
  import { loadPOIRuntime } from "./runtime.ts"
@@ -103,21 +103,31 @@ export function usePOISearch({
103
103
  debounceMs = 250,
104
104
  }: UsePOISearchOptions): UsePOISearch {
105
105
  const [runtime, setRuntime] = useState<POIRuntime | null>(null)
106
- const [result, setResult] = useState<POIExplorerResult | null>(null)
107
- const [liveSearch, setLiveSearch] = useState<LiveSearchState>({ status: "idle" })
106
+ /**
107
+ * The classify result KEYED BY the query that produced it. The visible result is derived during render
108
+ * (`storedResult.query === trimmedText ? … : null`), so a new query invalidates the old answer by derivation — the
109
+ * effect never writes state synchronously to "reset", which is the react(set-state-in-effect) shape the lint bump
110
+ * rightly flags.
111
+ */
112
+ const [storedResult, setStoredResult] = useState<{ query: string; value: POIExplorerResult } | null>(null)
113
+ /**
114
+ * Live-search state, keyed the same way: launched FOR a query, visible only while that query stands.
115
+ */
116
+ const [storedLive, setStoredLive] = useState<{ query: string; state: LiveSearchState } | null>(null)
108
117
 
109
118
  const debouncedText = useDebouncedValue(text, debounceMs)
119
+ const trimmedText = debouncedText.trim()
110
120
 
111
- // Capture the loader in a ref so the load fires exactly ONCE on mount, regardless of whether the
112
- // caller passes a fresh `loadRuntime` closure each render (an inline `async () => …` would otherwise
113
- // retrigger the effect → reload → re-render loop). The runtime is a load-once resource.
114
- const loadRuntimeRef = useRef(loadRuntime)
115
- loadRuntimeRef.current = loadRuntime
121
+ // The load fires exactly ONCE on mount regardless of whether the caller passes a fresh `loadRuntime`
122
+ // closure each render (an inline `async () => …` would otherwise retrigger the effect → reload →
123
+ // re-render loop). `useEffectEvent` reads the LATEST closure without joining the dependency list —
124
+ // the runtime is a load-once resource.
125
+ const loadRuntimeEvent = useEffectEvent(() => loadRuntime())
116
126
 
117
127
  useEffect(() => {
118
128
  let cancelled = false
119
129
 
120
- loadRuntimeRef.current().then((loaded) => {
130
+ void loadRuntimeEvent().then((loaded) => {
121
131
  if (!cancelled) {
122
132
  setRuntime(loaded)
123
133
  }
@@ -129,20 +139,16 @@ export function usePOISearch({
129
139
  }, [])
130
140
 
131
141
  // Classify the debounced query and derive the subject + OverpassQL (async, so it lives in an effect).
142
+ // Every state write below happens after an await — invalidation on query change is handled by the
143
+ // key derivation above, not by a synchronous reset here.
132
144
  useEffect(() => {
133
145
  if (!runtime) return
134
146
 
135
- let cancelled = false
136
- const trimmed = debouncedText.trim()
137
-
138
- // A new query invalidates live results from the previous one.
139
- setLiveSearch({ status: "idle" })
147
+ const trimmed = trimmedText
140
148
 
141
- if (!trimmed) {
142
- setResult(null)
149
+ if (!trimmed) return
143
150
 
144
- return
145
- }
151
+ let cancelled = false
146
152
 
147
153
  const input = { raw: trimmed, normalized: trimmed }
148
154
  const shape = computeQueryShape(trimmed)
@@ -153,7 +159,7 @@ export function usePOISearch({
153
159
  const matched = kindResult.kind === "poi_query" ? matchPOISubject(trimmed, undefined, runtime.lexicon) : null
154
160
 
155
161
  if (!matched) {
156
- setResult({ kindResult })
162
+ setStoredResult({ query: trimmed, value: { kindResult } })
157
163
 
158
164
  return
159
165
  }
@@ -161,15 +167,18 @@ export function usePOISearch({
161
167
  // Brand subject: the lexicon carries the brand's canonical name as `categoryID` + its Wikidata QID. No category
162
168
  // record, no OverpassQL (brands are searched by QID against the layer's `brand_wikidata` index, not OSM tags).
163
169
  if ((matched.match.kind ?? "category") === "brand") {
164
- setResult({
165
- kindResult,
166
- subject: {
167
- kind: "brand",
168
- name: matched.match.categoryID,
169
- ...(matched.match.wikidata ? { wikidata: matched.match.wikidata } : {}),
170
- matchedPhrase: matched.match.matchedPhrase,
171
- confidence: matched.match.confidence,
172
- remainder: matched.remainder,
170
+ setStoredResult({
171
+ query: trimmed,
172
+ value: {
173
+ kindResult,
174
+ subject: {
175
+ kind: "brand",
176
+ name: matched.match.categoryID,
177
+ ...(matched.match.wikidata ? { wikidata: matched.match.wikidata } : {}),
178
+ matchedPhrase: matched.match.matchedPhrase,
179
+ confidence: matched.match.confidence,
180
+ remainder: matched.remainder,
181
+ },
173
182
  },
174
183
  })
175
184
 
@@ -179,29 +188,37 @@ export function usePOISearch({
179
188
  const category = runtime.lookup.getPOICategory(matched.match.categoryID)
180
189
 
181
190
  if (!category) {
182
- setResult({ kindResult })
191
+ setStoredResult({ query: trimmed, value: { kindResult } })
183
192
 
184
193
  return
185
194
  }
186
195
 
187
- setResult({
188
- kindResult,
189
- subject: {
190
- kind: "category",
191
- category,
192
- matchedPhrase: matched.match.matchedPhrase,
193
- confidence: matched.match.confidence,
194
- remainder: matched.remainder,
195
- buildLocal: runtime.lookup.requiresBuildLocalLayer(category),
196
+ setStoredResult({
197
+ query: trimmed,
198
+ value: {
199
+ kindResult,
200
+ subject: {
201
+ kind: "category",
202
+ category,
203
+ matchedPhrase: matched.match.matchedPhrase,
204
+ confidence: matched.match.confidence,
205
+ remainder: matched.remainder,
206
+ buildLocal: runtime.lookup.requiresBuildLocalLayer(category),
207
+ },
208
+ ...buildOverpass(runtime, matched.match.categoryID, matched.match.matchedPhrase, matched.remainder),
196
209
  },
197
- ...buildOverpass(runtime, matched.match.categoryID, matched.match.matchedPhrase, matched.remainder),
198
210
  })
199
211
  })
200
212
 
201
213
  return () => {
202
214
  cancelled = true
203
215
  }
204
- }, [debouncedText, runtime])
216
+ }, [trimmedText, runtime])
217
+
218
+ // Derived visibility: an answer stands only while its query does. A new query reads as null/idle
219
+ // with no reset write anywhere.
220
+ const result = storedResult?.query === trimmedText ? storedResult.value : null
221
+ const liveSearch: LiveSearchState = storedLive?.query === trimmedText ? storedLive.state : { status: "idle" }
205
222
 
206
223
  const subject = result?.subject
207
224
 
@@ -221,7 +238,7 @@ export function usePOISearch({
221
238
 
222
239
  if (subject.kind === "category" ? subject.buildLocal : !(brandLiveSearch && subject.wikidata)) return
223
240
 
224
- setLiveSearch({ status: "loading" })
241
+ setStoredLive({ query: trimmedText, state: { status: "loading" } })
225
242
 
226
243
  try {
227
244
  const outcome = await runLiveSearch(
@@ -241,16 +258,25 @@ export function usePOISearch({
241
258
  )
242
259
 
243
260
  if (outcome.status === "success") {
244
- setLiveSearch({ status: "success", hits: outcome.hits, centerName: outcome.centerName })
261
+ setStoredLive({
262
+ query: trimmedText,
263
+ state: { status: "success", hits: outcome.hits, centerName: outcome.centerName },
264
+ })
245
265
  } else if (outcome.status === "unplaced") {
246
- setLiveSearch({ status: "error", message: `couldn't place "${outcome.anchor}"` })
266
+ setStoredLive({ query: trimmedText, state: { status: "error", message: `couldn't place "${outcome.anchor}"` } })
247
267
  } else {
248
- setLiveSearch({ status: "error", message: "the published POI layer isn't reachable" })
268
+ setStoredLive({
269
+ query: trimmedText,
270
+ state: { status: "error", message: "the published POI layer isn't reachable" },
271
+ })
249
272
  }
250
273
  } catch {
251
- setLiveSearch({ status: "error", message: "the published POI layer isn't reachable" })
274
+ setStoredLive({
275
+ query: trimmedText,
276
+ state: { status: "error", message: "the published POI layer isn't reachable" },
277
+ })
252
278
  }
253
- }, [runLiveSearch, runtime, subject, brandLiveSearch])
279
+ }, [runLiveSearch, runtime, subject, brandLiveSearch, trimmedText])
254
280
 
255
281
  return { runtimeReady: runtime !== null, result, liveSearch, canSearchLive, searchLive }
256
282
  }
@@ -189,15 +189,18 @@ export function useDemoRuntime<TAssets, TRelease extends DemoReleaseBase = DemoR
189
189
  // Latest-ref the injected loaders: a host that re-creates them each render (an inline arrow) must NOT retrigger the
190
190
  // load effects, which key ONLY on version/backend. The effects read `.current` at run time.
191
191
  const loadManifestRef = useRef(config.loadManifest)
192
- loadManifestRef.current = config.loadManifest
193
192
  const loadAssetsRef = useRef(config.loadAssets)
194
- loadAssetsRef.current = config.loadAssets
195
193
 
196
194
  // Latest manifest for the version-load effect, so it can resolve the release WITHOUT depending on `manifest`
197
195
  // identity — which would double-fire the load the instant the manifest first arrives (the selection transition
198
196
  // null → defaultVersion already fires it once).
199
197
  const manifestRef = useRef<DemoManifest<TRelease> | null>(null)
200
- manifestRef.current = manifest
198
+
199
+ useEffect(() => {
200
+ loadManifestRef.current = config.loadManifest
201
+ loadAssetsRef.current = config.loadAssets
202
+ manifestRef.current = manifest
203
+ }, [config.loadAssets, config.loadManifest, manifest])
201
204
 
202
205
  // Mount: fetch the manifest, then select the default version.
203
206
  useEffect(() => {
package/styles.css CHANGED
@@ -15,7 +15,7 @@
15
15
  font-size: 0.95rem;
16
16
  }
17
17
 
18
- /* ── Field (label + input + button) ──────────────────────────────────────── */
18
+ /* #region Field (label + input + button) */
19
19
 
20
20
  .mw-field {
21
21
  display: flex;
@@ -60,7 +60,9 @@
60
60
  cursor: not-allowed;
61
61
  }
62
62
 
63
- /* ── Preset chips ────────────────────────────────────────────────────────── */
63
+ /* #endregion */
64
+
65
+ /* #region Preset chips */
64
66
 
65
67
  .mw-presets {
66
68
  margin-top: 0.75rem;
@@ -98,7 +100,9 @@
98
100
  cursor: not-allowed;
99
101
  }
100
102
 
101
- /* ── Generic button (copy / search) ──────────────────────────────────────── */
103
+ /* #endregion */
104
+
105
+ /* #region Generic button (copy / search) */
102
106
 
103
107
  .mw-btn {
104
108
  font-size: 0.8rem;
@@ -120,7 +124,9 @@
120
124
  cursor: not-allowed;
121
125
  }
122
126
 
123
- /* ── Result panel + errors ───────────────────────────────────────────────── */
127
+ /* #endregion */
128
+
129
+ /* #region Result panel + errors */
124
130
 
125
131
  .mw-result {
126
132
  margin-top: 1.5rem;
@@ -161,7 +167,9 @@
161
167
  white-space: pre-wrap;
162
168
  }
163
169
 
164
- /* ── KindBadge ───────────────────────────────────────────────────────────── */
170
+ /* #endregion */
171
+
172
+ /* #region KindBadge */
165
173
 
166
174
  .mw-kind {
167
175
  margin-bottom: 0.5rem;
@@ -183,7 +191,9 @@
183
191
  list-style: disc;
184
192
  }
185
193
 
186
- /* ── POI subject panel ───────────────────────────────────────────────────── */
194
+ /* #endregion */
195
+
196
+ /* #region POI subject panel */
187
197
 
188
198
  .mw-subject__row {
189
199
  display: flex;
@@ -258,7 +268,9 @@
258
268
  margin: 0;
259
269
  }
260
270
 
261
- /* ── OverpassQL block ────────────────────────────────────────────────────── */
271
+ /* #endregion */
272
+
273
+ /* #region OverpassQL block */
262
274
 
263
275
  .mw-overpass {
264
276
  margin-top: 0.75rem;
@@ -275,7 +287,9 @@
275
287
  overflow-x: auto;
276
288
  }
277
289
 
278
- /* ── Live results ────────────────────────────────────────────────────────── */
290
+ /* #endregion */
291
+
292
+ /* #region Live results */
279
293
 
280
294
  .mw-live {
281
295
  margin-top: 1rem;
@@ -317,7 +331,9 @@
317
331
  white-space: nowrap;
318
332
  }
319
333
 
320
- /* ── Component table ─────────────────────────────────────────────────────── */
334
+ /* #endregion */
335
+
336
+ /* #region Component table */
321
337
 
322
338
  .mw-components {
323
339
  width: 100%;
@@ -340,7 +356,9 @@
340
356
  text-transform: uppercase;
341
357
  }
342
358
 
343
- /* ── Confidence cell ─────────────────────────────────────────────────────── */
359
+ /* #endregion */
360
+
361
+ /* #region Confidence cell */
344
362
 
345
363
  .mw-conf__dash {
346
364
  color: var(--ifm-color-emphasis-500, #8c8c8c);
@@ -377,7 +395,9 @@
377
395
  font-size: 0.8rem;
378
396
  }
379
397
 
380
- /* ── Resolved place ──────────────────────────────────────────────────────── */
398
+ /* #endregion */
399
+
400
+ /* #region Resolved place */
381
401
 
382
402
  .mw-resolved {
383
403
  margin-top: 1rem;
@@ -416,7 +436,9 @@
416
436
  font-size: 0.9rem;
417
437
  }
418
438
 
419
- /* ── Candidate picker ────────────────────────────────────────────────────── */
439
+ /* #endregion */
440
+
441
+ /* #region Candidate picker */
420
442
 
421
443
  .mw-candidates {
422
444
  margin-top: 1rem;
@@ -471,7 +493,9 @@
471
493
  color: var(--ifm-color-emphasis-600, #6e6e6e);
472
494
  }
473
495
 
474
- /* ── LoadingIndicator ────────────────────────────────────────────────────── */
496
+ /* #endregion */
497
+
498
+ /* #region LoadingIndicator */
475
499
 
476
500
  .mw-loading {
477
501
  display: flex;
@@ -647,7 +671,9 @@
647
671
  transform: translateY(-60%) rotate(45deg);
648
672
  }
649
673
 
650
- /* ── Demo map (react-map-gl/maplibre shell) ──────────────────────────────── */
674
+ /* #endregion */
675
+
676
+ /* #region Demo map (react-map-gl/maplibre shell) */
651
677
 
652
678
  /*
653
679
  * The <DemoMap> wrapper. Sizing is the host's call (pass `style`/`className`), but a min-height keeps
@@ -661,8 +687,6 @@
661
687
  overflow: hidden;
662
688
  }
663
689
 
664
- /* ── Geocoder demo (composed: full-viewport map + floating control panel) ──── */
665
-
666
690
  /*
667
691
  * The demo fills its positioning context; the host sizes it (a full-viewport wrapper in docs). The map is the
668
692
  * background layer, the control panel floats over the top-left (bottom on narrow screens). A host that wants a
@@ -705,7 +729,9 @@
705
729
  }
706
730
  }
707
731
 
708
- /* ── Demo control field (version / compare-version selects) ──────────────── */
732
+ /* #endregion */
733
+
734
+ /* #region Demo control field (version / compare-version selects) */
709
735
 
710
736
  .mw-demo-control {
711
737
  margin-bottom: 0.75rem;
@@ -732,7 +758,9 @@
732
758
  opacity: 0.5;
733
759
  }
734
760
 
735
- /* ── Backend indicator + WASM toggle / compare toggle ────────────────────── */
761
+ /* #endregion */
762
+
763
+ /* #region Backend indicator + WASM toggle / compare toggle */
736
764
 
737
765
  .mw-demo-backend,
738
766
  .mw-demo-compare {
@@ -771,7 +799,9 @@
771
799
  color: var(--ifm-color-emphasis-600, #6e6e6e);
772
800
  }
773
801
 
774
- /* ── Place autocomplete listbox (reuses .mw-chip) ────────────────────────── */
802
+ /* #endregion */
803
+
804
+ /* #region Place autocomplete listbox (reuses .mw-chip) */
775
805
 
776
806
  .mw-demo-suggest {
777
807
  margin-top: 0.75rem;