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 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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
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',
])

/**
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-6": "npm:[email protected]",
"react-router-7": "npm:[email protected]"
RomanGaignault marked this conversation as resolved.
Show resolved Hide resolved
},
"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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { initializeReactPlugin } from '../../../test/initializeReactPlugin'
import { createMemoryRouter as createMemoryRouterV6 } from '../reactRouterV6'
import { createMemoryRouter as createMemoryRouterV7 } from '../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-6'
import { MemoryRouter as MemoryRouterV7, Route as RouteV7, useNavigate as useNavigateV7 } from 'react-router-7'
import { initializeReactPlugin } from '../../../test/initializeReactPlugin'
import { appendComponent } from '../../../test/appendComponent'
import { Routes as RoutesV6 } from '../reactRouterV6'
import { Routes as RoutesV7 } from '../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))
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
Loading