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
20 changes: 1 addition & 19 deletions DONOTUPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,4 @@ We need to solve a type puzzle involving dispatching thunked actions in order to
Current Version: `8.6.14`
Latest Version: `9.x`

We need to migrate away from the knobs addon

## @testing/library

Current Version: `12.1.5`
Latest Version: `16.3.0`

Newer versions do not work with React 17

## react, react-dom

Current Version: `17.0.2`
Latest Version: `19.x.x`

Reasons for not upgrading: It's not so much the package itself may break our library, but rather the ecosystem of supplemental libraries surrounding it that may still require react 17

React 19 looks too new for our ecosystem

React 18 is the safest bet. We can upgrade to react 18 once we have fully decoupled ourselves from legacy UI toolkit dependencies.
We need to migrate away from the knobs addon
18 changes: 18 additions & 0 deletions docs/adr/0003-react-18-minimum-peer-dependency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# React 18 minimum peer dependency; drop React 17 support

We will require React 18+ (`>=18.0.0 <20.0.0`) as a peer dependency for the npm package, replacing the current bundled React 17 dependency. The viewer bundle (standalone HTML) is unaffected because it bundles React inline.

We chose React 18 as the floor because:

1. `ReactDOM.render()` is removed in React 19. Switching to `createRoot()` is required for forward compatibility, and `createRoot()` exists in React 18+.
2. React 17 does not have `createRoot()`, so maintaining React 17 support would require a runtime version-detect shim at every `ReactDOM.render` call site. This is brittle and masks the real constraint.
3. The `@testing-library/react` 12.x test stack is pinned to React 17 and blocks upgrades to `@testing-library/react` 16.x, `react-test-renderer` 18.x, etc. React 18 unblocks the entire testing stack.
4. `DONOTUPGRADE.md` already identified React 18 as the safest upgrade target and React 17 as the blocker for multiple ecosystem packages.

**Breaking change for npm consumers:** Consumers on React 17 must upgrade to React 18+ to use future versions of this package. The viewer bundle (standalone `viewer.zip`) is unaffected — React is bundled inline and the viewer owns its own React version.

**Considered and rejected:** targeting React 18 with a `>=17.0.0 <20.0.0` peer range and a `createRoot` / `render` shim. This would silently allow React 17 consumers to install the package but crash at runtime when `mount()` is called, since `createRoot` does not exist in React 17.

**Considered and rejected:** targeting React 19 directly. Multiple ecosystem dependencies (`react-hot-toast`, `react-tiny-popover`, `react-rnd`) have unknown React 19 compatibility. React 18 gives the broadest ecosystem support while still using `createRoot()`.

