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
74 changes: 69 additions & 5 deletions src/containers/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { getAssetRoot } from "../utils/asset";
import { setFusionRoot } from "../api/runtime";
import { AppContextProvider, LegendNodeExtraHTMLProps } from "../components/context";
import type { IElementState } from '../actions/defs';
import { useInitError, useInitErrorStack, useInitErrorOptions, useViewerLocale, useActiveMapBranch, useActiveMapName, useViewerFeatureTooltipsEnabled } from './hooks';
import { useInitError, useInitErrorStack, useInitErrorOptions, useViewerLocale, useActiveMapBranch, useActiveMapName, useViewerFeatureTooltipsEnabled, useCustomAppSettings } from './hooks';
import { areStatesEqual, getStateFromUrl, type IAppUrlState, updateUrl } from './url-state';
import { debug } from '../utils/logger';
import { setElementStates } from '../actions/template';
Expand All @@ -27,6 +27,65 @@ import { MapGroup, MapLayer } from "../api/contracts/runtime-map";
import { useElementContext } from "../components/elements/element-context";
import type { ISelectedFeatureProps } from "../components/selection-panel";

/**
* The app setting key used to specify URL props to ignore.
* The value should be a comma-separated list of URL parameter names.
*
* @example
* To ignore `x`, `y`, and `scale` URL parameters, set this app setting in your appdef's ViewerSettings:
* ```xml
* <Setting name="urlPropsIgnore" value="x,y,scale" />
* ```
*
* @since 0.15
*/
export const APP_SETTING_URL_PROPS_IGNORE = "urlPropsIgnore";

/**
* Gets the effective list of URL props to ignore, combining the `urlPropsIgnore` prop value
* with any value specified in the app settings under the {@link APP_SETTING_URL_PROPS_IGNORE} key.
*
* This allows `urlPropsIgnore` to be configured via an app setting in the loaded Application
* Definition, without requiring modification of the viewer HTML template.
*
* @param propIgnore The `urlPropsIgnore` prop value
* @param settingsValue The value of the `urlPropsIgnore` app setting (comma-separated)
* @returns The combined list of URL props to ignore, or undefined if both are empty
*
* @example
* // Merging a prop value with a settings string
* getEffectiveUrlPropsIgnore(["scale"], "x,y"); // returns ["scale", "x", "y"]
*
* // Settings-only configuration
* getEffectiveUrlPropsIgnore(undefined, "x,y,scale"); // returns ["x", "y", "scale"]
*
* @since 0.15
* @hidden
*/
export function getEffectiveUrlPropsIgnore(propIgnore: string[] | undefined, settingsValue: string | undefined): string[] | undefined {
const normalizeIgnoreProp = (value: string) => value.trim().toLowerCase();
const fromProps = propIgnore?.map(normalizeIgnoreProp).filter(s => s.length > 0) ?? [];
const fromSettings = settingsValue?.split(",").map(normalizeIgnoreProp).filter(s => s.length > 0) ?? [];
const merged = [...new Set([...fromProps, ...fromSettings])];
return merged.length > 0 ? merged : undefined;
}

/**
* Returns a copy of the given URL state with the specified keys omitted.
* Used to strip ignored keys before equality comparison so that changes
* to ignored fields do not trigger unnecessary URL updates.
*/
function omitIgnoredStateKeys(state: IAppUrlState, ignoreProps: string[]): IAppUrlState {
const ignoreSet = new Set(ignoreProps);
const result: IAppUrlState = {};
for (const k in state) {
if (!ignoreSet.has(k)) {
(result as any)[k] = (state as any)[k];
}
}
return result;
}

