Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions src/actions/init-mapguide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,18 @@ export class DefaultViewerInitCommand extends ViewerInitCommand<SubjectLayerType
});
// Collect only the MapDefinition entries for lazy-load eligibility check
const mapDefItems = mapDefs.filter(isMapDefinition);
// Lazy creation only applies when: not stateless, not reusing session, and there are multiple MapGuide maps
const canLazyLoad = !isStateless && !sessionWasReused && mapDefItems.length > 1;
// Lazy creation only applies when: not stateless and there are multiple MapGuide maps.
// Note: We intentionally do NOT exclude sessionWasReused here. Even on a browser refresh
// (where the session is reused), non-active maps should still be deferred because they may
// never have been created in the previous session (the user may not have switched to them).
// These deferred maps will be lazily initialized via activateMap() when the user switches
// to them, which now tries to describe the existing map first before creating a new one.
const canLazyLoad = !isStateless && mapDefItems.length > 1;
// When the session is reused (browser refresh), use initialActiveMap from the URL (?map=)
// to identify which map to eagerly recover. If the URL param doesn't match any map in the
// appdef (or is absent), fall back to the first map by position.
const initialActiveMapName = this.options.initialActiveMap;
const activeMapExistsInAppDef = !!initialActiveMapName && mapDefItems.some(mi => mi.name === initialActiveMapName);
if (isStateless) {
for (const m of mapDefs) {
if (isMapDefinition(m)) {
Expand All @@ -264,15 +274,23 @@ export class DefaultViewerInitCommand extends ViewerInitCommand<SubjectLayerType
let isFirstMapDef = true;
for (const m of mapDefs) {
if (isMapDefinition(m)) {
//sessionWasReused is a hint whether to create a new runtime map, or recover the last runtime map state from the given map name
if (sessionWasReused) {
// Determine if this is the "primary" map to eagerly load/recover.
// - For new sessions: the primary is always the first map in the appdef.
// - For reused sessions (browser refresh): the primary is the map the user was
// viewing, identified via initialActiveMap (from the ?map= URL param). If the
// URL param is absent or does not match any map, fall back to first-by-position.
const isPrimaryMap = (sessionWasReused && activeMapExistsInAppDef)
? m.name === initialActiveMapName
: isFirstMapDef;
if (canLazyLoad && !isPrimaryMap) {
// Defer non-primary maps in a multi-map layout to avoid loading them upfront.
// This applies regardless of whether the session is being reused.
info(`Deferring lazy creation of runtime map (${m.name}) for: ${m.mapDef}`);
pendingMapDefs[m.name] = m;
} else if (sessionWasReused) {
//FIXME: If the map state we're recovering has a selection, we need to re-init the selection client-side
info(`Session ID re-used. Attempting recovery of map state of: ${m.name}`);
mapPromises.push(this.tryDescribeRuntimeMapAsync(m.name, session, m.mapDef, siteVersion));
} else if (canLazyLoad && !isFirstMapDef) {
// Defer creation of non-first maps in a multi-map layout to avoid loading all maps upfront
info(`Deferring lazy creation of runtime map (${m.name}) for: ${m.mapDef}`);
pendingMapDefs[m.name] = m;
} else {
info(`Creating runtime map state (${m.name}) for: ${m.mapDef}`);
assertIsDefined(this.client);
Expand Down
87 changes: 58 additions & 29 deletions src/actions/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,40 +555,69 @@ export function activateMap(mapName: string): ReduxThunkedAction {
}
if (sessionId) {
try {
info(`Lazily creating runtime map state (${mapName}) for: ${pendingMap.mapDef}`);
const client = new Client(agentUri, agentKind);
const siteVersion = new AsyncLazy<SiteVersionResponse>(async () => client.getSiteVersion());
const sv = await siteVersion.getValueAsync();
let map: RuntimeMap;
if (canUseQueryMapFeaturesV4(parseSiteVersion(sv.Version))) {
map = await client.createRuntimeMap_v4({
mapDefinition: pendingMap.mapDef,
requestedFeatures: RuntimeMapFeatureFlags.LayerFeatureSources | RuntimeMapFeatureFlags.LayerIcons | RuntimeMapFeatureFlags.LayersAndGroups,
session: sessionId,
targetMapName: mapName
});
} else {
map = await client.createRuntimeMap({
mapDefinition: pendingMap.mapDef,
requestedFeatures: RuntimeMapFeatureFlags.LayerFeatureSources | RuntimeMapFeatureFlags.LayerIcons | RuntimeMapFeatureFlags.LayersAndGroups,
session: sessionId,
targetMapName: mapName
});
const useV4 = canUseQueryMapFeaturesV4(parseSiteVersion(sv.Version));
let map: RuntimeMap | undefined;
// Try to describe the runtime map first. This handles the case where the map
// was previously created in a reused session (e.g. after a browser refresh where
// the user had previously switched to this map). If the map does not exist yet
// (i.e. the user has never switched to it), fall back to creating it.
try {
info(`Attempting to describe existing runtime map state (${mapName})`);
if (useV4) {
map = await client.describeRuntimeMap_v4({
mapname: mapName,
requestedFeatures: RuntimeMapFeatureFlags.LayerFeatureSources | RuntimeMapFeatureFlags.LayerIcons | RuntimeMapFeatureFlags.LayersAndGroups,
session: sessionId
});
} else {
map = await client.describeRuntimeMap({
mapname: mapName,
requestedFeatures: RuntimeMapFeatureFlags.LayerFeatureSources | RuntimeMapFeatureFlags.LayerIcons | RuntimeMapFeatureFlags.LayersAndGroups,
session: sessionId
});
}
} catch (describeErr: any) {
if (describeErr?.message === "MgResourceNotFoundException") {
// Map does not exist yet in this session, create it
info(`Lazily creating runtime map state (${mapName}) for: ${pendingMap.mapDef}`);
if (useV4) {
map = await client.createRuntimeMap_v4({
mapDefinition: pendingMap.mapDef,
requestedFeatures: RuntimeMapFeatureFlags.LayerFeatureSources | RuntimeMapFeatureFlags.LayerIcons | RuntimeMapFeatureFlags.LayersAndGroups,
session: sessionId,
targetMapName: mapName
});
} else {
map = await client.createRuntimeMap({
mapDefinition: pendingMap.mapDef,
requestedFeatures: RuntimeMapFeatureFlags.LayerFeatureSources | RuntimeMapFeatureFlags.LayerIcons | RuntimeMapFeatureFlags.LayersAndGroups,
session: sessionId,
targetMapName: mapName
});
}
} else {
throw describeErr;
}
}
// Register the map's projection if needed
const epsg = map.CoordinateSystem.EpsgCode;
const arbCs = tryParseArbitraryCs(map.CoordinateSystem.MentorCode);
if (!arbCs && epsg && epsg !== "0" && !proj4.defs[`EPSG:${epsg}`]) {
await resolveProjectionFromEpsgCodeAsync(epsg, locale, map.MapDefinition);
register(proj4);
if (map) {
// Register the map's projection if needed
const epsg = map.CoordinateSystem.EpsgCode;
const arbCs = tryParseArbitraryCs(map.CoordinateSystem.MentorCode);
if (!arbCs && epsg && epsg !== "0" && !proj4.defs[`EPSG:${epsg}`]) {
await resolveProjectionFromEpsgCodeAsync(epsg, locale, map.MapDefinition);
register(proj4);
}
// Update the map state with the runtime map
dispatch({
type: ActionType.MAP_REFRESH,
payload: { mapName, map }
});
}
// Update the map state with the newly created runtime map
dispatch({
type: ActionType.MAP_REFRESH,
payload: { mapName, map }
});
} catch (e) {
warn(`Failed to lazily create runtime map (${mapName}): ${e?.message ?? e}. Proceeding with map switch; the map may not render correctly.`);
} catch (e: any) {
warn(`Failed to lazily initialize runtime map (${mapName}): ${e?.message ?? e}. Proceeding with map switch; the map may not render correctly.`);
}
} else {
warn(`Cannot lazily create runtime map (${mapName}): no active session found. Proceeding with map switch; the map may not render correctly.`);
Expand Down
136 changes: 135 additions & 1 deletion test/actions/map.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import { combineSelections } from "../../src/actions/map"
import { QueryMapFeaturesResponse } from "../../src/api/contracts/query"

Expand Down Expand Up @@ -666,6 +666,7 @@ import {
externalLayersReady,
mapLayerAdded
} from "../../src/actions/map";
import { Client } from "../../src/api/client";
import { ActionType } from "../../src/constants/actions";
import { ActiveMapTool, UnitOfMeasure } from "../../src/api/common";
import { VectorStyleSource } from "../../src/api/ol-style-contracts";
Expand Down Expand Up @@ -971,4 +972,137 @@ describe("actions/map - activateMap thunk", () => {
expect(dispatched[0].type).toBe(ActionType.MAP_SET_ACTIVE_MAP);
expect(dispatched[0].payload).toBe("LazyMap");
});
});

vi.mock("../../src/api/client", () => ({
Client: vi.fn()
}));

describe("actions/map - activateMap thunk (with session)", () => {
afterEach(() => {
vi.clearAllMocks();
});

function createStateWithPendingMap(sessionId = "test-session-id") {
const initialState = createInitialState();
const map = createMap();
const mapState = {
[map.Name]: {
...MAP_STATE_INITIAL_SUB_STATE,
mapguide: { ...MG_INITIAL_SUB_STATE, runtimeMap: { ...map, SessionId: sessionId } }
}
};
return {
...initialState,
config: {
...initialState.config,
agentUri: "http://localhost/mapguide/mapagent/mapagent.fcgi",
agentKind: "mapagent" as const,
activeMapName: map.Name,
pendingMaps: {
"LazyMap": { mapDef: "Library://LazyMap.MapDefinition", metadata: {} }
}
},
mapState
};
}

function createLazyMap() {
return {
...createMap(),
Name: "LazyMap",
MapDefinition: "Library://LazyMap.MapDefinition"
};
}

it("dispatches MAP_REFRESH with described map when describe succeeds (reused session)", async () => {
const lazyMap = createLazyMap();
const mockClient = {
getSiteVersion: vi.fn().mockResolvedValue({ Version: "4.0.0.0" }),
describeRuntimeMap_v4: vi.fn().mockResolvedValue(lazyMap),
createRuntimeMap_v4: vi.fn()
};
vi.mocked(Client).mockImplementation(() => mockClient as any);

const state = createStateWithPendingMap();
const dispatched: any[] = [];
const dispatch = (action: any) => { dispatched.push(action); return action; };
const getState = () => state as any;

const thunk = activateMap("LazyMap");
await thunk(dispatch as any, getState as any);

expect(mockClient.describeRuntimeMap_v4).toHaveBeenCalledWith(expect.objectContaining({
mapname: "LazyMap",
session: "test-session-id"
}));
expect(mockClient.createRuntimeMap_v4).not.toHaveBeenCalled();

expect(dispatched).toHaveLength(2);
expect(dispatched[0].type).toBe(ActionType.MAP_REFRESH);
expect(dispatched[0].payload.mapName).toBe("LazyMap");
expect(dispatched[0].payload.map).toBe(lazyMap);
expect(dispatched[1].type).toBe(ActionType.MAP_SET_ACTIVE_MAP);
expect(dispatched[1].payload).toBe("LazyMap");
});

it("dispatches MAP_REFRESH with created map when describe fails with MgResourceNotFoundException (new map)", async () => {
const lazyMap = createLazyMap();
const mockClient = {
getSiteVersion: vi.fn().mockResolvedValue({ Version: "4.0.0.0" }),
describeRuntimeMap_v4: vi.fn().mockRejectedValue(new Error("MgResourceNotFoundException")),
createRuntimeMap_v4: vi.fn().mockResolvedValue(lazyMap)
};
vi.mocked(Client).mockImplementation(() => mockClient as any);

const state = createStateWithPendingMap();
const dispatched: any[] = [];
const dispatch = (action: any) => { dispatched.push(action); return action; };
const getState = () => state as any;

const thunk = activateMap("LazyMap");
await thunk(dispatch as any, getState as any);

expect(mockClient.describeRuntimeMap_v4).toHaveBeenCalledWith(expect.objectContaining({
mapname: "LazyMap",
session: "test-session-id"
}));
expect(mockClient.createRuntimeMap_v4).toHaveBeenCalledWith(expect.objectContaining({
mapDefinition: "Library://LazyMap.MapDefinition",
session: "test-session-id",
targetMapName: "LazyMap"
}));

expect(dispatched).toHaveLength(2);
expect(dispatched[0].type).toBe(ActionType.MAP_REFRESH);
expect(dispatched[0].payload.mapName).toBe("LazyMap");
expect(dispatched[0].payload.map).toBe(lazyMap);
expect(dispatched[1].type).toBe(ActionType.MAP_SET_ACTIVE_MAP);
expect(dispatched[1].payload).toBe("LazyMap");
});

it("dispatches only MAP_SET_ACTIVE_MAP when describe fails with a non-MgResourceNotFoundException error", async () => {
const mockClient = {
getSiteVersion: vi.fn().mockResolvedValue({ Version: "4.0.0.0" }),
describeRuntimeMap_v4: vi.fn().mockRejectedValue(new Error("MgSessionExpiredException")),
createRuntimeMap_v4: vi.fn()
};
vi.mocked(Client).mockImplementation(() => mockClient as any);

const state = createStateWithPendingMap();
const dispatched: any[] = [];
const dispatch = (action: any) => { dispatched.push(action); return action; };
const getState = () => state as any;

const thunk = activateMap("LazyMap");
await thunk(dispatch as any, getState as any);

expect(mockClient.createRuntimeMap_v4).not.toHaveBeenCalled();

// MAP_SET_ACTIVE_MAP is always dispatched even on failure to allow graceful degradation;
// the map switch will still proceed, though the map may not render correctly.
expect(dispatched).toHaveLength(1);
expect(dispatched[0].type).toBe(ActionType.MAP_SET_ACTIVE_MAP);
expect(dispatched[0].payload).toBe("LazyMap");
});
});
Loading