**Outcome:** React and ReactDOM become peer dependencies (`>=18.0.0 <20.0.0`) plus dev dependencies. All `ReactDOM.render()` calls are replaced with `createRoot()`. The testing stack is upgraded to `@testing-library/react` 16.x and `react-test-renderer` 18.x. `DONOTUPGRADE.md` entries for React and `@testing-library` are removed.
43 changes: 24 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"dependencies": {
"@reduxjs/toolkit": "^2.12.0",
"colorbrewer": "^1.7.0",
"dompurify": "^3.4.10",
"dompurify": "^3.4.12",
"geojson-vt": "^4.0.3",
"jspdf": "^4.2.1",
"lodash.debounce": "^4.0.8",
Expand All @@ -73,19 +73,17 @@
"ol": "^10.9.0",
"papaparse": "^5.5.3",
"proj4": "2.20.9",
"react": "^17.0.2",
"react-colorful": "^5.7.0",
"react-dom": "^17.0.2",
"react-hot-toast": "2.6.0",
"react-redux": "^8.1.3",
"react-redux": "^9.3.0",
"react-rnd": "^10.5.2",
"react-tiny-popover": "8.1.6",
"stickybits": "3.7.11",
"tslib": "^2.8.1"
},
"devDependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"@playwright/test": "1.59.1",
"@playwright/test": "1.61.1",
"@storybook/addon-actions": "^8.6.18",
"@storybook/addon-docs": "^8.6.18",
"@storybook/addon-knobs": "^8.0.1",
Expand All @@ -98,50 +96,57 @@
"@storybook/react": "^8.6.18",
"@storybook/react-vite": "^8.6.18",
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "0.24.3",
"@storybook/test-runner": "0.24.4",
"@storybook/theming": "^8.6.18",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^12.1.5",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.0",
"@types/dompurify": "^3.2.0",
"@types/geojson": "7946.0.16",
"@types/lodash.debounce": "4.0.9",
"@types/lodash.xor": "4.5.9",
"@types/lodash.xorby": "4.7.9",
"@types/react": "17.0.91",
"@types/react-dom": "17.0.26",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "3.2.4",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^6.0.4",
"@vitest/coverage-v8": "4.1.10",
"@welldone-software/why-did-you-render": "^7.0.1",
"barrelsby": "2.8.1",
"bestzip": "3.0.1",
"concurrently": "9.2.1",
"concurrently": "10.0.3",
"copyfiles": "2.4.1",
"coveralls": "3.1.1",
"cross-env": "10.1.0",
"docsify-cli": "4.4.4",
"jest-image-snapshot": "6.5.2",
"jsdom": "^26.1.0",
"jsdom": "^29.1.1",
"jsonfile": "6.2.1",
"patch-package": "^8.0.0",
"raf": "^3.4.1",
"react-test-renderer": "17.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-test-renderer": "^18.3.1",
"rimraf": "6.1.3",
"rollup-plugin-visualizer": "^7.0.1",
"storybook": "8.6.18",
"translate": "^3.0.1",
"tslint": "6.1.3",
"tsx": "^4.21.0",
"typedoc": "0.28.19",
"typedoc": "0.28.20",
"typedoc-plugin-ga": "^1.1.1",
"typedoc-plugin-merge-modules": "7.0.0",
"typescript": "5.9.3",
"typescript-json-schema": "^0.67.1",
"typescript-json-schema": "^0.68.0",
"vite": "^8.0.16",
"vitest": "3.2.6",
"wait-on": "9.0.10"
"vitest": "4.1.10",
"wait-on": "9.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": ">=18.0.0 <20.0.0",
"react-dom": ">=18.0.0 <20.0.0"
},
"packageManager": "yarn@4.14.1"
}
4 changes: 2 additions & 2 deletions src/api/default-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { showModalComponent, showModalUrl } from '../actions/modal';
import { refresh } from '../actions/legend';
import { setTaskPaneVisibility, setLegendVisibility, setSelectionPanelVisibility } from '../actions/template';
import React from "react";
import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";

