fix(post): close location-map race, block submit on bad coords

Code review (4 independent reviewers) converged on the same bug: the
maplibre-gl singleton cached its handle only after import() resolved,
so a fast close/reopen of the "More location details" panel could
race two Map instances onto one container. Cache the in-flight promise
synchronously instead, and propagate/handle import rejection so a
failed map load surfaces a hint instead of hanging silently.

Also closes a submit-time hole the adversarial pass found: the
mismatch flag on lat/lng was purely cosmetic and never blocked
form submission, so out-of-range coordinates could be saved. The
flag now gates submit like the other required fields, and clears
itself when both fields are blanked back out instead of sticking.

The geocode fetch gets a 10s timeout via AbortController so a hung
response can't leave the lookup button disabled forever.

Also moves the location-details CSS out of the site-wide style.css
into post-form's own code-split stylesheet, since none of it is used
outside the post form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 20:03:01 +02:00
co-authored by Claude Sonnet 5
parent 797b472a60
commit 13c76b29a8
6 changed files with 184 additions and 152 deletions
File diff suppressed because one or more lines are too long
+20 -7
View File
@@ -12,10 +12,17 @@
* which calls this on every open and then always calls the returned
* handle's resize() (the container sits under display:none while the panel
* is closed, so the first paint would otherwise get a zero-size canvas).
*
* The cache stores the in-flight PROMISE, not just the resolved handle —
* written synchronously before import() settles. A close/reopen of the panel
* while the maplibre-gl chunk is still loading would otherwise re-enter this
* function and race a second import().then() into building a second Map
* against the same container (caught in code review — confirmed independently
* by four reviewers).
*/
import { MAP_STYLE } from './map-style.js';
var cached = null; // { container, handle }
var cached = null; // { container, promise }
function buildPinElement() {
var el = document.createElement('div');
@@ -25,10 +32,10 @@ function buildPinElement() {
export function getOrCreateLocationMap(container, onDragEnd) {
if (cached && cached.container === container) {
return Promise.resolve(cached.handle);
return cached.promise;
}
return import('maplibre-gl').then(function (mod) {
var promise = import('maplibre-gl').then(function (mod) {
var maplibregl = mod.default || mod;
var map = new maplibregl.Map({
container: container,
@@ -45,7 +52,7 @@ export function getOrCreateLocationMap(container, onDragEnd) {
if (onDragEnd) onDragEnd(marker.getLngLat());
});
var handle = {
return {
setPin: function (lat, lng) {
marker.setLngLat([lng, lat]);
if (!pinSet) { marker.addTo(map); pinSet = true; }
@@ -54,8 +61,14 @@ export function getOrCreateLocationMap(container, onDragEnd) {
hasPin: function () { return pinSet; },
resize: function () { map.resize(); }
};
cached = { container: container, handle: handle };
return handle;
}).catch(function (err) {
// Leave the cache clear so a later retry (e.g. after transient network
// failure) re-attempts the import instead of permanently returning a
// rejected promise for this container.
if (cached && cached.container === container) cached = null;
throw err;
});
cached = { container: container, promise: promise };
return promise;
}
+104
View File
@@ -519,3 +519,107 @@
/* SortableJS drag feedback. */
.photo-editor__cell.sortable-ghost { opacity: 0.4; }
.photo-editor__cell.sortable-chosen { outline: 2px solid var(--color-accent); }
/* "More location details" disclosure — search + map preview for setting an
entry's coordinates without live GPS. Mirrors .more-options's disclosure
look (above); the lat/lng fields (relocated here by JS) and the lookup
button reuse style.css's existing .btn-action/.form-status/.field-invalid
conventions unmodified. */
.location-details {
margin-bottom: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
}
.location-details__summary {
cursor: pointer;
padding: 0.875rem 1rem;
min-height: 44px;
display: flex;
align-items: center;
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-ink);
list-style: none;
user-select: none;
}
.location-details__summary::-webkit-details-marker { display: none; }
.location-details__summary::before {
content: '▸';
margin-right: var(--space-2);
color: var(--color-ink-muted);
transition: transform 0.15s;
}
.location-details[open] .location-details__summary::before { transform: rotate(90deg); }
.location-details[open] .location-details__summary { border-bottom: 1px solid var(--color-border); }
.location-details > .form-field { padding: 0 1rem; }
.location-details > .form-field:first-of-type { padding-top: var(--space-4); }
.location-details > .form-field:last-of-type { padding-bottom: var(--space-2); }
.location-details__body { padding: 1rem; }
.location-search-row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.location-search-hint {
font-size: var(--text-sm);
color: var(--color-ink-muted);
margin-top: var(--space-2);
}
.location-search-hint:empty { display: none; }
.location-search-results {
list-style: none;
margin: var(--space-3) 0 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.location-search-results:empty { display: none; margin: 0; border: none; }
.location-search-results li + li { border-top: 1px solid var(--color-border); }
.location-search-results button {
display: block;
width: 100%;
text-align: left;
padding: 0.75rem 1rem;
min-height: 44px;
background: var(--color-canvas);
border: none;
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-ink);
cursor: pointer;
}
.location-search-results button:hover,
.location-search-results button:focus-visible { background: var(--color-paper); }
.location-map {
position: relative;
width: 100%;
height: 240px;
margin-top: var(--space-4);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-paper);
}
.location-map .maplibregl-canvas { border-radius: var(--radius-md); }
.location-pin {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--color-accent);
border: 3px solid #fff;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
cursor: grab;
}
.location-pin:active { cursor: grabbing; }
/* Mismatch flag: a typed lat/lng that doesn't (yet) parse to a valid pin. */
.location-field--mismatch { border-color: var(--color-error) !important; outline-color: var(--color-error) !important; }
.location-field-note {
display: block;
font-size: var(--text-sm);
color: var(--color-error);
margin-top: var(--space-1);
}
+21 -2
View File
@@ -619,6 +619,10 @@ function initLocationDetails() {
// Only flag once the traveller has actually typed something —
// fresh blank fields are "no pin yet" (R12), not a mismatch.
setMismatch();
} else {
// Both blanked back out after being flagged — nothing left to submit,
// so the flag no longer applies.
clearMismatch();
}
}
syncPinFromFields = syncFields; // expose for initGeo()'s GPS handler
@@ -637,6 +641,8 @@ function initLocationDetails() {
mapHandle = handle;
handle.resize(); // fixes the zero-size canvas from painting while display:none
syncFields();
}).catch(function () {
setHint('Map preview unavailable — you can still enter coordinates directly.');
});
});
@@ -686,7 +692,11 @@ function initLocationDetails() {
lookupBtn.textContent = 'Searching…';
var url = 'https://geocoding-api.open-meteo.com/v1/search?name=' +
encodeURIComponent(query) + '&count=10&language=en&format=json';
fetch(url).then(function (r) { return r.json(); }).then(function (data) {
// Bound the request so a hung/never-resolving response can't leave the
// button stuck disabled on "Searching…" forever.
var controller = new AbortController();
var timeoutId = setTimeout(function () { controller.abort(); }, 10000);
fetch(url, { signal: controller.signal }).then(function (r) { return r.json(); }).then(function (data) {
var list = (data && data.results) || [];
if (!list.length) {
setHint('No matches — try adding a country, or drag the pin on the map.');
@@ -713,8 +723,10 @@ function initLocationDetails() {
}
showResults(list);
}).catch(function () {
// R8: network failure degrades silently — fields untouched.
// R8: network failure (including our own timeout abort) degrades
// silently — fields untouched.
}).then(function () {
clearTimeout(timeoutId);
lookupBtn.disabled = false;
lookupBtn.textContent = LOOKUP_LABEL;
});
@@ -805,6 +817,13 @@ function initValidation() {
if (!firstInvalid) firstInvalid = el;
}
});
// A flagged-but-unresolved lat/lng (typed garbage, never fixed or
// cleared) must not reach the server — the mismatch styling alone
// doesn't block submission (caught in code review).
var mismatchEl = form.querySelector('.location-field--mismatch');
if (mismatchEl && !firstInvalid) firstInvalid = mismatchEl;
if (firstInvalid) {
e.preventDefault();
if (typeof firstInvalid.focus === 'function') firstInvalid.focus();