const AppLoadingPlaceholder: React.FC<{ locale: string }> = ({ locale }) => {
const { NonIdealState, Spinner } = useElementContext();
return <NonIdealState
Expand Down Expand Up @@ -244,6 +303,7 @@ export const App = (props: IAppProps) => {
const map = useActiveMapBranch();
const activeMapName = useActiveMapName();
const ftEnabled = useViewerFeatureTooltipsEnabled();
const configuredAppSettings = useCustomAppSettings();

const dispatch = useReduxDispatch();
const viewer = useMapProviderContext();
Expand All @@ -265,6 +325,7 @@ export const App = (props: IAppProps) => {
layout: layoutProp,
urlPropsIgnore
} = props;
const effectiveUrlPropsIgnore = getEffectiveUrlPropsIgnore(urlPropsIgnore, appSettings?.[APP_SETTING_URL_PROPS_IGNORE]);
const {
locale: urlLocale,
resource: urlResource,
Expand All @@ -278,7 +339,7 @@ export const App = (props: IAppProps) => {
hl: urlHideLayers,
sg: urlShowGroups,
hg: urlHideGroups
} = getStateFromUrl(urlPropsIgnore);
} = getStateFromUrl(effectiveUrlPropsIgnore);
if (setElementVisibility && mapguide?.initialElementVisibility) {
const { taskpane, legend, selection } = mapguide.initialElementVisibility;
const states: IElementState = {
Expand Down Expand Up @@ -428,9 +489,12 @@ export const App = (props: IAppProps) => {
}
}
}
if (!areStatesEqual(curUrlState, nextUrlState))
updateUrl(nextUrlState, undefined, props.urlPropsIgnore);
}, [map, activeMapName, ftEnabled, props]);
const effectiveIgnore = getEffectiveUrlPropsIgnore(props.urlPropsIgnore, configuredAppSettings?.[APP_SETTING_URL_PROPS_IGNORE]);
const curComparable = effectiveIgnore ? omitIgnoredStateKeys(curUrlState, effectiveIgnore) : curUrlState;
const nextComparable = effectiveIgnore ? omitIgnoredStateKeys(nextUrlState, effectiveIgnore) : nextUrlState;
if (!areStatesEqual(curComparable, nextComparable))
updateUrl(nextUrlState, undefined, effectiveIgnore);
}, [map, activeMapName, ftEnabled, props, configuredAppSettings]);
Comment on lines 490 to +497
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL sync effect compares curUrlState vs nextUrlState with areStatesEqual() before calling updateUrl(), but areStatesEqual() does not account for ignored URL props. If urlPropsIgnore includes keys that nextUrlState still sets (eg x/y/scale), the states will never be equal and this effect will call updateUrl() on every run even when the effective URL cannot change. Consider stripping ignored keys from nextUrlState (and/or reading curUrlState with the same ignore list) before comparing to avoid redundant replaceState calls.

Copilot uses AI. Check for mistakes.

