Skip to content

Commit cd7ae01

Browse files
committed
Address review feedback (round 4)
- Register a restored loader synchronously and fetch the service metadata lazily on its first query, memoized with the memo cleared on failure. A metadata fetch that failed once left the layer permanently unbound, with refresh reporting a transient-sounding error that nothing ever retried; now the next pan or refresh retries it, and there is no window in which a refresh finds the layer unbound at all. - Reword the refresh error accordingly: no loader now means no map, not a race with startup. - Drop the unreachable abort branch in fetchArcGISFeatureCount — a viewport query is the only cancellable caller and planArcGISPaging skips the count for those, so the signal was always undefined where it ran. - Test restoreArcGISViewportLayers end to end: synchronous binding, a failed metadata read reported and retried on the next pan, rebinding when the map instance changes, and reloadArcGISViewportLayer re-querying the viewport.
1 parent 1c13c0d commit cd7ae01

3 files changed

Lines changed: 119 additions & 27 deletions

File tree

apps/geolibre-desktop/src/lib/layer-refresh.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -579,12 +579,12 @@ async function refreshArcGISLayer(layer: GeoLibreLayer): Promise<GeoJsonRefreshR
579579
await import("@geolibre/plugins");
580580
if (layer.metadata.viewportLoading === true) {
581581
const viewport = reloadArcGISViewportLayer(layer.id);
582-
// No live loader yet: a just-reopened project is still resolving the
583-
// service metadata its loader needs, or that resolve failed. Falling
584-
// through to the unbounded replay below would download the entire service
585-
// — the cost this layer is loaded by viewport to avoid — so say so instead.
582+
// No loader at all: the layer is in a host with no map (`restoreArcGISViewportLayers`
583+
// registers one synchronously wherever there is one). Falling through to
584+
// the unbounded replay below would download the entire service — the cost
585+
// this layer is loaded by viewport to avoid — so say so instead.
586586
if (!viewport) {
587-
throw new Error("This layer is still binding to the map viewport. Try again in a moment.");
587+
throw new Error("This layer is not bound to a map viewport, so it cannot be refreshed.");
588588
}
589589
const bounded = await viewport;
590590
return { geojson: bounded, featureCount: bounded.features.length };

packages/plugins/src/plugins/arcgis-layer.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -445,17 +445,23 @@ async function addArcGISFeatureLayerAsGeoJson(
445445

446446
const bounds = arcgisExtentToBounds(layerInfo.extent);
447447
if (bounds && options.zoomTo !== false) app.fitBounds?.(bounds);
448-
if (map) startArcGISViewportLoader(id, map, queryUrl, options, layerInfo);
448+
if (map) startArcGISViewportLoader(id, map, queryUrl, options, () => Promise.resolve(layerInfo));
449449
return id;
450450
}
451451

452-
/** Keep one FeatureServer layer synchronized with the settled map viewport. */
452+
/**
453+
* Keep one FeatureServer layer synchronized with the settled map viewport.
454+
*
455+
* `resolveLayerInfo` is a thunk rather than a value so the restore path can
456+
* register a loader before it has the service metadata: the fetch then happens
457+
* on the first query, and a failed one is retried by the next.
458+
*/
453459
function startArcGISViewportLoader(
454460
layerId: string,
455461
map: maplibregl.Map,
456462
queryUrl: string,
457463
options: ArcGISLayerOptions,
458-
layerInfo: ArcGISFeatureLayerInfo,
464+
resolveLayerInfo: () => Promise<ArcGISFeatureLayerInfo>,
459465
): void {
460466
let abort: AbortController | null = null;
461467
let requestSequence = 0;
@@ -488,6 +494,13 @@ function startArcGISViewportLoader(
488494
abort = controller;
489495
loader.abort = controller;
490496
const sequence = ++requestSequence;
497+
let layerInfo: ArcGISFeatureLayerInfo;
498+
try {
499+
layerInfo = await resolveLayerInfo();
500+
} catch (error) {
501+
if (sequence !== requestSequence) return currentArcGISLayerGeojson(layerId);
502+
throw error;
503+
}
491504
const envelopes = arcgisViewportEnvelopes(map.getBounds());
492505
// One bucket per envelope, so a viewport split across the antimeridian
493506
// publishes both halves together instead of each replacing the other.
@@ -579,6 +592,10 @@ export function reloadArcGISViewportLayer(layerId: string): Promise<FeatureColle
579592
* view when it was saved: panning fetches nothing, and a refresh falls back to
580593
* the unbounded download the viewport path exists to avoid.
581594
*
595+
* Loaders are registered synchronously, before the service metadata they need
596+
* is fetched, so there is no window in which a refresh finds the layer
597+
* unbound.
598+
*
582599
* @param app - The host app API, for the map the loaders bind to.
583600
*/
584601
export function restoreArcGISViewportLayers(app: GeoLibreAppAPI): void {
@@ -608,21 +625,23 @@ export function restoreArcGISViewportLayers(app: GeoLibreAppAPI): void {
608625
pageSize: typeof source.pageSize === "number" ? source.pageSize : undefined,
609626
sourceType: "url",
610627
};
611-
const layerId = layer.id;
612628
// Re-read the service metadata rather than trusting a stored copy, the same
613629
// reason refreshArcGISFeatureLayer does: paging capabilities and
614-
// `maxRecordCount` are the service's to change between sessions.
615-
void fetchArcGISJson<ArcGISFeatureLayerInfo>(
616-
trimTrailingSlash(queryUrl).replace(/\/query$/i, ""),
617-
options,
618-
undefined,
619-
)
620-
.then((layerInfo) => {
621-
// The layer may have been removed while the metadata was in flight.
622-
if (!useAppStore.getState().layers.some((entry) => entry.id === layerId)) return;
623-
startArcGISViewportLoader(layerId, map, queryUrl, options, layerInfo);
624-
})
625-
.catch((error: unknown) => handleArcGISViewportError(layerId, error));
630+
// `maxRecordCount` are the service's to change between sessions. Fetched
631+
// lazily on the first query and memoized, with a failure clearing the
632+
// memo — so a blocked or flaky reopen retries on the next pan or refresh
633+
// instead of leaving the layer permanently unbound.
634+
let pending: Promise<ArcGISFeatureLayerInfo> | null = null;
635+
const resolveLayerInfo = (): Promise<ArcGISFeatureLayerInfo> =>
636+
(pending ??= fetchArcGISJson<ArcGISFeatureLayerInfo>(
637+
trimTrailingSlash(queryUrl).replace(/\/query$/i, ""),
638+
options,
639+
undefined,
640+
).catch((error: unknown) => {
641+
pending = null;
642+
throw error;
643+
}));
644+
startArcGISViewportLoader(layer.id, map, queryUrl, options, resolveLayerInfo);
626645
}
627646
}
628647

@@ -1241,7 +1260,7 @@ async function planArcGISPaging(
12411260
supportsOrderBy: layerInfo.advancedQueryCapabilities?.supportsOrderBy !== false,
12421261
// Spatial counts can be as expensive as fetching the first page on large
12431262
// polygon services. Start rendering immediately for viewport queries.
1244-
total: request.params ? null : await fetchArcGISFeatureCount(queryUrl, params, request.signal),
1263+
total: request.params ? null : await fetchArcGISFeatureCount(queryUrl, params),
12451264
};
12461265
}
12471266

@@ -1275,13 +1294,16 @@ function positiveInteger(value: number | undefined): number | null {
12751294
* condition. Best-effort: a service that will not answer `returnCountOnly`
12761295
* still pages fine, so any failure resolves to `null` rather than throwing.
12771296
*
1297+
* Takes no abort signal, and needs none: {@link planArcGISPaging} skips the
1298+
* count entirely for a viewport query (the only cancellable caller), because a
1299+
* spatial count can cost as much as the first page.
1300+
*
12781301
* @param queryUrl - The layer's `/query` endpoint.
1279-
* @param token - The access token to send, if any.
1302+
* @param params - The query params every request in the plan shares.
12801303
*/
12811304
async function fetchArcGISFeatureCount(
12821305
queryUrl: string,
12831306
params: Record<string, string | undefined>,
1284-
signal?: AbortSignal,
12851307
): Promise<number | null> {
12861308
try {
12871309
const response = await fetch(
@@ -1291,15 +1313,13 @@ async function fetchArcGISFeatureCount(
12911313
returnCountOnly: "true",
12921314
where: "1=1",
12931315
}),
1294-
{ signal },
12951316
);
12961317
if (!response.ok) return null;
12971318
const json = (await response.json()) as { count?: unknown };
12981319
return typeof json.count === "number" && Number.isFinite(json.count) && json.count >= 0
12991320
? json.count
13001321
: null;
1301-
} catch (error) {
1302-
if (signal?.aborted) throw error;
1322+
} catch {
13031323
return null;
13041324
}
13051325
}

tests/arcgis-feature-layer.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { GeoLibreAppAPI } from "../packages/plugins/src/types";
55
import {
66
addArcGISLayer,
77
refreshArcGISFeatureLayer,
8+
reloadArcGISViewportLayer,
9+
restoreArcGISViewportLayers,
810
} from "../packages/plugins/src/plugins/arcgis-layer";
911

1012
// Minimal ArcGIS FeatureServer layer metadata (the `?f=json` response) with a
@@ -461,6 +463,76 @@ describe("addArcGISLayer (feature layer)", () => {
461463
assert.equal(layer?.geojson?.features.length, 1, "the shared feature is published once");
462464
});
463465

466+
it("re-binds a reopened project's layer to the viewport", async () => {
467+
const geometries: string[] = [];
468+
let metadataFailures = 1;
469+
globalThis.fetch = (async (input: RequestInfo | URL) => {
470+
const url = new URL(typeof input === "string" ? input : input.toString());
471+
if (!url.pathname.endsWith("/query")) {
472+
// The first metadata read fails, as a flaky reopen would.
473+
if (metadataFailures > 0) {
474+
metadataFailures -= 1;
475+
throw new Error("Service unavailable");
476+
}
477+
return jsonResponse(VIEWPORT_LAYER_INFO);
478+
}
479+
const offset = Number(url.searchParams.get("resultOffset") ?? "0");
480+
if (offset > 0) return jsonResponse({ type: "FeatureCollection", features: [] });
481+
geometries.push(url.searchParams.get("geometry") ?? "");
482+
return jsonResponse({ type: "FeatureCollection", features: [viewportFeature(1)] });
483+
}) as typeof fetch;
484+
485+
// A layer as a saved project restores it: viewport metadata and the stored
486+
// query URL, but no live loader.
487+
const id = useAppStore
488+
.getState()
489+
.addGeoJsonLayer("Restored", { type: "FeatureCollection", features: [] }, undefined, null);
490+
useAppStore.getState().updateLayer(id, {
491+
source: { type: "geojson", arcgisQueryUrl: `${SERVICE_URL}/query` },
492+
metadata: { sourceKind: "arcgis-feature-query", viewportLoading: true },
493+
});
494+
assert.equal(reloadArcGISViewportLayer(id), null, "no loader before the project is restored");
495+
496+
const view = fakeViewportMap([144, -39, 146, -37]);
497+
restoreArcGISViewportLayers({ getMap: () => view.map } as unknown as GeoLibreAppAPI);
498+
// Registered before the metadata fetch is even started, so a refresh in
499+
// this window never finds the layer unbound.
500+
assert.ok(view.listeners.has("moveend"), "the loader binds synchronously");
501+
await settle();
502+
503+
// The first query failed on metadata, which is reported, not swallowed.
504+
assert.match(
505+
useAppStore.getState().layers.find((layer) => layer.id === id)?.connection?.lastError ?? "",
506+
/Service unavailable/,
507+
);
508+
509+
// The next pan retries the metadata rather than staying stuck.
510+
view.setBounds([150, -35, 152, -33]);
511+
view.listeners.get("moveend")?.();
512+
await settle();
513+
assert.deepEqual(geometries, ["150,-35,152,-33"]);
514+
const restored = useAppStore.getState().layers.find((layer) => layer.id === id);
515+
assert.equal(restored?.connection?.lastError, null);
516+
assert.equal(restored?.geojson?.features.length, 1);
517+
518+
// Re-running the restore against a new map rebinds rather than skipping.
519+
const remounted = fakeViewportMap([10, 10, 12, 12]);
520+
restoreArcGISViewportLayers({ getMap: () => remounted.map } as unknown as GeoLibreAppAPI);
521+
await settle();
522+
assert.deepEqual(
523+
view.offCalls.map(([event]) => event),
524+
["moveend"],
525+
"the loader detaches from the old map",
526+
);
527+
assert.ok(remounted.listeners.has("moveend"));
528+
assert.deepEqual(geometries, ["150,-35,152,-33", "10,10,12,12"]);
529+
530+
// A refresh takes this same bounded path rather than the unbounded replay.
531+
const reloaded = await reloadArcGISViewportLayer(id);
532+
assert.equal(reloaded?.features.length, 1);
533+
assert.deepEqual(geometries, ["150,-35,152,-33", "10,10,12,12", "10,10,12,12"]);
534+
});
535+
464536
it("holds a split viewport to maxFeatures across both envelopes", async () => {
465537
// Each half answers with the cap's worth of distinct features, so an
466538
// uncoordinated limit would leave the layer holding twice the maximum.

0 commit comments

Comments
 (0)