Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ [RUM-5500] React-router v7 support #3299

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 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
3 changes: 2 additions & 1 deletion LICENSE-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ dev,npm-run-all,MIT,Copyright 2015 Toru Nagashima
dev,pako,MIT,(C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin
dev,prettier,MIT,Copyright James Long and contributors
dev,puppeteer,Apache-2.0,Copyright 2017 Google Inc.
dev,react-router-dom,MIT,Copyright (c) React Training LLC 2015-2019 Copyright (c) Remix Software Inc. 2020-2021 Copyright (c) Shopify Inc. 2022-2023
dev,react-router-dom-6,MIT,Copyright (c) React Training LLC 2015-2019 Copyright (c) Remix Software Inc. 2020-2021 Copyright (c) Shopify Inc. 2022-2023
dev,react-router-dom-7,MIT,Copyright (c) React Training LLC 2015-2019 Copyright (c) Remix Software Inc. 2020-2021 Copyright (c) Shopify Inc. 2022-2023
RomanGaignault marked this conversation as resolved.
Show resolved Hide resolved
dev,style-loader,MIT,Copyright JS Foundation and other contributors
dev,terser-webpack-plugin,MIT,Copyright JS Foundation and other contributors
dev,ts-loader,MIT,Copyright 2015 TypeStrong
Expand Down
2 changes: 2 additions & 0 deletions eslint-local-rules/disallowSideEffects.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const packagesWithoutSideEffect = new Set([
'@datadog/browser-rum-core',
'react',
'react-router-dom',
'react-router-6',
'react-router-7',
RomanGaignault marked this conversation as resolved.
Show resolved Hide resolved
])

/**
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/domain/connectivity/connectivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface BrowserNavigator extends Navigator {
export interface NetworkInformation {
type?: NetworkInterface
effectiveType?: EffectiveType
saveData: boolean
}

export interface Connectivity {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/emulate/mockNavigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function setNavigatorOnLine(onLine: boolean) {
})
}

export function setNavigatorConnection(connection: NetworkInformation | undefined) {
export function setNavigatorConnection(connection: Partial<NetworkInformation> | undefined) {
Object.defineProperty(navigator, 'connection', {
get() {
return connection
Expand Down
5 changes: 3 additions & 2 deletions packages/rum-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"peerDependencies": {
"react": "18",
"react-router-dom": "6"
"react-router-dom": "6 || 7"
},
"peerDependenciesMeta": {
"@datadog/browser-rum": {
Expand All @@ -38,7 +38,8 @@
"@types/react-dom": "18.3.5",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.28.2"
"react-router-dom-6": "npm:[email protected]",
"react-router-dom-7": "npm:[email protected]"
},
"repository": {
"type": "git",
Expand Down
6 changes: 6 additions & 0 deletions packages/rum-react/react-router-v7/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"private": true,
"main": "../cjs/entries/reactRouterV7.js",
"module": "../esm/entries/reactRouterV7.js",
"types": "../cjs/entries/reactRouterV7.d.ts"
}
RomanGaignault marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { initializeReactPlugin } from '../../../test/initializeReactPlugin'
import { createMemoryRouter as createMemoryRouterV6 } from '../../entries/reactRouterV6'
import { createMemoryRouter as createMemoryRouterV7 } from '../../entries/reactRouterV7'

describe('createRouter', () => {
const versions = [
{ label: 'react-router v6', createMemoryRouter: createMemoryRouterV6 },
{ label: 'react-router v7', createMemoryRouter: createMemoryRouterV7 },
]

for (const { label, createMemoryRouter } of versions) {
describe(label, () => {
let startViewSpy: jasmine.Spy<(name?: string | object) => void>
let router: ReturnType<typeof createMemoryRouter>

beforeEach(() => {
if (!window.AbortController) {
pending('createMemoryRouter relies on AbortController')
}

startViewSpy = jasmine.createSpy()
initializeReactPlugin({
configuration: {
router: true,
},
publicApi: {
startView: startViewSpy,
},
})

router = createMemoryRouter(
[{ path: '/foo' }, { path: '/bar', children: [{ path: 'nested' }] }, { path: '*' }],
{
initialEntries: ['/foo'],
}
)
})

afterEach(() => {
router?.dispose()
})

it('creates a new view when the router is created', () => {
expect(startViewSpy).toHaveBeenCalledWith('/foo')
})

it('creates a new view when the router navigates', async () => {
startViewSpy.calls.reset()
await router.navigate('/bar')
expect(startViewSpy).toHaveBeenCalledWith('/bar')
})

it('creates a new view when the router navigates to a nested route', async () => {
await router.navigate('/bar')
startViewSpy.calls.reset()
await router.navigate('/bar/nested')
expect(startViewSpy).toHaveBeenCalledWith('/bar/nested')
})

it('creates a new view with the fallback route', async () => {
startViewSpy.calls.reset()
await router.navigate('/non-existent')
expect(startViewSpy).toHaveBeenCalledWith('/non-existent')
})

it('does not create a new view when navigating to the same URL', async () => {
await router.navigate('/bar')
startViewSpy.calls.reset()
await router.navigate('/bar')
expect(startViewSpy).not.toHaveBeenCalled()
})

it('does not create a new view when just changing query parameters', async () => {
await router.navigate('/bar')
startViewSpy.calls.reset()
await router.navigate('/bar?baz=1')
expect(startViewSpy).not.toHaveBeenCalled()
})
})
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import React from 'react'
import { flushSync } from 'react-dom'
import { MemoryRouter as MemoryRouterV6, Route as RouteV6, useNavigate as useNavigateV6 } from 'react-router-dom-6'
import { MemoryRouter as MemoryRouterV7, Route as RouteV7, useNavigate as useNavigateV7 } from 'react-router-dom-7'
import { initializeReactPlugin } from '../../../test/initializeReactPlugin'
import { appendComponent } from '../../../test/appendComponent'
import { Routes as RoutesV6 } from '../../entries/reactRouterV6'
import { Routes as RoutesV7 } from '../../entries/reactRouterV7'
;[
{
version: 'react-router-6',
MemoryRouter: MemoryRouterV6,
Route: RouteV6,
useNavigate: useNavigateV6,
Routes: RoutesV6,
},
{
version: 'react-router-7',
MemoryRouter: MemoryRouterV7,
Route: RouteV7,
useNavigate: useNavigateV7,
Routes: RoutesV7,
},
].forEach(({ version, MemoryRouter, Route, useNavigate, Routes }) => {
describe(`Routes component (${version})`, () => {
let startViewSpy: jasmine.Spy<(name?: string | object) => void>

beforeEach(() => {
startViewSpy = jasmine.createSpy()
initializeReactPlugin({
configuration: {
router: true,
},
publicApi: {
startView: startViewSpy,
},
})
})

it('starts a new view as soon as it is rendered', () => {
appendComponent(
<MemoryRouter initialEntries={['/foo']}>
<Routes>
<Route path="/foo" element={null} />
</Routes>
</MemoryRouter>
)

expect(startViewSpy).toHaveBeenCalledOnceWith('/foo')
})

it('renders the matching route', () => {
const container = appendComponent(
<MemoryRouter initialEntries={['/foo']}>
<Routes>
<Route path="/foo" element="foo" />
</Routes>
</MemoryRouter>
)

expect(container.innerHTML).toBe('foo')
})

it('does not start a new view on re-render', () => {
let forceUpdate: () => void

function App() {
const [, setState] = React.useState(0)
forceUpdate = () => setState((s) => s + 1)
return (
<MemoryRouter initialEntries={['/foo']}>
<Routes>
<Route path="/foo" element={null} />
</Routes>
</MemoryRouter>
)
}

appendComponent(<App />)

expect(startViewSpy).toHaveBeenCalledTimes(1)

flushSync(() => {
forceUpdate!()
})

expect(startViewSpy).toHaveBeenCalledTimes(1)
})

it('starts a new view on navigation', async () => {
let navigate: (path: string) => void

function NavBar() {
navigate = useNavigate()
return null
}

appendComponent(
<MemoryRouter initialEntries={['/foo']}>
<NavBar />
<Routes>
<Route path="/foo" element={null} />
<Route path="/bar" element={null} />
</Routes>
</MemoryRouter>
)

startViewSpy.calls.reset()
flushSync(() => {
navigate!('/bar')
})
await new Promise((resolve) => setTimeout(resolve, 0))
RomanGaignault marked this conversation as resolved.
Show resolved Hide resolved
expect(startViewSpy).toHaveBeenCalledOnceWith('/bar')
})

it('does not start a new view if the URL is the same', () => {
let navigate: (path: string) => void

function NavBar() {
navigate = useNavigate()
return null
}

appendComponent(
<MemoryRouter initialEntries={['/foo']}>
<NavBar />
<Routes>
<Route path="/foo" element={null} />
</Routes>
</MemoryRouter>
)

startViewSpy.calls.reset()
flushSync(() => {
navigate!('/foo')
})

expect(startViewSpy).not.toHaveBeenCalled()
})

it('does not start a new view if the path is the same but with different parameters', () => {
let navigate: (path: string) => void

function NavBar() {
navigate = useNavigate()
return null
}

appendComponent(
<MemoryRouter initialEntries={['/foo']}>
<NavBar />
<Routes>
<Route path="/foo" element={null} />
</Routes>
</MemoryRouter>
)

startViewSpy.calls.reset()
flushSync(() => {
navigate!('/foo?bar=baz')
})

expect(startViewSpy).not.toHaveBeenCalled()
})

it('does not start a new view if it does not match any route', () => {
// Prevent react router from showing a warning in the console when a route does not match
spyOn(console, 'warn')

appendComponent(
<MemoryRouter>
<Routes>
<Route path="/bar" element={null} />
</Routes>
</MemoryRouter>
)

expect(startViewSpy).not.toHaveBeenCalled()
})

it('allows passing a location object', () => {
appendComponent(
<MemoryRouter>
<Routes location={{ pathname: '/foo' }}>
<Route path="/foo" element={null} />
</Routes>
</MemoryRouter>
)

expect(startViewSpy).toHaveBeenCalledOnceWith('/foo')
})

it('allows passing a location string', () => {
appendComponent(
<MemoryRouter>
<Routes location="/foo">
<Route path="/foo" element={null} />
</Routes>
</MemoryRouter>
)

expect(startViewSpy).toHaveBeenCalledOnceWith('/foo')
})
})
})
Loading