const renderErrorMessage = React.useCallback((err: Error | InitError, locale: string, args: any): JSX.Element => {
const msg = err.message;
Expand Down
30 changes: 27 additions & 3 deletions src/containers/url-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,33 @@ const debouncedReplaceState = debounce((state, _, url) => window.history.replace
* @since 0.14.9 - New optional ignoreProps argument
*/
export function updateUrl(state: IAppUrlState, extraState?: any, ignoreProps?: string[] | undefined): void {
const st: any = { ...extraState };
const ignoreSet = new Set((ignoreProps ?? []).map(k => k.toLowerCase()));
const st: any = {};

// Preserve existing URL params by default, excluding ignored keys.
// Keys are normalised to lowercase so that a later write of the same
// key from IAppUrlState (which is always lowercase) overwrites the
// existing entry rather than creating a duplicate with different casing.
const currentParams = parseUrlParameters(window.location.href);
for (const k in currentParams) {
if (ignoreSet.has(k.toLowerCase())) {
continue;
}
st[k.toLowerCase()] = currentParams[k];
}

Comment on lines +49 to +63
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateUrl() now copies existing query params into st and then appends state keys, but appendParameters(..., bDiscardExistingParams=true) no longer normalizes key casing against existing params. If the current URL contains differently-cased keys (eg LOCALE/SESSION) you can end up with both LOCALE and locale in the output URL. Consider normalizing/deduping parameter names case-insensitively when building st (eg reuse the paramNames[name.toUpperCase()] approach from appendParameters, or store keys in a canonical casing) so updates overwrite the existing key instead of adding a second one.

Copilot uses AI. Check for mistakes.
// Apply extra state next, unless explicitly ignored.
if (extraState != null) {
for (const k in extraState) {
if (ignoreSet.has(k.toLowerCase())) {
continue;
}
st[k] = extraState[k];
}
}

for (const k in state) {
if (ignoreProps && ignoreProps.indexOf(k) >= 0)
if (ignoreSet.has(k.toLowerCase()))
continue;
const val: any = (state as any)[k];
switch (k) {
Expand All @@ -72,7 +96,7 @@ export function updateUrl(state: IAppUrlState, extraState?: any, ignoreProps?: s
break;
}
}
const url = appendParameters(window.location.href, st, true, false, ignoreProps != null);
const url = appendParameters(window.location.href, st, true, false, true);
//window.history.replaceState(st, "", url);
debouncedReplaceState(st, "", url);
}
Expand Down
52 changes: 52 additions & 0 deletions test/containers/app.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { getEffectiveUrlPropsIgnore } from "../../src/containers/app";

describe("getEffectiveUrlPropsIgnore", () => {
it("returns undefined when both prop and settings value are undefined", () => {
expect(getEffectiveUrlPropsIgnore(undefined, undefined)).toBeUndefined();
});

it("returns the prop value when settings value is undefined", () => {
expect(getEffectiveUrlPropsIgnore(["x", "y"], undefined)).toEqual(["x", "y"]);
});

it("returns parsed settings value when prop is undefined", () => {
expect(getEffectiveUrlPropsIgnore(undefined, "x,y")).toEqual(["x", "y"]);
});

it("merges prop value and parsed settings value", () => {
expect(getEffectiveUrlPropsIgnore(["scale"], "x,y")).toEqual(["scale", "x", "y"]);
});

it("trims whitespace from settings values", () => {
expect(getEffectiveUrlPropsIgnore(undefined, " x , y ")).toEqual(["x", "y"]);
});

it("filters empty entries from settings value", () => {
expect(getEffectiveUrlPropsIgnore(undefined, "x,,y")).toEqual(["x", "y"]);
});

it("returns prop when settings value results in empty list after filtering", () => {
expect(getEffectiveUrlPropsIgnore(["scale"], ",,")).toEqual(["scale"]);
});

it("returns undefined when prop is undefined and settings value results in empty list", () => {
expect(getEffectiveUrlPropsIgnore(undefined, " ,, ")).toBeUndefined();
});

it("lowercases all entries from prop", () => {
expect(getEffectiveUrlPropsIgnore(["X", "Y"], undefined)).toEqual(["x", "y"]);
});

it("lowercases all entries from settings value", () => {
expect(getEffectiveUrlPropsIgnore(undefined, "X,Y,Scale")).toEqual(["x", "y", "scale"]);
});

it("deduplicates entries across prop and settings", () => {
expect(getEffectiveUrlPropsIgnore(["x", "scale"], "x,y")).toEqual(["x", "scale", "y"]);
});

it("deduplicates case-insensitively (prop uppercase, settings lowercase)", () => {
expect(getEffectiveUrlPropsIgnore(["X"], "x,y")).toEqual(["x", "y"]);
});
});
44 changes: 44 additions & 0 deletions test/containers/url-state.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Tests for URL state synchronization behavior when ignored URL properties are configured.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { updateUrl } from "../../src/containers/url-state";

describe("updateUrl", () => {
beforeEach(() => {
vi.useFakeTimers();
const params = new URLSearchParams({
resource: "Library://Samples/Sheboygan/Layouts/SheboyganAsp.WebLayout",
foo: "bar",
x: "1"
});
window.history.replaceState({}, "", `/?${params.toString()}`);
});
Comment on lines +6 to +14
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suite enables fake timers in beforeEach but never restores real timers. That can leak fake timers into subsequent test files and cause unrelated failures. Add an afterEach that clears timers and calls vi.useRealTimers() (similar to test/components/session-keep-alive.spec.tsx).

Copilot uses AI. Check for mistakes.

afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});

it("preserves existing non-ignored params when ignore props are configured", () => {
updateUrl({ x: 2, y: 3, scale: 1000 }, undefined, ["x", "y"]);
vi.runAllTimers();

const params = new URL(window.location.href).searchParams;
expect(params.get("resource")).toBe("Library://Samples/Sheboygan/Layouts/SheboyganAsp.WebLayout");
expect(params.get("foo")).toBe("bar");
expect(params.get("x")).toBeNull();
expect(params.get("y")).toBeNull();
expect(params.get("scale")).toBe("1000");
});

it("leaves existing params alone when no ignore props are configured", () => {
updateUrl({ x: 2, y: 3, scale: 1000 });
vi.runAllTimers();

const params = new URL(window.location.href).searchParams;
expect(params.get("resource")).toBe("Library://Samples/Sheboygan/Layouts/SheboyganAsp.WebLayout");
expect(params.get("foo")).toBe("bar");
expect(params.get("x")).toBe("2");
expect(params.get("y")).toBe("3");
expect(params.get("scale")).toBe("1000");
});
});
Loading