fix: synchronize dynamic GeoServer layer lifecycle
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 59s

Optional sources became dynamic while dependent labels and controller state remained static, allowing stale requests to restore removed layers. Register dependent layers through the same lifecycle and invalidate target request generations before release.
This commit is contained in:
2026-09-11 18:25:41 +08:00
parent 31acd33a2e
commit bf44cc0f25
5 changed files with 303 additions and 13 deletions
@@ -67,11 +67,8 @@ export function useWorkbenchMapController({
controller.getSnapshot controller.getSnapshot
); );
useEffect(() => { useEffect(() => {
const target = state.target; if (availableSourceIds) controller.syncAvailableSources(availableSourceIds);
if (target && availableSourceIds && !availableSourceIds.includes(target.sourceId)) { }, [availableSourceIds, controller]);
controller.clearHighlight();
}
}, [availableSourceIds, controller, state.target]);
useEffect(() => () => controller.destroy(), [controller]); useEffect(() => () => controller.destroy(), [controller]);
return { controller, state }; return { controller, state };
} }
@@ -276,7 +276,10 @@ export function useWorkbenchMap({
REQUIRED_GEOSERVER_SOURCE_IDS REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer)); ).forEach((layer) => map.addLayer(layer));
scadaInteractionLayers.forEach((layer) => map.addLayer(layer)); scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
createValueLabelLayers().forEach((layer) => map.addLayer(layer)); filterWaterNetworkLayersBySourceIds(
createValueLabelLayers(),
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers simulationAnnotationLayers
.filter((layer) => layer.type === "symbol") .filter((layer) => layer.type === "symbol")
.forEach((layer) => map.addLayer(layer)); .forEach((layer) => map.addLayer(layer));
@@ -461,6 +464,17 @@ function syncDataDependentGeoServerLayers(
availableSourceIdSet, availableSourceIdSet,
layerVisibility layerVisibility
); );
addMissingDataDependentLayers(
map,
createValueLabelLayers(),
[
...simulationAnnotationLayers.filter((layer) => layer.type === "symbol"),
...waterNetworkHitLayers,
...scadaHitLayers
].map((layer) => layer.id),
availableSourceIdSet,
layerVisibility
);
addMissingDataDependentLayers( addMissingDataDependentLayers(
map, map,
waterNetworkHitLayers, waterNetworkHitLayers,
@@ -58,6 +58,155 @@ describe("WorkbenchMapController", () => {
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND"); expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
}); });
it("drops cached features and presentation state when a dynamic source disappears", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
let collection = pointCollection;
const fetchFeatures = vi.fn(async () => collection);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures
});
const target = { sourceId: "valves" as const, featureId: "V-1" };
await controller.locateAndHighlight(target);
controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.5 });
await controller.showValueLabel({
sourceId: "valves",
property: "elevation",
precision: 1,
showValue: true,
showId: false,
textSize: 12,
textColor: "#0F172A"
});
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
expect(controller.getSnapshot()).toMatchObject({
target: null,
valueLabels: {},
styledGroups: []
});
collection = {
...pointCollection,
features: [{
...pointCollection.features[0],
geometry: { type: "Point", coordinates: [121.9, 29.1] }
}]
};
map.easeTo.mockClear();
await controller.locateAndHighlight(target);
expect(fetchFeatures).toHaveBeenCalledTimes(2);
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [121.9, 29.1] }));
});
it("ignores an in-flight dynamic-source result from an earlier availability generation", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
resolveFeatures(pointCollection);
await locating;
expect(map.easeTo).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
});
it("keeps a newer cached target when an older dynamic-source request resolves", async () => {
const map = createMap();
let resolveDynamicFeatures = (_collection: FeatureCollection) => {};
const dynamicFeatureResponse = new Promise<FeatureCollection>((resolve) => {
resolveDynamicFeatures = resolve;
});
const fetchFeatures = vi.fn(async (sourceId: string) =>
sourceId === "valves" ? dynamicFeatureResponse : pointCollection
);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures
});
const cachedTarget = { sourceId: "junctions" as const, featureId: "J-1" };
await controller.locateAndHighlight(cachedTarget);
const locatingDynamicTarget = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
await controller.locateAndHighlight(cachedTarget);
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
resolveDynamicFeatures(pointCollection);
await locatingDynamicTarget;
expect(map.easeTo).toHaveBeenCalledTimes(2);
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
});
it("does not restore a target after its in-flight request is cleared", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.clearHighlight();
resolveFeatures(pointCollection);
await locating;
expect(map.easeTo).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({ target: null, pending: false });
});
it("reports an unavailable target when its source disappears in flight", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
resolveFeatures(pointCollection);
await locating;
expect(controller.getSnapshot()).toMatchObject({
target: null,
pending: false,
errorCode: "WFS_UNAVAILABLE"
});
});
it("fits line bounds and uses responsive workbench padding", async () => { it("fits line bounds and uses responsive workbench padding", async () => {
const map = createMap(); const map = createMap();
const padding = { top: 50, right: 420, bottom: 50, left: 320 }; const padding = { top: 50, right: 420, bottom: 50, left: 320 };
@@ -27,6 +27,7 @@ import {
normalizeMapFeatureCollection, normalizeMapFeatureCollection,
parseMapFeatureQuery parseMapFeatureQuery
} from "./map-feature-query"; } from "./map-feature-query";
import { DATA_DEPENDENT_GEOSERVER_SOURCE_IDS } from "./geoserver-layer-availability";
import { import {
applyValueLabelStyle, applyValueLabelStyle,
clearValueLabels, clearValueLabels,
@@ -110,6 +111,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
private flowState: FlowOverlayState = createFlowOverlayState(); private flowState: FlowOverlayState = createFlowOverlayState();
private flowUpdateRevision = 0; private flowUpdateRevision = 0;
private scadaAnalysisRevision = 0; private scadaAnalysisRevision = 0;
private targetRequestRevision = 0;
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
private sourceAvailabilityGenerations = new Map<WaterNetworkSourceId, number>();
constructor(private readonly options: WorkbenchMapControllerOptions) {} constructor(private readonly options: WorkbenchMapControllerOptions) {}
@@ -121,16 +126,20 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
getSnapshot = () => this.state; getSnapshot = () => this.state;
async zoomToFeature(target: FeatureTarget) { async zoomToFeature(target: FeatureTarget) {
const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return; if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target); const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
const map = this.requireTargetMap(target); const map = this.requireTargetMap(target);
if (!feature || !map) return; if (!feature || !map) return;
this.moveCameraToFeature(map, feature); this.moveCameraToFeature(map, feature);
} }
async locateAndHighlight(target: FeatureTarget) { async locateAndHighlight(target: FeatureTarget) {
const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return; if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target); const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
const map = this.requireTargetMap(target); const map = this.requireTargetMap(target);
if (!feature || !map) return; if (!feature || !map) return;
if (!this.moveCameraToFeature(map, feature)) return; if (!this.moveCameraToFeature(map, feature)) return;
@@ -167,12 +176,15 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
} }
async highlight(target: FeatureTarget) { async highlight(target: FeatureTarget) {
const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return; if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target); const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
if (feature) this.setHighlight(target); if (feature) this.setHighlight(target);
} }
clearHighlight = () => { clearHighlight = () => {
this.beginTargetCommand();
const map = this.options.getMap(); const map = this.options.getMap();
if (map && this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) { if (map && this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted"); map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
@@ -181,6 +193,73 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.patchState({ target: null, errorCode: null }); this.patchState({ target: null, errorCode: null });
}; };
syncAvailableSources(sourceIds: readonly WaterNetworkSourceId[]) {
const availableSourceIds = new Set<WaterNetworkSourceId>(sourceIds);
if (this.availableSourceIds) {
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (this.availableSourceIds?.has(sourceId) && !availableSourceIds.has(sourceId)) {
this.sourceAvailabilityGenerations.set(
sourceId,
(this.sourceAvailabilityGenerations.get(sourceId) ?? 0) + 1
);
}
});
}
this.availableSourceIds = availableSourceIds;
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (availableSourceIds.has(sourceId)) return;
const cachePrefix = `${sourceId}:`;
for (const key of this.cachedFeatures.keys()) {
if (key.startsWith(cachePrefix)) this.cachedFeatures.delete(key);
}
});
const targetUnavailable = Boolean(
this.highlightedTarget && !availableSourceIds.has(this.highlightedTarget.sourceId)
);
const pendingTargetUnavailable = Boolean(
this.pendingTargetSourceId && !availableSourceIds.has(this.pendingTargetSourceId)
);
if (pendingTargetUnavailable) {
this.targetRequestRevision += 1;
this.pendingTargetSourceId = null;
}
if (targetUnavailable && this.highlightedTarget) {
const map = this.options.getMap();
if (map?.getSource(this.highlightedTarget.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
}
this.highlightedTarget = null;
}
const valueLabelEntries = Object.entries(this.state.valueLabels);
const availableValueLabelEntries = valueLabelEntries.filter(([sourceId]) =>
availableSourceIds.has(sourceId as WaterNetworkSourceId)
);
const valueLabelsChanged = availableValueLabelEntries.length !== valueLabelEntries.length;
const styledGroups = this.state.styledGroups.filter((groupId) =>
availableSourceIds.has(groupId)
);
const styledGroupsChanged = styledGroups.length !== this.state.styledGroups.length;
if (
!targetUnavailable &&
!pendingTargetUnavailable &&
!valueLabelsChanged &&
!styledGroupsChanged
) return;
this.patchState({
target: targetUnavailable ? null : this.state.target,
pending: pendingTargetUnavailable ? false : this.state.pending,
errorCode: pendingTargetUnavailable ? "WFS_UNAVAILABLE" : this.state.errorCode,
valueLabels: valueLabelsChanged
? Object.fromEntries(availableValueLabelEntries) as WorkbenchMapControllerState["valueLabels"]
: this.state.valueLabels,
styledGroups
});
}
async setFlowVisible(visible: boolean) { async setFlowVisible(visible: boolean) {
const map = this.requireMap(); const map = this.requireMap();
if (!map) return; if (!map) return;
@@ -445,29 +524,52 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
destroy() { destroy() {
this.flowUpdateRevision += 1; this.flowUpdateRevision += 1;
this.targetRequestRevision += 1;
this.pendingTargetSourceId = null;
const map = this.options.getMap(); const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState); if (map) hideFlowOverlay(map, this.flowState);
this.listeners.clear(); this.listeners.clear();
} }
private async resolveTarget(target: FeatureTarget) { private beginTargetCommand() {
const requestRevision = ++this.targetRequestRevision;
if (this.pendingTargetSourceId) {
this.pendingTargetSourceId = null;
this.patchState({ pending: false });
}
return requestRevision;
}
private async resolveTarget(target: FeatureTarget, requestRevision: number) {
const map = this.requireMap(); const map = this.requireMap();
if (!map) return null; if (!map) return null;
const key = `${target.sourceId}:${target.featureId}`; const key = `${target.sourceId}:${target.featureId}`;
const cached = this.cachedFeatures.get(key); const dataDependent = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
target.sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
);
const cached = dataDependent ? undefined : this.cachedFeatures.get(key);
if (cached) return cached; if (cached) return cached;
const sourceAvailabilityGeneration = this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0;
this.pendingTargetSourceId = target.sourceId;
this.patchState({ pending: true, errorCode: null }); this.patchState({ pending: true, errorCode: null });
try { try {
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]); const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
if (
requestRevision !== this.targetRequestRevision ||
sourceAvailabilityGeneration !== (this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0)
) return null;
this.pendingTargetSourceId = null;
const feature = collection.features[0]; const feature = collection.features[0];
if (!feature) { if (!feature) {
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" }); this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
return null; return null;
} }
this.cachedFeatures.set(key, feature); if (!dataDependent) this.cachedFeatures.set(key, feature);
this.patchState({ pending: false }); this.patchState({ pending: false });
return feature; return feature;
} catch { } catch {
if (requestRevision !== this.targetRequestRevision) return null;
this.pendingTargetSourceId = null;
this.patchState({ this.patchState({
pending: false, pending: false,
errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE" errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE"
+29 -1
View File
@@ -61,10 +61,38 @@ test("shows data-dependent layer controls only when GeoServer reports features",
await expect(page.getByRole("button", { name: /^水箱/ })).toBeVisible(); await expect(page.getByRole("button", { name: /^水箱/ })).toBeVisible();
await expect.poll(() => page.evaluate(() => ({ await expect.poll(() => page.evaluate(() => ({
valve: Boolean(globalThis.__waterNetworkMap?.getLayer("valves-symbol")), valve: Boolean(globalThis.__waterNetworkMap?.getLayer("valves-symbol")),
valveLabel: Boolean(globalThis.__waterNetworkMap?.getLayer("workbench-value-label-valves")),
reservoir: Boolean(globalThis.__waterNetworkMap?.getLayer("reservoirs-symbol")), reservoir: Boolean(globalThis.__waterNetworkMap?.getLayer("reservoirs-symbol")),
reservoirLabel: Boolean(globalThis.__waterNetworkMap?.getLayer("workbench-value-label-reservoirs")),
pump: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol")), pump: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol")),
tank: Boolean(globalThis.__waterNetworkMap?.getLayer("tanks-symbol")) tank: Boolean(globalThis.__waterNetworkMap?.getLayer("tanks-symbol"))
}))).toEqual({ valve: true, reservoir: true, pump: true, tank: true }); }))).toEqual({
valve: true,
valveLabel: true,
reservoir: true,
reservoirLabel: true,
pump: true,
tank: true
});
await expect.poll(() => page.evaluate(() => {
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
return {
valveLabel: layerIds.indexOf("workbench-value-label-valves"),
simulationLabel: layerIds.indexOf("simulation-burst-label")
};
})).toMatchObject({
valveLabel: expect.any(Number),
simulationLabel: expect.any(Number)
});
const layerOrder = await page.evaluate(() => {
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
return [
layerIds.indexOf("workbench-value-label-valves"),
layerIds.indexOf("simulation-burst-label")
];
});
expect(layerOrder[0]).toBeGreaterThanOrEqual(0);
expect(layerOrder[0]).toBeLessThan(layerOrder[1]);
}); });
test("loads required map layers while optional layer probes are still pending", async ({ page }) => { test("loads required map layers while optional layer probes are still pending", async ({ page }) => {