function panMap(dispatch: ReduxDispatch, viewer: IMapViewer, value: "right" | "left" | "up" | "down") {
const settings: any = {
Expand Down Expand Up @@ -416,7 +416,7 @@ export function initDefaultCommands() {
<title>Print View</title>
`;
printWindow.document.body.innerHTML = '<div id="print"></div>';
ReactDOM.render(el, printWindow.document.getElementById("print"));
createRoot(printWindow.document.getElementById("print")!).render(el);
}
}
});
Expand Down
9 changes: 6 additions & 3 deletions src/components/map-providers/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ export function useReduxDispatch(): ReduxDispatch {
}

/**
* Fetches the requested sub-section of the application state
*
* Fetches the requested sub-section of the application state.
*
* In react-redux 9, useSelector no longer takes an explicit root-state generic;
* the state type is inferred from the selector's parameter type.
*
* @since 0.14
*/
export function useAppState<TState>(selector: (state: IApplicationState) => TState, equalityFn?: (left: TState, right: TState) => boolean) {
return useSelector<IApplicationState, TState>(selector, equalityFn);
return useSelector(selector, equalityFn);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/entries/application.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import { createRoot } from "react-dom/client";
import { App, IAppProps } from "../containers/app";
import { ReduxThunkedAction, ICommand, IApplicationState, IConfigurationReducerState } from "../api/common";
import { configureStore } from "../store/configure-store";
Expand Down Expand Up @@ -92,10 +92,10 @@ export class ApplicationViewModel {
this._store = configureStore(initState, extraReducers);
// Register our MapGuide-specific viewer implementation
const provider = new MapGuideMapProviderContext();
ReactDOM.render(<MapContextProvider value={provider} store={this._store}>
createRoot(node).render(<MapContextProvider value={provider} store={this._store}>
<App {...props} />
{subs.map((s, i) => <Subscriber key={`subscriber-${i}-${s.name}`} {...s} />)}
</MapContextProvider>, node);
</MapContextProvider>);
}
/**
* Dispatches the given action
Expand Down
10 changes: 6 additions & 4 deletions test/actions/map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -991,7 +991,9 @@ describe("actions/map - activateMap thunk", () => {
});

vi.mock("../../src/api/client", () => ({
Client: vi.fn()
Client: vi.fn().mockImplementation(function(this: any) {
return this;
})
}));

describe("actions/map - activateMap thunk (with session)", () => {
Expand Down Expand Up @@ -1039,7 +1041,7 @@ describe("actions/map - activateMap thunk (with session)", () => {
describeRuntimeMap_v4: vi.fn().mockResolvedValue(lazyMap),
createRuntimeMap_v4: vi.fn()
};
vi.mocked(Client).mockImplementation(() => mockClient as any);
vi.mocked(Client).mockImplementation(function(this: any) { return mockClient as any; });

const state = createStateWithPendingMap("test-session-id", true);
const dispatched: any[] = [];
Expand Down Expand Up @@ -1070,7 +1072,7 @@ describe("actions/map - activateMap thunk (with session)", () => {
describeRuntimeMap_v4: vi.fn(),
createRuntimeMap_v4: vi.fn().mockResolvedValue(lazyMap)
};
vi.mocked(Client).mockImplementation(() => mockClient as any);
vi.mocked(Client).mockImplementation(function(this: any) { return mockClient as any; });

const state = createStateWithPendingMap("test-session-id", false);
const dispatched: any[] = [];
Expand Down Expand Up @@ -1101,7 +1103,7 @@ describe("actions/map - activateMap thunk (with session)", () => {
describeRuntimeMap_v4: vi.fn().mockRejectedValue(new Error("MgSessionExpiredException")),
createRuntimeMap_v4: vi.fn()
};
vi.mocked(Client).mockImplementation(() => mockClient as any);
vi.mocked(Client).mockImplementation(function(this: any) { return mockClient as any; });

const state = createStateWithPendingMap("test-session-id", true);
const dispatched: any[] = [];
Expand Down
12 changes: 7 additions & 5 deletions test/components/accordion.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ const PANEL_SPEC: IAccordionPanelSpec[] = [
describe("components/accordion", () => {
beforeEach(() => {
delete (window as any).ResizeObserver;
window.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
window.ResizeObserver = vi.fn().mockImplementation(function(this: any) {
return {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
};
});
});
afterEach(() => {
window.ResizeObserver = ResizeObserver;
Expand Down
2 changes: 1 addition & 1 deletion test/containers/measure.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const actionMapMock = vi.hoisted(() => ({

const measureClassMock = vi.hoisted(() => ({
instances: [] as any[],
MeasureContext: vi.fn().mockImplementation((_viewer: any, mapName: string, parent: any) => {
MeasureContext: vi.fn().mockImplementation(function(this: any, _viewer: any, mapName: string, parent: any) {
const instance = {
mapName,
parent,
Expand Down
14 changes: 8 additions & 6 deletions test/containers/neo-and-quick-plot.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,14 @@ const loggerMock = vi.hoisted(() => ({
}));

const mapCapturerMock = vi.hoisted(() => ({
MapCapturerContext: vi.fn().mockImplementation(() => ({
getMapName: () => "Map1",
activate: vi.fn(),
updateBox: vi.fn(),
deactivate: vi.fn(),
})),
MapCapturerContext: vi.fn().mockImplementation(function(this: any) {
return {
getMapName: () => "Map1",
activate: vi.fn(),
updateBox: vi.fn(),
deactivate: vi.fn(),
};
}),
}));

const swipeMock = vi.hoisted(() => ({
Expand Down
10 changes: 6 additions & 4 deletions test/utils/browser-support.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ describe("utils/browser-support", () => {
document.documentElement.ontouchstart = orig;
});
it("isMobileViewport returns true if matchMedia matches", () => {
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ matches: true }) as any);
const orig = window.matchMedia;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: true })) as any;
expect(isMobileViewport()).toBe(true);
spy.mockRestore();
window.matchMedia = orig;
});
it("isMobileViewport returns false if matchMedia does not match", () => {
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ matches: false }) as any);
const orig = window.matchMedia;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: false })) as any;
expect(isMobileViewport()).toBe(false);
spy.mockRestore();
window.matchMedia = orig;
});
it("supportsWebGL returns a boolean", () => {
// In jsdom, WebGLRenderingContext is not defined; mock it to false to test the path
Expand Down
Loading
Loading