Compare commits

..

2 Commits

Author SHA1 Message Date
Naman Verma
f4d84b41fe Merge branch 'main' into nv/cache-grid 2026-10-30 19:58:53 +05:30
Naman Verma
5dcc195c72 perf: divide cache buckets for a query into cells in a grid 2026-10-30 19:55:47 +05:30
86 changed files with 2352 additions and 2008 deletions

View File

@@ -593,18 +593,16 @@
}
},
{
// The react-router allowlist: the facade itself, the route table, the router mount,
// `useSafeNavigate` and the test and story router mounts. Test files are
// The react-router allowlist: the facade itself, the route table, `useSafeNavigate`,
// the history singleton and the test and story router mounts. Test files are
// deliberately absent: they render at a route or use tests/router-mocks.
"files": [
"src/lib/router/**",
"src/AppRoutes/**",
"src/app/AppRouter.tsx",
"src/hooks/useSafeNavigate.ts",
"src/lib/history.ts",
"src/tests/router.tsx",
"src/tests/router-utils.ts",
"src/storybook/navigation/containment.ts",
"src/storybook/navigation/pageScope.ts",
"src/storybook/providers/StorybookProviders.tsx",
"src/storybook/renderAtRoute.tsx"
],
"rules": {

View File

@@ -8,8 +8,8 @@ const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
/**
* Modules replaced for every story. Same idea as `moduleNameMapper` in
* `jest.config.ts`: the app keeps importing its own paths, Storybook resolves
* them to a mock. Regexes so only exact specifiers match: `store` must not
* catch `store/actions`.
* them to a mock. Regexes so only exact specifiers match: `lib/history` must
* not catch `lib/historyUtils`.
*
* Each replacement is typed as the module it stands in for, so drift is a
* compile error rather than a story that fails at render. The `jest` note on
@@ -17,6 +17,11 @@ const srcPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src');
* only diverge where the runner needs them to.
*/
const mockAliases = [
{
// jest: not replaced, jsdom drives a real browser history.
find: /^(?:src\/)?lib\/history$/,
replacement: `${srcPath}/storybook/navigation/history.alias.ts`,
},
{
// jest: src/__tests__/logEventMock.ts
find: /^(?:src\/)?api\/common\/logEvent$/,

View File

@@ -64,7 +64,7 @@ const config: Config.InitialOptions = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],
moduleDirectories: ['node_modules', 'src'],
testEnvironment: '<rootDir>/jest.environment.cjs',
testEnvironment: 'jest-environment-jsdom',
coverageThreshold: {
global: {
statements: 80,

View File

@@ -1,22 +0,0 @@
const { TestEnvironment } = require('jest-environment-jsdom');
/**
* jsdom plus the fetch `Request` a data router builds for every navigation,
* which jsdom does not ship. Node's own class is used, except that it keeps the
* `signal` it is handed: the router passes jsdom's `AbortSignal`, and Node's
* `Request` rejects one from another realm.
*/
class JSDOMWithRequestEnvironment extends TestEnvironment {
constructor(config, context) {
super(config, context);
this.global.Request = class extends Request {
constructor(input, { signal, ...init } = {}) {
super(input, init);
Object.defineProperty(this, 'signal', { value: signal ?? super.signal });
}
};
}
}
module.exports = JSDOMWithRequestEnvironment;

View File

@@ -5,8 +5,6 @@
/**
* Adds custom matchers from the react testing library to all tests
*/
import { TextDecoder, TextEncoder } from 'node:util';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import 'jest-styled-components';
@@ -16,14 +14,6 @@ import { server } from './src/mocks-server/server';
import './src/styles.scss';
// Establish API mocking before all tests.
// react-router@7's entry point pulls in its server-runtime cookie signing,
// which builds a TextEncoder at module scope. jsdom ships neither encoder, so
// importing anything from the router throws before a test starts.
Object.assign(globalThis, {
TextEncoder: globalThis.TextEncoder ?? TextEncoder,
TextDecoder: globalThis.TextDecoder ?? TextDecoder,
});
// Mock window.matchMedia
window.matchMedia =
window.matchMedia ||

View File

@@ -83,6 +83,7 @@
"dompurify": "3.4.15",
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
"html-to-image": "1.11.13",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
@@ -116,7 +117,8 @@
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router": "7.18.4",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.30.6",
"react-syntax-highlighter": "15.5.0",
"react-use": "^17.3.2",
"react-virtuoso": "4.0.3",
@@ -170,6 +172,7 @@
"@types/crypto-js": "4.2.2",
"@types/d3-hierarchy": "1.1.11",
"@types/event-source-polyfill": "^1.0.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@types/lodash-es": "^4.17.4",
"@types/node": "^22.20.5",
@@ -180,6 +183,7 @@
"@types/react-grid-layout": "^1.1.2",
"@types/react-redux": "^7.1.11",
"@types/react-resizable": "3.0.3",
"@types/react-router-dom": "^5.1.6",
"@types/react-syntax-highlighter": "15.5.13",
"@types/redux-mock-store": "1.0.4",
"@types/styled-components": "^5.1.4",

View File

@@ -1,6 +1,7 @@
import { ruleTester } from './rule-tester.mjs';
const FACADE = 'src/lib/router facade';
const HISTORY = 'lib/history singleton';
const MOCK = 'tests/router-mocks helpers';
await ruleTester({
@@ -10,6 +11,11 @@ await ruleTester({
name: 'facade import',
code: "import { useAppNavigate } from 'lib/router/useAppNavigate';",
},
{
name:
'the history package itself is a version-bump concern, not a facade one',
code: "import { createBrowserHistory } from 'history';",
},
{
name: 'unrelated module whose name contains history',
code: "import { useHistoryPanel } from 'container/HistoryPanel';",
@@ -43,8 +49,13 @@ await ruleTester({
errors: [{ message: MOCK }],
},
{
name: 'jest.doMock of react-router',
code: "jest.doMock('react-router');",
name: 'jest.mock of lib/history',
code: "jest.mock('lib/history');",
errors: [{ message: MOCK }],
},
{
name: 'jest.doMock of react-router-dom-v5-compat',
code: "jest.doMock('react-router-dom-v5-compat');",
errors: [{ message: MOCK }],
},
{
@@ -63,14 +74,29 @@ await ruleTester({
errors: [{ message: FACADE }],
},
{
name: 'react-router-dom is flagged as the v7 re-export shim',
code: "import { Outlet } from 'react-router-dom';",
errors: [{ message: 'only a re-export shim' }],
name: 'react-router-dom-v5-compat',
code: "import { useNavigate } from 'react-router-dom-v5-compat';",
errors: [{ message: 'compat package is an implementation detail' }],
},
{
name: 'require',
code: "const router = require('react-router');",
errors: [{ message: FACADE }],
name: 'lib/history default import',
code: "import history from 'lib/history';",
errors: [{ message: HISTORY }],
},
{
name: 'lib/history through the @/ alias',
code: "import history from '@/lib/history';",
errors: [{ message: HISTORY }],
},
{
name: 'jest.mock of lib/history through the @/ alias',
code: "jest.mock('@/lib/history');",
errors: [{ message: MOCK }],
},
{
name: 'lib/history require',
code: "const history = require('lib/history').default;",
errors: [{ message: HISTORY }],
},
{
name: 'dynamic import',
@@ -90,10 +116,10 @@ await ruleTester({
{
name: 'one report per import statement',
code:
"import { Link } from 'react-router';\nimport { Outlet } from 'react-router';",
"import { Link } from 'react-router-dom';\nimport history from 'lib/history';",
errors: [
{ message: FACADE, line: 1 },
{ message: FACADE, line: 2 },
{ message: HISTORY, line: 2 },
],
},
],

View File

@@ -5,14 +5,18 @@
* upgrade is a change to one directory instead of ~300 call sites. This rule
* keeps new call sites, and test mocks, from reaching past it.
*
* `react-router-dom` is still flagged even though the package is gone: on v7 it is a
* re-export shim over `react-router`, so reinstalling it would split the tree again.
* `history` (the package) is deliberately not flagged: it is a transitive concern of
* the version bump, not something the facade replaces.
*
* The allowlist (the facade itself, the route table, the router mount,
* `useSafeNavigate` and the test and story router mounts) is applied via overrides
* in .oxlintrc.json, not here, so the file list stays visible next to the severity.
* The allowlist (the facade itself, the route table, `useSafeNavigate`, the history
* singleton and the test and story router mounts) is applied via overrides in
* .oxlintrc.json, not here, so the file list stays visible next to the severity.
*/
import path from 'node:path';
const HISTORY_MODULE_SUFFIX = path.join('src', 'lib', 'history');
const JEST_MODULE_METHODS = new Set([
'mock',
'doMock',
@@ -25,12 +29,30 @@ const JEST_MODULE_METHODS = new Set([
]);
const MESSAGE_IDS = {
'react-router': 'router',
'react-router-dom': 'routerDom',
'react-router': 'v5Router',
'react-router-dom': 'v5Router',
'react-router-dom-v5-compat': 'v6Compat',
'lib/history': 'historySingleton',
'@/lib/history': 'historySingleton',
};
function messageIdFor(specifier) {
return MESSAGE_IDS[specifier] ?? null;
/** Resolves `./history` / `../history` so files inside src/lib count too. */
function isHistoryModule(specifier, filename) {
if (!specifier.startsWith('.') || !filename) {
return false;
}
const resolved = path.resolve(path.dirname(filename), specifier);
return (
resolved.endsWith(HISTORY_MODULE_SUFFIX) ||
resolved.endsWith(`${HISTORY_MODULE_SUFFIX}.ts`)
);
}
function messageIdFor(specifier, filename) {
if (MESSAGE_IDS[specifier] !== undefined) {
return MESSAGE_IDS[specifier];
}
return isHistoryModule(specifier, filename) ? 'historySingleton' : null;
}
export default {
@@ -38,15 +60,17 @@ export default {
type: 'suggestion',
docs: {
description:
'Disallow direct react-router imports; import from src/lib/router instead',
'Disallow direct react-router / lib/history imports; import from src/lib/router instead',
category: 'React Router migration',
},
schema: [],
messages: {
router:
'Do not import react-router directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
routerDom:
'Do not import react-router-dom. The app is on react-router; on v7 react-router-dom is only a re-export shim, and installing it puts two copies of the router in the tree. Use the src/lib/router facade.',
v5Router:
'Do not import react-router-dom directly. Use the src/lib/router facade (useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute) so a router upgrade stays contained.',
v6Compat:
'Do not import react-router-dom-v5-compat directly. Use the src/lib/router facade instead; the compat package is an implementation detail of the migration and disappears with it.',
historySingleton:
'Do not import the lib/history singleton. Use the helpers in src/lib/router/navigation.ts, or useAppNavigate() to follow the nearest router. history loses basename handling once the router owns it.',
mock:
'Do not mock the router directly. Render at a route with tests/test-utils, or spy on the facade with the tests/router-mocks helpers.',
},
@@ -61,7 +85,7 @@ export default {
) {
return;
}
const messageId = messageIdFor(sourceNode.value);
const messageId = messageIdFor(sourceNode.value, context.filename);
if (messageId !== null) {
context.report({
node: sourceNode,

126
frontend/pnpm-lock.yaml generated
View File

@@ -24,6 +24,7 @@ overrides:
nanoid@<3.3.18: '>=3.3.18 <4'
prismjs@<1.30.0: '>=1.30.0 <2'
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
tmp@<0.2.6: '>=0.2.6 <0.3.0'
yaml@>=1.0.0 <1.10.3: '>=1.10.3 <2'
'@types/node': ^22.20.5
@@ -82,7 +83,7 @@ importers:
version: 0.4.0
'@signozhq/ui':
specifier: 0.2.0
version: 0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
version: 0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -164,6 +165,9 @@ importers:
eventemitter3:
specifier: 5.0.1
version: 5.0.1
history:
specifier: 4.10.1
version: 4.10.1
html-to-image:
specifier: 1.11.13
version: 1.11.13
@@ -199,7 +203,7 @@ importers:
version: 12.4.13(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nuqs:
specifier: 2.8.8
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
overlayscrollbars:
specifier: ^2.16.0
version: 2.16.0
@@ -263,9 +267,12 @@ importers:
react-rnd:
specifier: ^10.5.3
version: 10.5.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router:
specifier: 7.18.4
version: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router-dom:
specifier: ^5.2.0
version: 5.3.4(react@18.2.0)
react-router-dom-v5-compat:
specifier: 6.30.6
version: 6.30.6(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react@18.2.0)
react-syntax-highlighter:
specifier: 15.5.0
version: 15.5.0(react@18.2.0)
@@ -384,6 +391,9 @@ importers:
'@types/event-source-polyfill':
specifier: ^1.0.0
version: 1.0.1
'@types/history':
specifier: 4.7.11
version: 4.7.11
'@types/jest':
specifier: 30.0.0
version: 30.0.0
@@ -414,6 +424,9 @@ importers:
'@types/react-resizable':
specifier: 3.0.3
version: 3.0.3
'@types/react-router-dom':
specifier: ^5.1.6
version: 5.3.3
'@types/react-syntax-highlighter':
specifier: 15.5.13
version: 15.5.13
@@ -3315,6 +3328,10 @@ packages:
'@react-dnd/shallowequal@4.0.2':
resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==}
'@remix-run/router@1.23.4':
resolution: {integrity: sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==}
engines: {node: '>=14.0.0'}
'@rolldown/binding-android-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3996,6 +4013,9 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/history@4.7.11':
resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
'@types/hoist-non-react-statics@3.3.1':
resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==}
@@ -4077,6 +4097,12 @@ packages:
'@types/react-resizable@3.0.3':
resolution: {integrity: sha512-W/QsUOZoXBAIBQNhNm95A5ohoaiUA874lWQytO2UP9dOjp5JHO9+a0cwYNabea7sA12ZDJnGVUFZxcNaNksAWA==}
'@types/react-router-dom@5.3.3':
resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==}
'@types/react-router@5.1.20':
resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}
'@types/react-syntax-highlighter@15.5.13':
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
@@ -5009,10 +5035,6 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
@@ -6096,6 +6118,9 @@ packages:
history@4.10.1:
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==}
history@5.3.0:
resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
@@ -7490,7 +7515,7 @@ packages:
'@tanstack/react-router': ^1
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^5 || ^6 || ^7
react-router: '>=6.30.6 <7'
react-router-dom: ^5 || ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
@@ -7511,7 +7536,7 @@ packages:
'@tanstack/react-router': ^1
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^5 || ^6 || ^7 || ^8
react-router: '>=6.30.6 <7'
react-router-dom: ^5 || ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
@@ -8435,6 +8460,14 @@ packages:
react: '>=16.3.0'
react-dom: '>=16.3.0'
react-router-dom-v5-compat@6.30.6:
resolution: {integrity: sha512-I54amXpivD3V0r1BYUIj1qLOljXx+UpbLG63Bqbm3BdKH62z4H3znC0L7SyMTYAMEDZuUjDSlqtkBRG/OEDXBg==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router-dom: 4 || 5
react-router-dom@5.3.4:
resolution: {integrity: sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==}
peerDependencies:
@@ -8445,15 +8478,11 @@ packages:
peerDependencies:
react: '>=15'
react-router@7.18.4:
resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==}
engines: {node: '>=20.0.0'}
react-router@6.30.6:
resolution: {integrity: sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react: '>=16.8'
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
@@ -12899,6 +12928,8 @@ snapshots:
'@react-dnd/shallowequal@4.0.2': {}
'@remix-run/router@1.23.4': {}
'@rolldown/binding-android-arm64@1.0.0-beta.53':
optional: true
@@ -13131,7 +13162,7 @@ snapshots:
'@signozhq/icons@0.4.0': {}
'@signozhq/ui@0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)':
dependencies:
'@base-ui/react': 1.7.0(@date-fns/tz@1.4.1)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@chenglou/pretext': 0.0.8
@@ -13155,7 +13186,7 @@ snapshots:
lodash-es: 4.18.1
motion: 11.18.2(@emotion/is-prop-valid@1.2.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
next-themes: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nuqs: 2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
nuqs: 2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-compiler-runtime: 1.0.0(react@18.2.0)
react-day-picker: 9.9.0(react@18.2.0)
@@ -13607,6 +13638,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.2
'@types/history@4.7.11': {}
'@types/hoist-non-react-statics@3.3.1':
dependencies:
'@types/react': 18.0.26
@@ -13703,6 +13736,17 @@ snapshots:
dependencies:
'@types/react': 18.0.26
'@types/react-router-dom@5.3.3':
dependencies:
'@types/history': 4.7.11
'@types/react': 18.0.26
'@types/react-router': 5.1.20
'@types/react-router@5.1.20':
dependencies:
'@types/history': 4.7.11
'@types/react': 18.0.26
'@types/react-syntax-highlighter@15.5.13':
dependencies:
'@types/react': 18.0.26
@@ -14744,8 +14788,6 @@ snapshots:
cookie@0.7.2: {}
cookie@1.1.1: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
@@ -15933,6 +15975,10 @@ snapshots:
tiny-warning: 1.0.3
value-equal: 1.0.1
history@5.3.0:
dependencies:
'@babel/runtime': 7.29.2
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
@@ -16252,8 +16298,7 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
isarray@0.0.1:
optional: true
isarray@0.0.1: {}
isarray@1.0.0: {}
@@ -17748,20 +17793,20 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
nuqs@2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.0.0
react: 18.2.0
optionalDependencies:
react-router: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router: 6.30.6(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
nuqs@2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
nuqs@2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0):
dependencies:
'@standard-schema/spec': 1.1.0
react: 18.2.0
optionalDependencies:
react-router: 7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router: 6.30.6(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
nwsapi@2.2.23: {}
@@ -18137,7 +18182,6 @@ snapshots:
path-to-regexp@1.9.0:
dependencies:
isarray: 0.0.1
optional: true
path-to-regexp@6.3.0: {}
@@ -18907,9 +18951,18 @@ snapshots:
react-draggable: 4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
tslib: 2.6.2
react-router-dom-v5-compat@6.30.6(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react@18.2.0):
dependencies:
'@remix-run/router': 1.23.4
history: 5.3.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-router: 6.30.6(react@18.2.0)
react-router-dom: 5.3.4(react@18.2.0)
react-router-dom@5.3.4(react@18.2.0):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.28.2
history: 4.10.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -18917,11 +18970,10 @@ snapshots:
react-router: 5.3.4(react@18.2.0)
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
optional: true
react-router@5.3.4(react@18.2.0):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.28.2
history: 4.10.1
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
@@ -18931,15 +18983,11 @@ snapshots:
react-is: 16.13.1
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
optional: true
react-router@7.18.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
react-router@6.30.6(react@18.2.0):
dependencies:
cookie: 1.1.1
'@remix-run/router': 1.23.4
react: 18.2.0
set-cookie-parser: 2.6.0
optionalDependencies:
react-dom: 18.2.0(react@18.2.0)
react-style-singleton@2.2.3(@types/react@18.0.26)(react@18.2.0):
dependencies:

View File

@@ -74,6 +74,11 @@ overrides:
# (postcss-selector-parser ^7.0.0)
# remove: blocked, 4.2.0 is latest and the range is open, so the floor is what pulls the fix
postcss-selector-parser@>=7.1.0 <7.1.3: '>=7.1.3 <8'
# via: @signozhq/ui > nuqs@2 (react-router ^6.4.0 || ^7)
# remove: blocked. GHSA-wrjc-x8rr-h8h6 and the deserializeErrors advisory are patched
# only in 7.18.0. Do NOT open the cap: react-router >=7 requires React 19 and breaks
# the app-wide CompatRouter, so those two moderates stay until the app moves to React 19
react-router@>=6.7.0 <6.30.6: '>=6.30.6 <7'
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
tmp@<0.2.6: '>=0.2.6 <0.3.0'

View File

@@ -1,4 +1,5 @@
import { ReactChild, useCallback, useMemo } from 'react';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useListUsers } from 'api/generated/services/users';
@@ -8,9 +9,6 @@ import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { isEmpty } from 'lodash-es';
import { matchRoute } from 'lib/router/matchRoute';
import { Redirect } from 'lib/router/Redirect';
import { useAppLocation } from 'lib/router/useAppLocation';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
@@ -30,7 +28,7 @@ import routes, {
// eslint-disable-next-line sonarjs/cognitive-complexity
function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const location = useAppLocation();
const location = useLocation();
const { pathname } = location;
const {
org,
@@ -49,11 +47,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
() =>
new Map(
[...routes, LIST_LICENSES, SUPPORT_ROUTE].map((e) => {
const patterns = Array.isArray(e.path) ? e.path : [e.path];
const matches = patterns.some(
(pattern) => matchRoute(pathname, pattern) !== null,
);
return [matches ? 'current' : null, e];
const currentPath = matchPath(pathname, {
path: e.path,
});
return [currentPath === null ? null : 'current', e];
}),
),
[pathname],
@@ -245,7 +242,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} replace={false} />;
return <Redirect to={fromPathname} />;
}
if (pathname !== ROUTES.SOMETHING_WENT_WRONG) {
return <Redirect to={ROUTES.HOME} />;
@@ -259,7 +256,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
);
if (fromPathname) {
setLocalStorageApi(LOCALSTORAGE.UNAUTHENTICATED_ROUTE_HIT, '');
return <Redirect to={fromPathname} replace={false} />;
return <Redirect to={fromPathname} />;
}
return <Redirect to={ROUTES.HOME} />;
} else {

View File

@@ -1,6 +1,6 @@
import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router';
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
import { act, render, screen, waitFor } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
@@ -237,17 +237,12 @@ function buildPrivateRouteTree(
<MemoryRouter initialEntries={[initialRoute]}>
<AppContext.Provider value={contextValue}>
<PrivateRoute>
<Routes>
<Route
path="*"
element={
<>
<div data-testid="children-rendered">Content</div>
<LocationDisplay />
</>
}
/>
</Routes>
<Switch>
<Route path="*">
<div data-testid="children-rendered">Content</div>
<LocationDisplay />
</Route>
</Switch>
</PrivateRoute>
</AppContext.Provider>
</MemoryRouter>

View File

@@ -1,84 +0,0 @@
import { lazy, Suspense, useState } from 'react';
import {
createBrowserRouter,
type DataRouter,
Outlet,
RouterProvider,
} from 'react-router';
import { act, render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { resetTestRoute } from 'tests/router-utils';
import { createGatedRoutes, type EnabledRouteKeys } from '../routeTable';
import type { AppRoutes } from '../routes';
let mounts = 0;
function Page(): JSX.Element {
const [mount] = useState(() => {
mounts += 1;
return mounts;
});
return <div data-testid="page">mount {mount}</div>;
}
// Two lazy wrappers of one module, like the logs explorer and pipelines routes.
const Explorer = lazy(async () => ({ default: Page }));
const Pipelines = lazy(async () => ({ default: Page }));
const routes: AppRoutes[] = [
{ path: ROUTES.LOGS, component: Explorer, key: 'LOGS', isPrivate: true },
{
path: [ROUTES.LOGS_PIPELINES, ROUTES.LOGS_INDEX_FIELDS],
component: Pipelines,
key: 'LOGS_PIPELINES',
isPrivate: true,
},
];
const enabledRouteKeys: EnabledRouteKeys = new Set(['LOGS', 'LOGS_PIPELINES']);
function renderRoutes(): DataRouter {
window.history.replaceState(null, '', ROUTES.LOGS);
const router = createBrowserRouter([
{
element: (
<Suspense fallback={<div data-testid="fallback" />}>
<Outlet context={enabledRouteKeys} />
</Suspense>
),
children: createGatedRoutes(routes),
},
]);
render(<RouterProvider router={router} useTransitions={false} />);
return router;
}
describe('createGatedRoutes', () => {
beforeEach(() => {
mounts = 0;
});
afterEach(() => {
resetTestRoute();
});
it('remounts the page when another route entry resolves to the same component', async () => {
const router = renderRoutes();
await expect(screen.findByText('mount 1')).resolves.toBeInTheDocument();
await act(() => router.navigate(ROUTES.LOGS_PIPELINES));
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
});
it('keeps the page mounted across the patterns of one route entry', async () => {
const router = renderRoutes();
await act(() => router.navigate(ROUTES.LOGS_PIPELINES));
await expect(screen.findByText('mount 2')).resolves.toBeInTheDocument();
await act(() => router.navigate(ROUTES.LOGS_INDEX_FIELDS));
expect(screen.getByTestId('page')).toHaveTextContent('mount 2');
});
});

View File

@@ -1,12 +1,6 @@
import {
ReactNode,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Outlet } from 'react-router';
import { ReactNode, Suspense, useCallback, useEffect, useState } from 'react';
import { Route, Router, Switch } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
@@ -15,6 +9,7 @@ import AppPageProviders from 'app/AppPageProviders';
import AppShell from 'app/AppShell';
import AppLoading from 'components/AppLoading/AppLoading';
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
import Spinner from 'components/Spinner';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -28,7 +23,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { StatusCodes } from 'http-status-codes';
import { IS_PROD } from 'lib/env';
import { getCurrentLocation, navigate, subscribe } from 'lib/router/navigation';
import history from 'lib/history';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js';
import { useAppContext } from 'providers/App/App';
@@ -36,19 +31,25 @@ import { IUser } from 'providers/App/types';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
import defaultRoutes, {
AppRoutes,
LIST_LICENSES,
SUPPORT_ROUTE,
} from './routes';
import type { EnabledRouteKeys } from './routeTable';
import {
hasNoAppFrames,
isExternalIntegrationError,
SENTRY_IGNORE_ERRORS,
} from './sentryFilters';
const appRouter = (children: ReactNode): ReactNode => (
<Router history={history}>
<CompatRouter>{children}</CompatRouter>
</Router>
);
const appLayout = (children: ReactNode): ReactNode => (
<AppLayout>{children}</AppLayout>
);
@@ -69,24 +70,18 @@ function App(): JSX.Element {
isPreflightLoading,
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const enabledRouteKeys = useMemo<EnabledRouteKeys>(
() => new Set(routes.map(({ key }) => key)),
[routes],
);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const chatSupport = useChatSupport();
const { hostname } = window.location;
// Through the facade, not the raw history: the router owns the base path
// now, so `history.location.pathname` would still carry it.
const [pathname, setPathname] = useState(getCurrentLocation().pathname);
const [pathname, setPathname] = useState(history.location.pathname);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
useEffect(() => {
return subscribe(({ location }) => {
return history.listen((location) => {
setPathname(location.pathname);
});
}, []);
@@ -403,7 +398,7 @@ function App(): JSX.Element {
// this needs to be on top of data missing error because if there is an error, data will never be loaded and it will
// move to indefinitive loading
if (userFetchError && pathname !== ROUTES.SOMETHING_WENT_WRONG) {
navigate(ROUTES.SOMETHING_WENT_WRONG, { replace: true });
history.replace(ROUTES.SOMETHING_WENT_WRONG);
}
// if all of the data is not set then return a spinner, this is required because there is some gap between loading states and data setting
@@ -419,6 +414,7 @@ function App(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<AppShell
router={appRouter}
overlays={
isLoggedInState && (
<>
@@ -431,7 +427,18 @@ function App(): JSX.Element {
<PrivateRoute>
<AppPageProviders layout={appLayout}>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Outlet context={enabledRouteKeys} />
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppPageProviders>
</PrivateRoute>

View File

@@ -1,69 +0,0 @@
import type { ComponentType, ReactNode } from 'react';
import { type RouteObject, useOutletContext } from 'react-router';
import NotFound from 'components/NotFound';
import { RethrowRouteError } from 'lib/router/RethrowRouteError';
import { Home } from './pageComponents';
import routes, { AppRoutes, LIST_LICENSES, SUPPORT_ROUTE } from './routes';
/**
* The routes the plan, the license and the feature flags enable. The root
* layout computes it and hands it down as outlet context.
*/
export type EnabledRouteKeys = ReadonlySet<AppRoutes['key']>;
interface RouteGateProps {
routeKey: AppRoutes['key'];
component: ComponentType;
}
/**
* The table is created once, so a route the session may not use is still in
* it. It renders the not-found page instead, which is what the old dynamic
* `<Switch>` fell through to.
*/
function RouteGate({
routeKey,
component: Component,
}: RouteGateProps): JSX.Element {
const enabledRouteKeys = useOutletContext<EnabledRouteKeys>();
return enabledRouteKeys.has(routeKey) ? <Component /> : <NotFound />;
}
/**
* The gate is keyed by its route entry. The outlet renders the match unkeyed,
* so every route would otherwise reuse the previous route's gate, and a lazy
* page that resolves to the same component would keep its instance. The
* patterns of one entry share the key.
*/
export function createGatedRoutes(appRoutes: AppRoutes[]): RouteObject[] {
return appRoutes.flatMap(({ path, component, nested, key }) =>
(Array.isArray(path) ? path : [path]).map((pattern) => ({
path: nested ? `${pattern}/*` : pattern,
element: <RouteGate key={key} routeKey={key} component={component} />,
})),
);
}
const gatedRoutes = createGatedRoutes([
...routes,
LIST_LICENSES,
SUPPORT_ROUTE,
]);
/** `root` is the layout every route renders in; it has to render an `<Outlet>`. */
export function createRouteTable(root: ReactNode): RouteObject[] {
return [
{
element: root,
// An error above `Sentry.ErrorBoundary` in the root layout crashes the
// root and reaches Sentry, as it did before the data router.
errorElement: <RethrowRouteError />,
children: [
...gatedRoutes,
{ path: '/', element: <Home /> },
{ path: '*', element: <NotFound /> },
],
},
];
}

View File

@@ -1,4 +1,4 @@
import type { ComponentType } from 'react';
import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import {
@@ -58,25 +58,27 @@ const routes: AppRoutes[] = [
{
component: SignupPage,
path: ROUTES.SIGN_UP,
exact: true,
isPrivate: false,
key: 'SIGN_UP',
},
{
path: ROUTES.GET_STARTED_WITH_CLOUD,
nested: true,
exact: false,
component: OnboardingV2,
isPrivate: true,
key: 'GET_STARTED_WITH_CLOUD',
},
{
path: ROUTES.HOME,
exact: true,
component: Home,
isPrivate: true,
key: 'HOME',
},
{
path: ROUTES.ONBOARDING,
nested: true,
exact: false,
component: OrgOnboarding,
isPrivate: true,
key: 'ONBOARDING',
@@ -84,23 +86,27 @@ const routes: AppRoutes[] = [
{
component: LogsIndexToFields,
path: ROUTES.LOGS_INDEX_FIELDS,
exact: true,
isPrivate: true,
key: 'LOGS_INDEX_FIELDS',
},
{
component: ServicesTablePage,
path: ROUTES.APPLICATION,
exact: true,
isPrivate: true,
key: 'APPLICATION',
},
{
path: ROUTES.SERVICE_METRICS,
exact: true,
component: ServiceMetricsPage,
isPrivate: true,
key: 'SERVICE_METRICS',
},
{
path: ROUTES.SERVICE_TOP_LEVEL_OPERATIONS,
exact: true,
component: ServiceTopLevelOperationsPage,
isPrivate: true,
key: 'SERVICE_TOP_LEVEL_OPERATIONS',
@@ -109,252 +115,292 @@ const routes: AppRoutes[] = [
path: ROUTES.SERVICE_MAP,
component: ServiceMapPage,
isPrivate: true,
exact: true,
key: 'SERVICE_MAP',
},
{
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.SETTINGS,
nested: true,
exact: false,
component: SettingsPage,
isPrivate: true,
key: 'SETTINGS',
},
{
path: ROUTES.USAGE_EXPLORER,
exact: true,
component: UsageExplorerPage,
isPrivate: true,
key: 'USAGE_EXPLORER',
},
{
path: ROUTES.ALL_DASHBOARD,
exact: true,
component: DashboardsListPage,
isPrivate: true,
key: 'ALL_DASHBOARD',
},
{
path: ROUTES.DASHBOARD,
exact: true,
component: DashboardPage,
isPrivate: true,
key: 'DASHBOARD',
},
{
path: ROUTES.PUBLIC_DASHBOARD,
nested: true,
exact: false,
component: PublicDashboardPage,
isPrivate: false,
key: 'PUBLIC_DASHBOARD',
},
{
path: ROUTES.DASHBOARD_PANEL_EDITOR,
exact: true,
component: DashboardPanelEditorPage,
isPrivate: true,
key: 'DASHBOARD_PANEL_EDITOR',
},
{
path: ROUTES.EDIT_ALERTS,
exact: true,
component: EditRulesPage,
isPrivate: true,
key: 'EDIT_ALERTS',
},
{
path: ROUTES.LIST_ALL_ALERT,
exact: true,
component: ListAllALertsPage,
isPrivate: true,
key: 'LIST_ALL_ALERT',
},
{
path: ROUTES.ALERTS_NEW,
exact: true,
component: CreateNewAlerts,
isPrivate: true,
key: 'ALERTS_NEW',
},
{
path: ROUTES.ALERT_HISTORY,
exact: true,
component: AlertHistory,
isPrivate: true,
key: 'ALERT_HISTORY',
},
{
path: ROUTES.ALERT_OVERVIEW,
exact: true,
component: AlertOverview,
isPrivate: true,
key: 'ALERT_OVERVIEW',
},
{
path: ROUTES.TRACES_EXPLORER,
exact: true,
component: TracesExplorer,
isPrivate: true,
key: 'TRACES_EXPLORER',
},
{
path: ROUTES.TRACES_FUNNELS,
exact: true,
component: TracesFunnels,
isPrivate: true,
key: 'TRACES_FUNNELS',
},
{
path: ROUTES.TRACES_FUNNELS_DETAIL,
exact: true,
component: TracesFunnelDetails,
isPrivate: true,
key: 'TRACES_FUNNELS_DETAIL',
},
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: ChannelsNew,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: ChannelsEdit,
isPrivate: true,
key: 'CHANNELS_EDIT',
},
{
path: ROUTES.ALL_ERROR,
exact: true,
isPrivate: true,
component: AllErrors,
key: 'ALL_ERROR',
},
{
path: ROUTES.ERROR_DETAIL,
exact: true,
component: ErrorDetails,
isPrivate: true,
key: 'ERROR_DETAIL',
},
{
path: ROUTES.VERSION,
exact: true,
component: StatusPage,
isPrivate: true,
key: 'VERSION',
},
{
path: ROUTES.LOGS,
exact: true,
component: Logs,
key: 'LOGS',
isPrivate: true,
},
{
path: ROUTES.LOGS_PIPELINES,
exact: true,
component: PipelinePage,
key: 'LOGS_PIPELINES',
isPrivate: true,
},
{
path: ROUTES.LOGIN,
exact: true,
component: Login,
isPrivate: false,
key: 'LOGIN',
},
{
path: ROUTES.FORGOT_PASSWORD,
exact: true,
component: ForgotPassword,
isPrivate: false,
key: 'FORGOT_PASSWORD',
},
{
path: ROUTES.UN_AUTHORIZED,
exact: true,
component: UnAuthorized,
key: 'UN_AUTHORIZED',
isPrivate: true,
},
{
path: ROUTES.PASSWORD_RESET,
exact: true,
component: PasswordReset,
key: 'PASSWORD_RESET',
isPrivate: false,
},
{
path: ROUTES.SOMETHING_WENT_WRONG,
exact: true,
component: SomethingWentWrong,
key: 'SOMETHING_WENT_WRONG',
isPrivate: false,
},
{
path: ROUTES.WORKSPACE_LOCKED,
exact: true,
component: WorkspaceBlocked,
isPrivate: true,
key: 'WORKSPACE_LOCKED',
},
{
path: ROUTES.WORKSPACE_SUSPENDED,
exact: true,
component: WorkspaceSuspended,
isPrivate: true,
key: 'WORKSPACE_SUSPENDED',
},
{
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
exact: true,
component: WorkspaceAccessRestricted,
isPrivate: true,
key: 'WORKSPACE_ACCESS_RESTRICTED',
},
{
path: ROUTES.INTEGRATIONS_DETAIL,
exact: true,
component: IntegrationsDetailsPage,
isPrivate: true,
key: 'INTEGRATIONS_DETAIL',
},
{
path: ROUTES.INTEGRATIONS,
exact: true,
component: Integrations,
isPrivate: true,
key: 'INTEGRATIONS',
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_KAFKA',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_CELERY_TASK',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
exact: true,
component: MessagingQueuesMainPage,
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
isPrivate: true,
},
{
path: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
exact: true,
component: InfrastructureMonitoring,
key: 'INFRASTRUCTURE_MONITORING_HOSTS',
isPrivate: true,
},
{
path: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
exact: true,
component: InfrastructureMonitoring,
key: 'INFRASTRUCTURE_MONITORING_KUBERNETES',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_EXPLORER,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
isPrivate: true,
@@ -362,48 +408,56 @@ const routes: AppRoutes[] = [
{
path: ROUTES.METER,
exact: true,
component: MeterExplorerPage,
key: 'METER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER,
exact: true,
component: MeterExplorerPage,
key: 'METER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.API_MONITORING,
exact: true,
component: ApiMonitoring,
key: 'API_MONITORING',
isPrivate: true,
},
{
path: [ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT],
exact: true,
component: AIAssistantPage,
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_OVERVIEW,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_EXPLORER,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.AI_OBSERVABILITY_CONFIGURATION,
exact: true,
component: LLMObservabilityPage,
key: 'AI_OBSERVABILITY_CONFIGURATION',
isPrivate: true,
@@ -412,6 +466,7 @@ const routes: AppRoutes[] = [
export const SUPPORT_ROUTE: AppRoutes = {
path: ROUTES.SUPPORT,
exact: true,
component: SupportPage,
key: 'SUPPORT',
isPrivate: true,
@@ -419,6 +474,7 @@ export const SUPPORT_ROUTE: AppRoutes = {
export const LIST_LICENSES: AppRoutes = {
path: ROUTES.LIST_LICENSES,
exact: true,
component: LicensePage,
isPrivate: true,
key: 'LIST_LICENSES',
@@ -450,15 +506,9 @@ export const ROUTES_NOT_TO_BE_OVERRIDEN: string[] = [
];
export interface AppRoutes {
component: ComponentType;
/** An array registers the same component under each pattern. */
path: string | string[];
/**
* The route renders its own child routes, so it matches as a prefix. v6
* matches the whole path by default, so the mount appends a `/*` splat for
* these and nothing for the rest.
*/
nested?: boolean;
component: RouteProps['component'];
path: RouteProps['path'];
exact: RouteProps['exact'];
isPrivate: boolean;
key: keyof typeof ROUTES;
}

View File

@@ -27,10 +27,6 @@ export interface AppProvidersProps {
* A new provider belongs here only if it holds process-wide state that does not
* depend on the user, the license or the route. One that fetches on mount would
* fire unauthenticated from here; put it in `AppShell` or lower.
*
* The whole stack is the root route's element, so it renders inside the data
* router: `searchParams` (nuqs) reads the router's `useNavigate` /
* `useSearchParams`, and the boot spinner renders at a route too.
*/
function AppProviders({
children,

View File

@@ -1,39 +0,0 @@
import { ReactNode } from 'react';
import {
createBrowserRouter,
type DataRouter,
RouterProvider,
} from 'react-router';
import { createRouteTable } from 'AppRoutes/routeTable';
import { attachRouter } from 'lib/router/navigation';
import { getBasePath } from 'utils/basePath';
/**
* Creates the app's data router and attaches it to `lib/router/navigation`, so
* code outside a component navigates through it. `root` is the layout route:
* every provider the pages need, `nuqs/adapters/react-router/v7` included,
* renders inside it, because a data router owns everything below it.
*/
export function createAppRouter(root: ReactNode): DataRouter {
const router = createBrowserRouter(createRouteTable(root), {
basename: getBasePath(),
});
attachRouter(router);
return router;
}
interface AppRouterProviderProps {
router: DataRouter;
}
/**
* Transitions are off on purpose: under one React keeps the previous screen up
* instead of committing the Suspense fallback, so a route whose chunk is not
* cached yet renders no loader at all. Pending UI under transitions needs
* route `lazy` and `useNavigation()`.
*/
export function AppRouterProvider({
router,
}: AppRouterProviderProps): JSX.Element {
return <RouterProvider router={router} useTransitions={false} />;
}

View File

@@ -5,8 +5,11 @@ import { NotificationProvider } from 'hooks/useNotifications';
import { CmdKProvider } from 'providers/cmdKProvider';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { AppLayer } from './types';
export interface AppShellProps {
children: ReactNode;
router: AppLayer;
/** Mounted beside the routed content: the command palette and its siblings. */
overlays?: ReactNode;
}
@@ -16,26 +19,28 @@ export interface AppShellProps {
* fetches settle, above `PrivateRoute`, so it also covers the redirects and the
* not-found route, and it survives every navigation.
*
* A new provider belongs here if it needs the user and has to outlive the page:
* a global overlay, a shortcut host, anything one route opens and the next one
* keeps. The router itself is higher up, in `AppProviders`.
* A new provider belongs here if it needs the router or the user and has to
* outlive the page: a global overlay, a shortcut host, anything one route opens
* and the next one keeps.
*
* `ConfigProvider` reads `useThemeConfig`, which needs `ThemeProvider` above it,
* so the antd theme is settled here instead of by the caller.
*/
function AppShell({ children, overlays }: AppShellProps): JSX.Element {
function AppShell({ children, router, overlays }: AppShellProps): JSX.Element {
const themeConfig = useThemeConfig();
return (
<ConfigProvider theme={themeConfig}>
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{overlays}
{children}
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>
{router(
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{overlays}
{children}
</ErrorModalProvider>
</NotificationProvider>
</CmdKProvider>,
)}
</ConfigProvider>
);
}

View File

@@ -121,7 +121,6 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
</div>
<a
class="c3"
data-discover="true"
href="/home"
tabindex="0"
>

View File

@@ -47,29 +47,23 @@ export function QuerySearchV2Provider({
store.getState().setInitialExpression(initialExpression);
}, [initialExpression, store]);
// The URL owns the expression, in both directions. A provider can outlive the
// navigation away from the page it belongs to (a route-driven tab keeps the
// leaving pane mounted until its animation ends), so treating a param that
// disappeared as something to restore republishes it onto the URL of the page
// being entered.
const isInitialized = useRef(false);
useEffect(() => {
const userExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
if (userExpression !== store.getState().committedExpression) {
store.getState().initializeFromUrl(userExpression);
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, store]);
const publishedExpression = useRef(committedExpression);
useEffect(() => {
if (committedExpression === publishedExpression.current) {
return;
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
publishedExpression.current = committedExpression;
setUrlExpression(committedExpression || null);
}, [committedExpression, setUrlExpression]);
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {

View File

@@ -110,22 +110,6 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.expression).toBe('status = 500');
});
it('should follow the URL when the param is dropped', () => {
mockUrlValue = 'status = 500';
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
expect(result.current.expression).toBe('status = 500');
mockSetQueryState.mockClear();
mockUrlValue = null;
rerender();
expect(result.current.expression).toBe('');
expect(mockSetQueryState).not.toHaveBeenCalledWith('status = 500');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useExpression());

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -18,10 +18,10 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { initialQueriesMap } from 'constants/queryBuilder';
import K8sBaseDetails, {
K8sDetailsFilters,
} from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { DefaultQueryRedirect } from 'container/InfraMonitoringK8sV2/Base/DefaultQueryRedirect';
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import StatusFilter from 'container/InfraMonitoringHostsV2/StatusFilter';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
@@ -29,6 +29,9 @@ import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from 'container/InfraMonitoringK8sV2/constants';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import {
getHostMetricsQueryPayload,
@@ -58,6 +61,10 @@ import {
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const compositeQuery = useGetCompositeQueryParam();
const { redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
const { startUnixMilli, endUnixMilli } = useMemo(() => {
@@ -69,6 +76,28 @@ function Hosts(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTime, getMinMaxTime]);
useEffect(() => {
if (isInitialized.current) {
return;
}
isInitialized.current = true;
if (!compositeQuery) {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
redirectWithQueryBuilderData({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: '' },
filters: { items: [], op: 'AND' as const },
})),
},
});
}
}, [compositeQuery, redirectWithQueryBuilderData]);
const handleFilterVisibilityChange = (): void => {
setShowFilters(!showFilters);
};
@@ -197,7 +226,6 @@ function Hosts(): JSX.Element {
return (
<>
<DefaultQueryRedirect />
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (

View File

@@ -1,42 +0,0 @@
import { useEffect, useRef } from 'react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
/**
* Puts the default query in the URL when the page mounts without one.
*
* Render it before the list: React runs sibling effects in tree order, and this
* navigation drops the nuqs writes still queued, such as the page size the list
* writes on mount.
*/
export function DefaultQueryRedirect(): null {
const compositeQuery = useGetCompositeQueryParam();
const { redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
useEffect(() => {
if (isInitialized.current) {
return;
}
isInitialized.current = true;
if (!compositeQuery) {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
redirectWithQueryBuilderData({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: '' },
filters: { items: [], op: 'AND' as const },
})),
},
});
}
}, [compositeQuery, redirectWithQueryBuilderData]);
return null;
}

View File

@@ -62,6 +62,7 @@ function K8sHeader({
const currentExpression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
const finalExpression = newExpression ?? currentExpression;
void setCurrentPage(1);
const updatedQuery = {
...currentQuery,
@@ -88,8 +89,6 @@ function K8sHeader({
);
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
// After the navigation: a navigation drops the nuqs writes still queued.
void setCurrentPage(1);
void invalidateQueries();
if (finalExpression?.trim()) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
@@ -9,12 +9,14 @@ import {
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { DefaultQueryRedirect } from './Base/DefaultQueryRedirect';
import { K8sDynamicList } from './Base/K8sDynamicList';
import EntityStatusFilter from './Base/components/EntityStatusFilter/EntityStatusFilter';
import {
@@ -66,7 +68,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setNodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [, setContainerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
@@ -93,6 +97,28 @@ export default function InfraMonitoringK8s(): JSX.Element {
[getUseFieldApis, selectedCategory],
);
useEffect(() => {
if (isInitialized.current) {
return;
}
isInitialized.current = true;
if (!compositeQuery) {
const defaultQuery = initialQueriesMap[DataSource.METRICS];
redirectWithQueryBuilderData({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: defaultQuery.builder.queryData.map((query) => ({
...query,
filter: { expression: '' },
filters: { items: [], op: 'AND' as const },
})),
},
});
}
}, [compositeQuery, redirectWithQueryBuilderData]);
useEffect(() => {
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
@@ -195,7 +221,14 @@ export default function InfraMonitoringK8s(): JSX.Element {
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
// Navigate first: a navigation drops the nuqs writes still queued.
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
redirectWithQueryBuilderData({
...currentQuery,
builder: {
@@ -209,14 +242,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
],
},
});
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
}
};
@@ -245,7 +270,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<DefaultQueryRedirect />
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (

View File

@@ -1,213 +0,0 @@
import { usePreferredPageSizeStore } from 'components/TanStackTableView/usePreferredPageSize.store';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import K8sHeader from '../Base/K8sHeader';
import EntityDateTimeSelector from '../EntityDetailsUtils/EntityDateTimeSelector/EntityDateTimeSelector';
import {
InfraMonitoringEntity,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategories,
} from '../constants';
import {
useInfraMonitoringCategory,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import InfraMonitoringK8s from '../InfraMonitoringK8s';
// The real hook: a navigation made while a nuqs write is queued drops that write,
// which is what these tests guard. The extension slips past jest.config.ts's mock.
jest.mock('hooks/useSafeNavigate', () =>
jest.requireActual('../../../hooks/useSafeNavigate.ts'),
);
jest.mock('components/QuickFilters/QuickFilters', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="quick-filters" />,
}));
// Keeps the list's own URL write on mount: the page size.
jest.mock('../Base/K8sDynamicList', () => {
const { useTableParams } = jest.requireActual('components/TanStackTableView');
function K8sDynamicList(): JSX.Element {
useTableParams(
{ page: 'page', limit: 'pageSize' },
{ page: 1, limit: 10, storageKey: 'k8s-containers' },
);
return <div data-testid="k8s-dynamic-list" />;
}
return { __esModule: true, K8sDynamicList, default: K8sDynamicList };
});
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: ({ onRun }: { onRun: (expression: string) => void }): JSX.Element => (
<button
type="button"
aria-label="Run filter"
data-testid="run-filter"
onClick={(): void => onRun("k8s.pod.name = 'pod-1'")}
/>
),
}));
jest.mock('lib/navigation', () => ({
getNavigationReferrer: (): string => 'direct',
}));
const route = ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES;
function DrawerHarness(): JSX.Element {
const [, setCategory] = useInfraMonitoringCategory();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
return (
<>
<button
type="button"
aria-label="Select category"
data-testid="select-category"
onClick={(): void => {
void setCategory(K8sCategories.PODS);
}}
/>
<button
type="button"
aria-label="Open row"
data-testid="open-row"
onClick={(): void =>
setSelectedItemParams({
selectedItem: 'pod-1',
clusterName: null,
namespaceName: null,
})
}
/>
{selectedItemParams.selectedItem && (
<div data-testid="drawer">
<EntityDateTimeSelector
eventEntity="k8s"
category={InfraMonitoringEntity.PODS}
view="overview"
/>
</div>
)}
</>
);
}
function getUrlParam(key: string): string | null {
return new URLSearchParams(window.location.search).get(key);
}
describe('K8s URL writes queued by nuqs', () => {
afterEach(() => {
usePreferredPageSizeStore
.getState()
.setPreferredPageSize('k8s-containers', null);
});
it('opens the drawer on the first click after an earlier shallow write', async () => {
render(<DrawerHarness />, undefined, {
initialRoute: `${route}?relativeTime=15m`,
});
fireEvent.click(screen.getByTestId('select-category'));
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY)).toBe(
K8sCategories.PODS,
),
);
fireEvent.click(screen.getByTestId('open-row'));
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM)).toBe(
'pod-1',
),
);
expect(screen.getByTestId('drawer')).toBeInTheDocument();
});
it('switches category on the first click while a filter is applied', async () => {
const query = initialQueriesMap[DataSource.METRICS];
const filteredQuery = {
...query,
builder: {
...query.builder,
queryData: [
{
...query.builder.queryData[0],
filter: { expression: "k8s.pod.name = 'pod-1'" },
},
],
},
};
const params = new URLSearchParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY]: K8sCategories.PODS,
compositeQuery: encodeURIComponent(JSON.stringify(filteredQuery)),
});
render(<InfraMonitoringK8s />, undefined, {
initialRoute: `${route}?${params.toString()}`,
});
fireEvent.click(
await screen.findByTestId(`category-${K8sCategories.CONTAINERS}`),
);
await waitFor(() => {
const compositeQuery = JSON.parse(
decodeURIComponent(getUrlParam('compositeQuery') ?? ''),
);
expect(compositeQuery.builder.queryData[0].filter.expression).toBe('');
});
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY)).toBe(
K8sCategories.CONTAINERS,
),
);
});
it('goes back to the first page when a filter runs', async () => {
render(
<K8sHeader
entity={InfraMonitoringEntity.PODS}
showAutoRefresh={false}
cancelQuery={jest.fn()}
/>,
undefined,
{ initialRoute: `${route}?relativeTime=30m&page=3` },
);
fireEvent.click(screen.getByTestId('run-filter'));
await waitFor(() => {
const compositeQuery = JSON.parse(
decodeURIComponent(getUrlParam('compositeQuery') ?? ''),
);
expect(compositeQuery.builder.queryData[0].filter.expression).toBe(
"k8s.pod.name = 'pod-1'",
);
});
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE)).toBe('1'),
);
});
it('keeps the page size the list writes while the default query loads', async () => {
usePreferredPageSizeStore
.getState()
.setPreferredPageSize('k8s-containers', 25);
render(<InfraMonitoringK8s />, undefined, { initialRoute: route });
await waitFor(() => expect(getUrlParam('compositeQuery')).not.toBeNull());
await waitFor(() =>
expect(getUrlParam(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE)).toBe('25'),
);
});
});

View File

@@ -1,46 +1,28 @@
import type { ReactNode } from 'react';
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react';
import { getCurrentLocation, navigate } from 'lib/router/navigation';
import { TestRouter } from 'tests/router';
import { useNavigationBlocker } from '../useNavigationBlocker';
type BlockerHook = RenderHookResult<
ReturnType<typeof useNavigationBlocker>,
{ shouldBlock: boolean }
>;
function wrapper({ children }: { children: ReactNode }): JSX.Element {
return <TestRouter initialRoute="/start">{children}</TestRouter>;
}
/** Lets router updates, and the `popstate` a POP arrives on, reach React. */
async function settle(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 60));
function settle(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 60);
});
}
/** Mounts the blocker, disarmed, at /c with /start, /a and /b behind it. */
async function renderAtC(): Promise<BlockerHook> {
const hook = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ wrapper, initialProps: { shouldBlock: false } },
);
navigate('/a');
navigate('/b');
navigate('/c');
await settle();
return hook;
}
describe('useNavigationBlocker against the real history', () => {
beforeEach(async () => {
navigate('/start');
navigate('/a');
navigate('/b');
navigate('/c');
await settle();
});
it('lets a bypassed browser Back through without showing the modal', async () => {
const { result, rerender } = await renderAtC();
rerender({ shouldBlock: true });
const { result } = renderHook(() => useNavigationBlocker(true));
act(() => result.current.allowNextNavigation());
window.history.back();
act(() => window.history.back());
await settle();
expect(getCurrentLocation().pathname).toBe('/b');
@@ -48,26 +30,23 @@ describe('useNavigationBlocker against the real history', () => {
});
it('keeps blocking after a bypassed navigation', async () => {
const { result, rerender } = await renderAtC();
rerender({ shouldBlock: true });
const { result } = renderHook(() => useNavigationBlocker(true));
act(() => result.current.allowNextNavigation());
window.history.back();
await settle();
navigate('/elsewhere');
act(() => window.history.back());
await settle();
act(() => navigate('/elsewhere'));
expect(getCurrentLocation().pathname).toBe('/b');
expect(result.current.isBlocked).toBe(true);
});
it('replays a blocked Forward on confirm', async () => {
const { result, rerender } = await renderAtC();
window.history.go(-2);
act(() => window.history.go(-2));
await settle();
rerender({ shouldBlock: true });
const { result } = renderHook(() => useNavigationBlocker(true));
window.history.forward();
act(() => window.history.forward());
await settle();
expect(result.current.isBlocked).toBe(true);
@@ -80,16 +59,12 @@ describe('useNavigationBlocker against the real history', () => {
});
it('replays a blocked multi-entry jump on confirm', async () => {
const { result, rerender } = await renderAtC();
window.history.go(-2);
act(() => window.history.go(-2));
await settle();
rerender({ shouldBlock: true });
const { result } = renderHook(() => useNavigationBlocker(true));
window.history.go(2);
act(() => window.history.go(2));
await settle();
expect(result.current.isBlocked).toBe(true);
act(() => result.current.confirmNavigation());
await settle();

View File

@@ -0,0 +1,383 @@
import { act, renderHook } from '@testing-library/react';
import { useNavigationBlocker } from '../useNavigationBlocker';
const mockUnblock = jest.fn();
const mockBlock = jest.fn().mockReturnValue(mockUnblock);
const mockRetry = jest.fn();
jest.mock('lib/router/navigation', () => ({
blockNavigation: (blocker: unknown): unknown => mockBlock(blocker),
}));
describe('useNavigationBlocker', () => {
const mockLocation = {
pathname: '/new-route',
search: '',
hash: '',
state: null,
key: 'test-key',
};
/**
* `blockNavigation` hands the blocker one transition object and cancels the
* navigation on its own; `retry()` is the only way through, which is why
* these cases assert on `retry` rather than on a returned `false`.
*/
function transition(action: string): {
location: typeof mockLocation;
action: string;
retry: jest.Mock;
} {
return { location: mockLocation, action, retry: mockRetry };
}
beforeEach(() => {
jest.clearAllMocks();
mockBlock.mockReturnValue(mockUnblock);
});
describe('initial state', () => {
it('returns isBlocked false when shouldBlock is false', () => {
const { result } = renderHook(() => useNavigationBlocker(false));
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
});
it('returns isBlocked false when shouldBlock is true but no navigation attempted', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
});
});
describe('blockNavigation behavior', () => {
it('registers a blocker when shouldBlock is true', () => {
renderHook(() => useNavigationBlocker(true));
expect(mockBlock).toHaveBeenCalledTimes(1);
expect(mockBlock).toHaveBeenCalledWith(expect.any(Function));
});
it('does not register a blocker when shouldBlock is false', () => {
renderHook(() => useNavigationBlocker(false));
expect(mockBlock).not.toHaveBeenCalled();
});
it('unblocks when shouldBlock changes from true to false', () => {
const { rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ initialProps: { shouldBlock: true } },
);
expect(mockBlock).toHaveBeenCalledTimes(1);
rerender({ shouldBlock: false });
expect(mockUnblock).toHaveBeenCalledTimes(1);
});
it('unblocks on unmount when blocking', () => {
const { unmount } = renderHook(() => useNavigationBlocker(true));
unmount();
expect(mockUnblock).toHaveBeenCalledTimes(1);
});
it('does not call unblock on unmount when not blocking', () => {
const { unmount } = renderHook(() => useNavigationBlocker(false));
unmount();
expect(mockUnblock).not.toHaveBeenCalled();
});
});
describe('navigation blocking', () => {
it('blocks PUSH navigation and sets blockedNavigationDetails', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(transition('PUSH'));
});
expect(mockRetry).not.toHaveBeenCalled();
expect(result.current.isBlocked).toBe(true);
expect(result.current.blockedNavigationDetails).toStrictEqual({
location: mockLocation,
action: 'PUSH',
});
});
it('blocks REPLACE navigation and sets blockedNavigationDetails', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(transition('REPLACE'));
});
expect(result.current.isBlocked).toBe(true);
expect(result.current.blockedNavigationDetails?.action).toBe('REPLACE');
});
it('blocks POP navigation and sets blockedNavigationDetails', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(transition('POP'));
});
expect(result.current.isBlocked).toBe(true);
expect(result.current.blockedNavigationDetails?.action).toBe('POP');
});
});
describe('confirmNavigation', () => {
it('does nothing when no blockedNavigationDetails', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
act(() => {
result.current.confirmNavigation();
});
expect(mockRetry).not.toHaveBeenCalled();
});
it('unblocks before retrying, or the blocker would catch the retry too', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(transition('PUSH'));
});
act(() => {
result.current.confirmNavigation();
});
expect(mockUnblock).toHaveBeenCalled();
expect(mockUnblock.mock.invocationCallOrder[0]).toBeLessThan(
mockRetry.mock.invocationCallOrder[0],
);
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
});
it.each(['PUSH', 'REPLACE', 'POP'])(
'retries the blocked %s, which replays it with its own action',
(action) => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(transition(action));
});
act(() => {
result.current.confirmNavigation();
});
expect(mockRetry).toHaveBeenCalledTimes(1);
},
);
});
describe('cancelNavigation', () => {
it('clears blockedNavigationDetails without performing navigation', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(transition('PUSH'));
});
expect(result.current.isBlocked).toBe(true);
act(() => {
result.current.cancelNavigation();
});
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
expect(mockRetry).not.toHaveBeenCalled();
});
});
describe('allowNextNavigation', () => {
it('bypasses blocking for next navigation only', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
act(() => {
result.current.allowNextNavigation();
});
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
blockCallback(transition('PUSH'));
});
expect(mockRetry).toHaveBeenCalledTimes(1);
expect(result.current.isBlocked).toBe(false);
expect(mockBlock).toHaveBeenCalledTimes(1);
expect(mockUnblock).not.toHaveBeenCalled();
});
it('drops a pending prompt when a bypassed navigation goes through', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
const stale = jest.fn();
act(() => {
blockCallback({ ...transition('PUSH'), retry: stale });
});
expect(result.current.isBlocked).toBe(true);
act(() => {
result.current.allowNextNavigation();
});
act(() => {
blockCallback(transition('PUSH'));
});
act(() => {
result.current.confirmNavigation();
});
expect(result.current.isBlocked).toBe(false);
expect(stale).not.toHaveBeenCalled();
});
it('resumes blocking after bypassed navigation', () => {
const { result } = renderHook(() => useNavigationBlocker(true));
const blockCallback = mockBlock.mock.calls[0][0];
act(() => {
result.current.allowNextNavigation();
});
act(() => {
blockCallback(transition('PUSH'));
});
mockRetry.mockClear();
act(() => {
blockCallback(transition('PUSH'));
});
expect(mockRetry).not.toHaveBeenCalled();
expect(result.current.isBlocked).toBe(true);
});
it('resets bypass flag when shouldBlock changes to false before navigation', () => {
const { result, rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ initialProps: { shouldBlock: true } },
);
act(() => {
result.current.allowNextNavigation();
});
rerender({ shouldBlock: false });
rerender({ shouldBlock: true });
const blockCallback = mockBlock.mock.calls[1][0];
act(() => {
blockCallback(transition('PUSH'));
});
expect(mockRetry).not.toHaveBeenCalled();
expect(result.current.isBlocked).toBe(true);
});
});
describe('beforeunload event', () => {
let addEventListenerSpy: jest.SpyInstance;
let removeEventListenerSpy: jest.SpyInstance;
beforeEach(() => {
addEventListenerSpy = jest.spyOn(window, 'addEventListener');
removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
});
afterEach(() => {
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
it('adds beforeunload listener when shouldBlock is true', () => {
renderHook(() => useNavigationBlocker(true));
expect(addEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('does not add beforeunload listener when shouldBlock is false', () => {
renderHook(() => useNavigationBlocker(false));
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('removes beforeunload listener on unmount', () => {
const { unmount } = renderHook(() => useNavigationBlocker(true));
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('removes beforeunload listener when shouldBlock changes to false', () => {
const { rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ initialProps: { shouldBlock: true } },
);
rerender({ shouldBlock: false });
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('beforeunload handler calls preventDefault', () => {
renderHook(() => useNavigationBlocker(true));
const beforeUnloadHandler = addEventListenerSpy.mock.calls.find(
(call) => call[0] === 'beforeunload',
)?.[1];
const mockEvent = {
preventDefault: jest.fn(),
};
const result = beforeUnloadHandler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(result).toBeUndefined();
});
});
});

View File

@@ -1,317 +0,0 @@
import type { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { back, getCurrentLocation, navigate } from 'lib/router/navigation';
import { TestRouter } from 'tests/router';
import { useNavigationBlocker } from '../useNavigationBlocker';
function wrapper({ children }: { children: ReactNode }): JSX.Element {
return <TestRouter initialRoute="/start">{children}</TestRouter>;
}
/** Lets router updates, and the `popstate` a POP arrives on, reach React. */
async function settle(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe('useNavigationBlocker', () => {
describe('initial state', () => {
it('returns isBlocked false when shouldBlock is false', () => {
const { result } = renderHook(() => useNavigationBlocker(false), {
wrapper,
});
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
});
it('returns isBlocked false when shouldBlock is true but no navigation attempted', () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
});
});
describe('navigation blocking', () => {
it('lets navigation through when shouldBlock is false', async () => {
renderHook(() => useNavigationBlocker(false), { wrapper });
navigate('/next');
await settle();
expect(getCurrentLocation().pathname).toBe('/next');
});
it('blocks PUSH navigation and sets blockedNavigationDetails', async () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
navigate('/next');
await settle();
expect(result.current.isBlocked).toBe(true);
expect(result.current.blockedNavigationDetails).toMatchObject({
location: { pathname: '/next' },
action: 'PUSH',
});
expect(getCurrentLocation().pathname).toBe('/start');
});
it('blocks REPLACE navigation and sets blockedNavigationDetails', async () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
navigate('/next', { replace: true });
await settle();
expect(result.current.blockedNavigationDetails?.action).toBe('REPLACE');
});
it('blocks POP navigation and sets blockedNavigationDetails', async () => {
const { result, rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ wrapper, initialProps: { shouldBlock: false } },
);
navigate('/next');
await settle();
rerender({ shouldBlock: true });
await settle();
back();
await waitFor(() => expect(result.current.isBlocked).toBe(true));
expect(result.current.blockedNavigationDetails).toMatchObject({
location: { pathname: '/start' },
action: 'POP',
});
await waitFor(() => expect(getCurrentLocation().pathname).toBe('/next'));
});
it('stops blocking when shouldBlock changes from true to false', async () => {
const { rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ wrapper, initialProps: { shouldBlock: true } },
);
rerender({ shouldBlock: false });
navigate('/next');
await settle();
expect(getCurrentLocation().pathname).toBe('/next');
});
});
describe('confirmNavigation', () => {
it('does nothing when nothing is blocked', () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
act(() => result.current.confirmNavigation());
expect(getCurrentLocation().pathname).toBe('/start');
});
it('completes the blocked navigation', async () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
navigate('/next', { state: { from: 'test' } });
await settle();
act(() => result.current.confirmNavigation());
await settle();
expect(getCurrentLocation()).toMatchObject({
pathname: '/next',
state: { from: 'test' },
});
expect(result.current.isBlocked).toBe(false);
});
it('keeps a blocked REPLACE a replace', async () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
const before = window.history.length;
navigate('/next', { replace: true });
await settle();
act(() => result.current.confirmNavigation());
await settle();
expect(getCurrentLocation().pathname).toBe('/next');
expect(window.history).toHaveLength(before);
});
});
describe('cancelNavigation', () => {
it('clears blockedNavigationDetails without performing navigation', async () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
navigate('/next');
await settle();
act(() => result.current.cancelNavigation());
await settle();
expect(result.current.isBlocked).toBe(false);
expect(result.current.blockedNavigationDetails).toBeNull();
expect(getCurrentLocation().pathname).toBe('/start');
});
it('keeps blocking after a cancel', async () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
navigate('/next');
await settle();
act(() => result.current.cancelNavigation());
await settle();
navigate('/next');
await settle();
expect(result.current.isBlocked).toBe(true);
});
});
describe('allowNextNavigation', () => {
it('bypasses blocking for next navigation only', async () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
act(() => result.current.allowNextNavigation());
navigate('/next');
await settle();
expect(getCurrentLocation().pathname).toBe('/next');
expect(result.current.isBlocked).toBe(false);
navigate('/after');
await settle();
expect(result.current.isBlocked).toBe(true);
expect(getCurrentLocation().pathname).toBe('/next');
});
it('drops a pending prompt when a bypassed navigation goes through', async () => {
const { result } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
navigate('/next');
await settle();
expect(result.current.isBlocked).toBe(true);
act(() => result.current.allowNextNavigation());
navigate('/other');
await settle();
expect(getCurrentLocation().pathname).toBe('/other');
expect(result.current.isBlocked).toBe(false);
});
it('resets bypass flag when shouldBlock changes to false before navigation', async () => {
const { result, rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ wrapper, initialProps: { shouldBlock: true } },
);
act(() => result.current.allowNextNavigation());
rerender({ shouldBlock: false });
rerender({ shouldBlock: true });
navigate('/next');
await settle();
expect(result.current.isBlocked).toBe(true);
});
});
describe('beforeunload event', () => {
let addEventListenerSpy: jest.SpyInstance;
let removeEventListenerSpy: jest.SpyInstance;
beforeEach(() => {
addEventListenerSpy = jest.spyOn(window, 'addEventListener');
removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
});
afterEach(() => {
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
it('adds beforeunload listener when shouldBlock is true', () => {
renderHook(() => useNavigationBlocker(true), { wrapper });
expect(addEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('does not add beforeunload listener when shouldBlock is false', () => {
renderHook(() => useNavigationBlocker(false), { wrapper });
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('removes beforeunload listener on unmount', () => {
const { unmount } = renderHook(() => useNavigationBlocker(true), {
wrapper,
});
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('removes beforeunload listener when shouldBlock changes to false', () => {
const { rerender } = renderHook(
({ shouldBlock }) => useNavigationBlocker(shouldBlock),
{ wrapper, initialProps: { shouldBlock: true } },
);
rerender({ shouldBlock: false });
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
);
});
it('beforeunload handler calls preventDefault', () => {
renderHook(() => useNavigationBlocker(true), { wrapper });
const beforeUnloadHandler = addEventListenerSpy.mock.calls.find(
(call) => call[0] === 'beforeunload',
)?.[1];
const mockEvent = {
preventDefault: jest.fn(),
};
const result = beforeUnloadHandler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(result).toBeUndefined();
});
});
});

View File

@@ -1,64 +0,0 @@
import { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { TestRouter } from 'tests/router';
// jest.config.ts maps every `useSafeNavigate` import to a mock; the extension
// slips past that mapping.
const { useSafeNavigate } = jest.requireActual<
typeof import('hooks/useSafeNavigate')
>('../useSafeNavigate.ts');
function wrapper({ children }: { children: ReactNode }): JSX.Element {
return <TestRouter initialRoute="/page?a=1">{children}</TestRouter>;
}
describe('useSafeNavigate same-URL check', () => {
let pushState: jest.SpyInstance;
let replaceState: jest.SpyInstance;
beforeEach(() => {
pushState = jest.spyOn(window.history, 'pushState');
replaceState = jest.spyOn(window.history, 'replaceState');
});
afterEach(() => {
jest.restoreAllMocks();
});
function render(): ReturnType<typeof useSafeNavigate>['safeNavigate'] {
const { result } = renderHook(() => useSafeNavigate(), { wrapper });
pushState.mockClear();
replaceState.mockClear();
return result.current.safeNavigate;
}
it('navigates to a different URL', () => {
const safeNavigate = render();
safeNavigate('/page?a=2');
expect(pushState).toHaveBeenCalledTimes(1);
expect(window.location.search).toBe('?a=2');
});
it('skips the URL the router is on', () => {
const safeNavigate = render();
safeNavigate('/page?a=1');
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
});
it('skips the URL a shallow write published ahead of the router', () => {
const safeNavigate = render();
// A nuqs write: it reaches the address bar but not the router.
window.history.replaceState(window.history.state, '', '/page?a=1&b=1');
replaceState.mockClear();
safeNavigate('/page?a=1&b=1');
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetCompositeQueryParam } from './useGetCompositeQueryParam';
@@ -18,19 +17,10 @@ export const useShareBuilderUrl = ({
}: UseShareBuilderUrlParams): void => {
const { resetQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const urlQuery = useUrlQuery();
const { pathname } = useAppLocation();
// The page that owns this query. A caller can keep rendering for a beat after
// a sibling pushes the next route (tab switches do this), and publishing then
// writes this page's query onto the URL of the page being entered.
const ownerPathname = useRef(pathname);
const compositeQuery = useGetCompositeQueryParam();
useEffect(() => {
if (pathname !== ownerPathname.current) {
return;
}
if (!compositeQuery || forceReset) {
resetQuery(defaultValue);
redirectWithQueryBuilderData(defaultValue);
@@ -42,6 +32,5 @@ export const useShareBuilderUrl = ({
compositeQuery,
resetQuery,
forceReset,
pathname,
]);
};

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { blockNavigation } from 'lib/router/navigation';
import type {
AppLocation,
BlockerFunction,
BlockedTransition,
NavigationAction,
} from 'lib/router/types';
import { useAppBlocker } from 'lib/router/useAppBlocker';
interface BlockedNavigationDetails {
location: AppLocation;
@@ -48,31 +48,45 @@ interface UseNavigationBlockerResult {
export function useNavigationBlocker(
shouldBlock: boolean,
): UseNavigationBlockerResult {
const [blockedNavigation, setBlockedNavigation] =
useState<BlockedNavigationDetails | null>(null);
const unblockRef = useRef<(() => void) | null>(null);
const retryRef = useRef<(() => void) | null>(null);
const bypassNextRef = useRef(false);
const blockedActionRef = useRef<NavigationAction>('PUSH');
useEffect(() => {
if (!shouldBlock) {
bypassNextRef.current = false;
}
}, [shouldBlock]);
const blockerFunction = useCallback<BlockerFunction>(
({ action }) => {
if (!shouldBlock) {
return false;
if (unblockRef.current) {
unblockRef.current();
unblockRef.current = null;
}
bypassNextRef.current = false;
return;
}
unblockRef.current = blockNavigation((transition: BlockedTransition) => {
if (bypassNextRef.current) {
bypassNextRef.current = false;
return false;
retryRef.current = null;
setBlockedNavigation(null);
transition.retry();
return;
}
blockedActionRef.current = action;
return true;
},
[shouldBlock],
);
retryRef.current = transition.retry;
setBlockedNavigation({
location: transition.location,
action: transition.action,
});
});
const blocker = useAppBlocker(blockerFunction);
return (): void => {
if (unblockRef.current) {
unblockRef.current();
unblockRef.current = null;
}
bypassNextRef.current = false;
};
}, [shouldBlock]);
useEffect(() => {
if (!shouldBlock) {
@@ -90,30 +104,37 @@ export function useNavigationBlocker(
};
}, [shouldBlock]);
// The router replays a proceeded navigation itself, so a blocked REPLACE
// stays a REPLACE and a blocked POP still walks the history stack.
const confirmNavigation = useCallback((): void => {
if (blocker.state === 'blocked') {
blocker.proceed();
if (!blockedNavigation) {
return;
}
}, [blocker]);
if (unblockRef.current) {
unblockRef.current();
unblockRef.current = null;
}
setBlockedNavigation(null);
// The transition replays itself, so a blocked REPLACE stays a REPLACE and
// a blocked POP still walks the history stack.
const retry = retryRef.current;
retryRef.current = null;
retry?.();
}, [blockedNavigation]);
const cancelNavigation = useCallback((): void => {
if (blocker.state === 'blocked') {
blocker.reset();
}
}, [blocker]);
retryRef.current = null;
setBlockedNavigation(null);
}, []);
const allowNextNavigation = useCallback((): void => {
bypassNextRef.current = true;
}, []);
return {
isBlocked: blocker.state === 'blocked',
blockedNavigationDetails:
blocker.state === 'blocked'
? { location: blocker.location, action: blockedActionRef.current }
: null,
isBlocked: blockedNavigation !== null,
blockedNavigationDetails: blockedNavigation,
confirmNavigation,
cancelNavigation,
allowNextNavigation,

View File

@@ -553,9 +553,8 @@ describe('ResourceProvider', () => {
];
const encoded = encode(JSON.stringify(seeded));
// The effect that re-hydrates reads the URL through lib/router/navigation,
// which drives the same router the tree renders in, so one navigation
// drives both.
// The effect that re-hydrates reads the URL through lib/history, the same
// history the router runs on, so one navigation drives both.
act(() => {
navigate(`/?resourceAttribute=${encoded}`);
});

View File

@@ -1,12 +1,11 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
interface NavigateOptions {
replace?: boolean;
state?: unknown;
state?: any;
newTab?: boolean;
}
@@ -109,12 +108,6 @@ export const useSafeNavigate = (
// oxlint-disable-next-line signoz/no-raw-absolute-path
const base = window.location.origin;
const currentUrl = new URL(`${location.pathname}${location.search}`, base);
// nuqs writes shallowly, so the address bar can be ahead of the router.
// Navigating to it anyway makes nuqs drop its queued writes.
const publishedUrl = new URL(
`${location.pathname}?${getUnstableCurrentSearchParams().toString()}`,
base,
);
let targetUrl: URL;
@@ -138,9 +131,7 @@ export const useSafeNavigate = (
return;
}
const urlsAreSame =
areUrlsEffectivelySame(currentUrl, targetUrl) ||
areUrlsEffectivelySame(publishedUrl, targetUrl);
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);
if (preventSameUrlNavigation && urlsAreSame) {
@@ -153,9 +144,9 @@ export const useSafeNavigate = (
};
if (typeof to === 'string') {
void navigate(to, navigationOptions);
navigate(to, navigationOptions);
} else {
void navigate(
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,

View File

@@ -2,11 +2,10 @@ import { ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient } from 'react-query';
import AppProviders from 'app/AppProviders';
import { AppRouterProvider, createAppRouter } from 'app/AppRouter';
import AppRoutes from 'AppRoutes';
import { AxiosError } from 'axios';
import { configureOverlayScrollbars } from 'lib/configureOverlayScrollbars';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppProvider } from 'providers/App/App';
import store from 'store';
import APIError from 'types/api/error';
@@ -56,7 +55,8 @@ const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
const router = createAppRouter(
root.render(
<AppProviders
store={store}
queryClient={queryClient}
@@ -66,6 +66,4 @@ if (container) {
<AppRoutes />
</AppProviders>,
);
root.render(<AppRouterProvider router={router} />);
}

View File

@@ -0,0 +1,15 @@
import { createBrowserHistory } from 'history';
import { getBasePath } from 'utils/basePath';
const history = createBrowserHistory({ basename: getBasePath() });
let inAppPushCount = 0;
history.listen((_, action) => {
if (action === 'PUSH') {
inAppPushCount += 1;
}
});
export const hasInAppHistory = (): boolean => inAppPushCount > 0;
export default history;

View File

@@ -1,8 +1,10 @@
import { forwardRef } from 'react';
import type { AnchorHTMLAttributes, ReactNode } from 'react';
import { Link } from 'react-router';
import { Link } from 'react-router-dom';
import type { To } from './types';
import { useAppLocation } from './useAppLocation';
import { withState } from './utils';
export interface AppLinkProps extends Omit<
AnchorHTMLAttributes<HTMLAnchorElement>,
@@ -16,8 +18,15 @@ export interface AppLinkProps extends Omit<
export const AppLink = forwardRef<HTMLAnchorElement, AppLinkProps>(
function AppLink({ to, replace, state, children, ...rest }, ref) {
const location = useAppLocation();
return (
<Link ref={ref} to={to} replace={replace} state={state} {...rest}>
<Link
ref={ref}
to={withState(to, state, location)}
replace={replace}
{...rest}
>
{children}
</Link>
);

View File

@@ -1,51 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { createContext, type ReactNode, useContext } from 'react';
import {
type DataRouter,
type RouteObject,
RouterProvider,
} from 'react-router';
import { RethrowRouteError } from './RethrowRouteError';
const HostedTree = createContext<ReactNode>(null);
function HostedTreeOutlet(): JSX.Element {
return <>{useContext(HostedTree)}</>;
}
/**
* The route table for the test and story mounts: the tree `HostRouter` was last
* rendered with, at any URL, or only at `patterns` so it can read their params
* the way a page does. Render errors propagate out of the router.
*/
export function createHostRoutes(patterns?: string[]): RouteObject[] {
const children: RouteObject[] = patterns
? [
...patterns.map((path) => ({ path, element: <HostedTreeOutlet /> })),
{ path: '*', element: null },
]
: [{ path: '*', element: <HostedTreeOutlet /> }];
return [{ errorElement: <RethrowRouteError />, children }];
}
export const hostRoutes: RouteObject[] = createHostRoutes();
interface HostRouterProps {
router: DataRouter;
children: ReactNode;
}
/**
* A data router's routes are fixed when it is created, so the tree reaches the
* route element through context instead, and a re-render with new children
* shows them.
*/
export function HostRouter({ router, children }: HostRouterProps): JSX.Element {
return (
<HostedTree.Provider value={children}>
<RouterProvider router={router} useTransitions={false} />
</HostedTree.Provider>
);
}

View File

@@ -1,11 +1,12 @@
import { Navigate } from 'react-router';
import { Redirect as RouterRedirect } from 'react-router-dom';
import type { To } from './types';
import { withState } from './utils';
export interface RedirectProps {
to: To;
/**
* Defaults to `true`, matching v5's `<Redirect>`. v7's `<Navigate>` pushes
* Defaults to `true`, matching v5's `<Redirect>`. v6's `<Navigate>` pushes
* unless told otherwise, which is invisible until someone presses Back.
*/
replace?: boolean;
@@ -17,7 +18,7 @@ export function Redirect({
replace = true,
state,
}: RedirectProps): JSX.Element {
return <Navigate to={to} replace={replace} state={state} />;
return <RouterRedirect to={withState(to, state)} push={!replace} />;
}
Redirect.defaultProps = {

View File

@@ -1,10 +0,0 @@
import { useRouteError } from 'react-router';
/**
* Rethrows past the router, so a render error reaches the next error boundary
* outside it (or fails a test) instead of rendering the router's default error
* page.
*/
export function RethrowRouteError(): never {
throw useRouteError();
}

View File

@@ -1,14 +1,14 @@
import { createRef } from 'react';
import type { ReactNode } from 'react';
import { useHistory } from 'react-router-dom';
import { cleanup, fireEvent, render, screen } from 'tests/test-utils';
import { AppLink, type AppLinkProps } from '../AppLink';
import { useAppLocation } from '../useAppLocation';
import { useAppNavigationType } from '../useAppNavigationType';
function LocationProbe(): JSX.Element {
const location = useAppLocation();
const navigationType = useAppNavigationType();
const history = useHistory();
return (
<div>
@@ -16,7 +16,7 @@ function LocationProbe(): JSX.Element {
<span data-testid="search">{location.search}</span>
<span data-testid="hash">{location.hash}</span>
<span data-testid="state">{JSON.stringify(location.state ?? null)}</span>
<span data-testid="action">{navigationType}</span>
<span data-testid="length">{history.length}</span>
</div>
);
}
@@ -146,23 +146,17 @@ describe('AppLink', () => {
expect(screen.getByTestId('state')).toHaveTextContent('{"from":"test"}');
});
it('pushes by default', () => {
renderLink({ to: '/logs', 'data-testid': 'link' } as AppLinkProps);
fireEvent.click(screen.getByTestId('link'));
expect(screen.getByTestId('action')).toHaveTextContent('PUSH');
});
it('replaces the entry when replace is set', () => {
renderLink({
to: '/logs',
replace: true,
'data-testid': 'link',
} as AppLinkProps);
const before = screen.getByTestId('length').textContent;
fireEvent.click(screen.getByTestId('link'));
expect(screen.getByTestId('pathname')).toHaveTextContent('/logs');
expect(screen.getByTestId('action')).toHaveTextContent('REPLACE');
expect(screen.getByTestId('length').textContent).toBe(before);
});
it('forwards a ref to the anchor', () => {

View File

@@ -1,19 +1,19 @@
import { useHistory } from 'react-router-dom';
import { render, screen } from 'tests/test-utils';
import { Redirect, type RedirectProps } from '../Redirect';
import { useAppLocation } from '../useAppLocation';
import { useAppNavigationType } from '../useAppNavigationType';
function LocationProbe(): JSX.Element {
const location = useAppLocation();
const navigationType = useAppNavigationType();
const history = useHistory();
return (
<div>
<span data-testid="pathname">{location.pathname}</span>
<span data-testid="search">{location.search}</span>
<span data-testid="state">{JSON.stringify(location.state ?? null)}</span>
<span data-testid="action">{navigationType}</span>
<span data-testid="action">{history.action}</span>
</div>
);
}

View File

@@ -3,9 +3,8 @@ import ROUTES from 'constants/routes';
import { buildRoutePath } from '../buildRoutePath';
/**
* The encoding contract. v7's `generatePath` percent-encodes every param with
* `encodeURIComponent`, so `buildRoutePath` no longer encodes anything itself
* and these assertions are what pins the library's behaviour.
* These assertions pin every URL `buildRoutePath` produces, so a router
* upgrade cannot change one unnoticed.
*/
describe('buildRoutePath', () => {
it('fills a single param', () => {
@@ -46,15 +45,9 @@ describe('buildRoutePath', () => {
expect(buildRoutePath(ROUTES.TRACE_DETAIL, { id: value })).toBe(expected);
});
it('percent-encodes a colon', () => {
it('leaves a colon alone', () => {
expect(buildRoutePath(ROUTES.TRACE_DETAIL, { id: 'a:b' })).toBe(
'/trace/a%3Ab',
);
});
it('encodes a raw value rather than passing an encoded one through', () => {
expect(buildRoutePath(ROUTES.TRACE_DETAIL, { id: 'a%2Fb' })).toBe(
'/trace/a%252Fb',
'/trace/a:b',
);
});
});

View File

@@ -1,11 +1,10 @@
import type { BlockedTransition } from '../types';
/**
* `utils/basePath` reads the base path at import time, so every case loads the
* module, and the test router it drives, in isolation with a fresh DOM.
* `lib/history` is a module-level singleton that reads the base path at import
* time, so every case loads it in isolation with a fresh DOM.
*/
type NavigationModule = typeof import('../navigation');
type TestRouterModule = typeof import('tests/router-utils');
let release: (() => void) | undefined;
function loadNavigation(baseHref?: string): NavigationModule {
if (baseHref !== undefined) {
@@ -15,25 +14,16 @@ function loadNavigation(baseHref?: string): NavigationModule {
}
let mod!: NavigationModule;
let testRouter!: TestRouterModule;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../navigation');
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
testRouter = require('tests/router-utils');
});
({ release } = testRouter.attachTestRouter());
return mod;
}
function nextUpdate(
nav: NavigationModule,
): Promise<{ location: { pathname: string }; action: string }> {
function settle(): Promise<void> {
return new Promise((resolve) => {
const unsubscribe = nav.subscribe((update) => {
unsubscribe();
resolve(update);
});
setTimeout(resolve, 60);
});
}
@@ -42,8 +32,6 @@ beforeEach(() => {
});
afterEach(() => {
release?.();
release = undefined;
for (const element of document.head.querySelectorAll('base')) {
element.remove();
}
@@ -56,13 +44,6 @@ describe('navigate', () => {
expect(nav.getCurrentLocation().pathname).toBe('/logs');
});
it('throws when no router is attached', () => {
const nav = loadNavigation();
release?.();
release = undefined;
expect(() => nav.navigate('/logs')).toThrow('no router is attached');
});
it('carries search and hash from a string target', () => {
const nav = loadNavigation();
nav.navigate('/logs?a=1#top');
@@ -131,79 +112,23 @@ describe('basename ownership', () => {
expect(nav.getCurrentLocation().pathname).toBe('/logs');
});
it('round-trips getCurrentLocation through navigate', () => {
it('does not double-prefix a path that already carries the base path', () => {
const nav = loadNavigation('/signoz/');
nav.navigate('/logs');
nav.navigate(nav.getCurrentLocation().pathname);
expect(window.location.pathname).toBe('/signoz/logs');
});
it('prefixes a path whose first segment equals the base path', () => {
const nav = loadNavigation('/logs/');
nav.navigate('/logs/logs-explorer');
expect(window.location.pathname).toBe('/logs/logs/logs-explorer');
expect(nav.getCurrentLocation().pathname).toBe('/logs/logs-explorer');
});
it('prefixes the pathname of an object target', () => {
const nav = loadNavigation('/signoz/');
nav.navigate({ pathname: '/logs', search: '?a=1' });
expect(window.location.pathname).toBe('/signoz/logs');
expect(window.location.search).toBe('?a=1');
});
});
describe('subscribe', () => {
it('reports a single update object, not two arguments', async () => {
const nav = loadNavigation();
const listener = jest.fn();
const unsubscribe = nav.subscribe(listener);
nav.navigate('/logs');
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0]).toHaveLength(1);
expect(listener.mock.calls[0][0]).toMatchObject({
location: { pathname: '/logs' },
action: 'PUSH',
});
unsubscribe();
});
it('reports REPLACE', () => {
const nav = loadNavigation();
const listener = jest.fn();
const unsubscribe = nav.subscribe(listener);
nav.navigate('/logs', { replace: true });
expect(listener.mock.calls[0][0].action).toBe('REPLACE');
unsubscribe();
});
it('stops reporting after unsubscribe', () => {
const nav = loadNavigation();
const listener = jest.fn();
nav.subscribe(listener)();
nav.navigate('/logs');
expect(listener).not.toHaveBeenCalled();
});
});
describe('back', () => {
it('reports POP and restores the previous location', async () => {
it('restores the previous location', async () => {
const nav = loadNavigation();
nav.navigate('/logs');
nav.navigate('/traces');
const popped = nextUpdate(nav);
nav.back();
const update = await popped;
await settle();
expect(update.action).toBe('POP');
expect(update.location.pathname).toBe('/logs');
expect(nav.getCurrentLocation().pathname).toBe('/logs');
});
});
@@ -226,3 +151,261 @@ describe('hasInAppHistory', () => {
expect(nav.hasInAppHistory()).toBe(true);
});
});
describe('blockNavigation', () => {
it('hands the blocker a transition and cancels the navigation', () => {
const nav = loadNavigation();
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
nav.navigate('/logs');
expect(seen).toHaveLength(1);
expect(seen[0].action).toBe('PUSH');
expect(seen[0].location.pathname).toBe('/logs');
expect(typeof seen[0].retry).toBe('function');
expect(nav.getCurrentLocation().pathname).toBe('/');
unblock();
});
it('completes the navigation when retry runs after unblocking', () => {
const nav = loadNavigation();
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
nav.navigate('/logs');
unblock();
seen[0].retry();
expect(nav.getCurrentLocation().pathname).toBe('/logs');
});
it('blocks again when retry runs while still blocked, as history@5 does', () => {
const nav = loadNavigation();
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
nav.navigate('/logs');
seen[0].retry();
expect(seen).toHaveLength(2);
expect(nav.getCurrentLocation().pathname).toBe('/');
unblock();
});
it('lets a POP through when retry runs inside the blocker', async () => {
const nav = loadNavigation();
nav.navigate('/logs');
nav.navigate('/traces');
const seen: BlockedTransition[] = [];
let unblock = (): void => {};
unblock = nav.blockNavigation((tx) => {
seen.push(tx);
unblock();
tx.retry();
});
nav.back();
await settle();
expect(seen).toHaveLength(1);
expect(seen[0].action).toBe('POP');
expect(nav.getCurrentLocation().pathname).toBe('/logs');
});
it('replays a blocked Forward by one entry', async () => {
const nav = loadNavigation();
nav.navigate('/logs');
nav.navigate('/traces');
nav.back();
await settle();
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
window.history.forward();
await settle();
unblock();
seen[0].retry();
await settle();
expect(seen[0].action).toBe('POP');
expect(nav.getCurrentLocation().pathname).toBe('/traces');
});
it('replays a blocked multi-entry jump by its full distance', async () => {
const nav = loadNavigation();
nav.navigate('/a');
nav.navigate('/b');
nav.navigate('/c');
window.history.go(-2);
await settle();
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
window.history.go(2);
await settle();
unblock();
seen[0].retry();
await settle();
expect(nav.getCurrentLocation().pathname).toBe('/c');
});
it('replays a blocked Back to an entry from before a reload', async () => {
const earlier = loadNavigation();
earlier.navigate('/a');
earlier.navigate('/b');
earlier.navigate('/c');
const nav = loadNavigation();
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
window.history.back();
await settle();
unblock();
seen[0].retry();
await settle();
expect(seen[0].action).toBe('POP');
expect(nav.getCurrentLocation().pathname).toBe('/b');
expect(window.location.pathname).toBe('/b');
});
it('replays a blocked Forward after leaving through an entry from before a reload', async () => {
const earlier = loadNavigation();
earlier.navigate('/a');
earlier.navigate('/b');
const nav = loadNavigation();
nav.navigate('/c');
window.history.go(-2);
await settle();
expect(nav.getCurrentLocation().pathname).toBe('/a');
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
window.history.forward();
await settle();
unblock();
seen[0].retry();
await settle();
expect(seen[0].action).toBe('POP');
expect(nav.getCurrentLocation().pathname).toBe('/b');
expect(window.location.pathname).toBe('/b');
});
it('replays a blocked Forward that skips from an entry from before a reload past the first known one', async () => {
const earlier = loadNavigation();
earlier.navigate('/a');
earlier.navigate('/b');
const nav = loadNavigation();
nav.navigate('/c');
window.history.go(-2);
await settle();
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
window.history.go(2);
await settle();
unblock();
seen[0].retry();
await settle();
expect(nav.getCurrentLocation().pathname).toBe('/c');
expect(window.location.pathname).toBe('/c');
window.history.back();
await settle();
expect(window.location.pathname).toBe('/b');
});
it('replays a blocked Back that skips from a known entry to one from before a reload', async () => {
const earlier = loadNavigation();
earlier.navigate('/a');
earlier.navigate('/b');
const nav = loadNavigation();
nav.navigate('/c');
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
window.history.go(-2);
await settle();
unblock();
seen[0].retry();
await settle();
expect(nav.getCurrentLocation().pathname).toBe('/a');
expect(window.location.pathname).toBe('/a');
window.history.forward();
await settle();
expect(window.location.pathname).toBe('/b');
});
it('catches the router up when history@4 does not revert a blocked POP', async () => {
const nav = loadNavigation();
nav.navigate('/p');
// nuqs pushes like this, so both entries carry the same history@4 key.
window.history.pushState(window.history.state, '', '/p?x=1');
window.history.back();
await settle();
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
window.history.forward();
await settle();
unblock();
seen[0].retry();
expect(nav.getCurrentLocation().search).toBe('?x=1');
expect(window.location.search).toBe('?x=1');
});
it('keeps the blocker armed when retry runs inside it', () => {
const nav = loadNavigation();
let allowNext = true;
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => {
seen.push(tx);
if (allowNext) {
allowNext = false;
tx.retry();
}
});
nav.navigate('/logs');
nav.navigate('/traces');
expect(nav.getCurrentLocation().pathname).toBe('/logs');
expect(seen).toHaveLength(2);
unblock();
});
it('preserves the replace action through retry', () => {
const nav = loadNavigation();
nav.navigate('/logs');
const before = window.history.length;
const seen: BlockedTransition[] = [];
const unblock = nav.blockNavigation((tx) => seen.push(tx));
nav.navigate('/traces', { replace: true });
unblock();
seen[0].retry();
expect(seen[0].action).toBe('REPLACE');
expect(nav.getCurrentLocation().pathname).toBe('/traces');
expect(window.history).toHaveLength(before);
});
it('stops blocking after unblock', () => {
const nav = loadNavigation();
const listener = jest.fn();
nav.blockNavigation(listener)();
nav.navigate('/logs');
expect(listener).not.toHaveBeenCalled();
expect(nav.getCurrentLocation().pathname).toBe('/logs');
});
});

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { TestRouter } from 'tests/router';
import { act, renderHook } from 'tests/test-utils';
import { renderHook } from 'tests/test-utils';
import { navigate } from '../navigation';
import { useAppLocation } from '../useAppLocation';
@@ -25,10 +25,10 @@ describe('useAppLocation', () => {
});
it('returns the entry state', () => {
navigate('/logs', { replace: true, state: { from: 'test' } });
const { result } = renderHook(() => useAppLocation<{ from: string }>(), {
wrapper: TestRouter,
});
act(() => navigate('/logs', { replace: true, state: { from: 'test' } }));
expect(result.current.state.from).toBe('test');
});

View File

@@ -1,9 +1,9 @@
import { useHistory } from 'react-router-dom';
import { fireEvent, render, screen } from 'tests/test-utils';
import type { NavigateOptions, To } from '../types';
import { useAppLocation } from '../useAppLocation';
import { useAppNavigate } from '../useAppNavigate';
import { useAppNavigationType } from '../useAppNavigationType';
function Probe({
to,
@@ -14,7 +14,7 @@ function Probe({
}): JSX.Element {
const navigate = useAppNavigate();
const location = useAppLocation();
const navigationType = useAppNavigationType();
const history = useHistory();
return (
<div>
@@ -27,7 +27,7 @@ function Probe({
<span data-testid="pathname">{location.pathname}</span>
<span data-testid="search">{location.search}</span>
<span data-testid="state">{JSON.stringify(location.state ?? null)}</span>
<span data-testid="action">{navigationType}</span>
<span data-testid="length">{history.length}</span>
</div>
);
}
@@ -62,16 +62,20 @@ describe('useAppNavigate', () => {
it('adds a history entry by default', () => {
renderProbe('/logs');
const before = screen.getByTestId('length').textContent;
fireEvent.click(screen.getByTestId('navigate'));
expect(screen.getByTestId('action')).toHaveTextContent('PUSH');
expect(Number(screen.getByTestId('length').textContent)).toBe(
Number(before) + 1,
);
});
it('does not add a history entry when replacing', () => {
renderProbe('/logs', { replace: true });
const before = screen.getByTestId('length').textContent;
fireEvent.click(screen.getByTestId('navigate'));
expect(screen.getByTestId('pathname')).toHaveTextContent('/logs');
expect(screen.getByTestId('action')).toHaveTextContent('REPLACE');
expect(screen.getByTestId('length').textContent).toBe(before);
});
});

View File

@@ -0,0 +1,146 @@
import type { History } from 'history';
import { applyNavigate, retryTransition, withState } from '../utils';
function fakeHistory(): {
history: History;
push: jest.Mock;
replace: jest.Mock;
go: jest.Mock;
} {
const push = jest.fn();
const replace = jest.fn();
const go = jest.fn();
return {
history: { push, replace, go } as unknown as History,
push,
replace,
go,
};
}
describe('applyNavigate', () => {
it('pushes a string target with no second argument', () => {
const { history, push } = fakeHistory();
applyNavigate(history, '/logs');
expect(push).toHaveBeenCalledWith('/logs');
});
it('pushes an object target with no state key', () => {
const { history, push } = fakeHistory();
applyNavigate(history, { pathname: '/logs', search: '?a=1' });
expect(push).toHaveBeenCalledWith({ pathname: '/logs', search: '?a=1' });
});
it('forwards state when there is state to forward', () => {
const { history, push } = fakeHistory();
applyNavigate(history, '/logs', { state: { from: '/home' } });
expect(push).toHaveBeenCalledWith('/logs', { from: '/home' });
});
it('attaches state to an object target', () => {
const { history, push } = fakeHistory();
applyNavigate(history, { pathname: '/logs' }, { state: { from: '/home' } });
expect(push).toHaveBeenCalledWith({ pathname: '/logs' }, { from: '/home' });
});
it('replaces instead of pushing', () => {
const { history, push, replace } = fakeHistory();
applyNavigate(history, '/logs', { replace: true });
expect(replace).toHaveBeenCalledWith('/logs');
expect(push).not.toHaveBeenCalled();
});
it('replaces with state', () => {
const { history, replace } = fakeHistory();
applyNavigate(history, '/logs', { replace: true, state: { a: 1 } });
expect(replace).toHaveBeenCalledWith('/logs', { a: 1 });
});
});
describe('retryTransition', () => {
const location = { pathname: '/logs', search: '', hash: '', state: undefined };
it('walks a blocked POP by its distance', () => {
const { history, go } = fakeHistory();
retryTransition(history, location, 'POP', 2);
expect(go).toHaveBeenCalledWith(2);
});
it('does not reload when a blocked POP has no distance', () => {
const { history, go } = fakeHistory();
retryTransition(history, location, 'POP', 0);
expect(go).not.toHaveBeenCalled();
});
it('settles on the carried location when a blocked POP has an unknown distance', () => {
const { history, go, replace } = fakeHistory();
retryTransition(history, location, 'POP', null);
expect(go).not.toHaveBeenCalled();
expect(replace).toHaveBeenCalledWith(location);
});
it('replays PUSH and REPLACE from the carried location', () => {
const { history, push, replace } = fakeHistory();
retryTransition(history, location, 'PUSH', 0);
retryTransition(history, location, 'REPLACE', 0);
expect(push).toHaveBeenCalledWith(location);
expect(replace).toHaveBeenCalledWith(location);
});
});
describe('withState', () => {
it('returns the target untouched when there is no state', () => {
expect(withState('/logs?a=1', undefined)).toBe('/logs?a=1');
});
it('splits a string target before attaching state', () => {
expect(withState('/logs?a=1#top', { from: '/home' })).toStrictEqual({
pathname: '/logs',
search: '?a=1',
hash: '#top',
state: { from: '/home' },
});
});
it('resolves a search-only string against the current location', () => {
expect(
withState(
'?tab=2',
{ from: '/home' },
{ pathname: '/x/y', search: '?q=1', hash: '', state: null },
),
).toMatchObject({
pathname: '/x/y',
search: '?tab=2',
state: { from: '/home' },
});
});
it('attaches state to an object target', () => {
expect(withState({ pathname: '/logs' }, { from: '/home' })).toStrictEqual({
pathname: '/logs',
state: { from: '/home' },
});
});
});

View File

@@ -1,31 +1,16 @@
import { generatePath } from 'react-router';
import { generatePath } from 'react-router-dom';
/**
* Fills `:param` segments in a route pattern.
*
* Param values are passed raw and come back percent-encoded. `generatePath`
* runs each one through `encodeURIComponent`, so a value can never introduce
* extra path segments or a query string. A value that is already encoded gets
* encoded twice: read params back with `matchRoute` / `useAppParams`, which
* un-escape only `%2F`, and decode before passing one here.
*
* v7's `generatePath` types params as `string | null`, so non-strings are
* stringified here. `undefined` values are dropped, which leaves an optional
* segment out and makes a required one throw.
* Param values are URI-encoded. `/`, `?` and `#` come back percent-encoded, so
* a value can never introduce extra path segments or a query string. v5's
* `generatePath` does this via path-to-regexp; v7's runs `encodeURIComponent`,
* which also encodes `:`.
*/
export function buildRoutePath(
pattern: string,
params?: Record<string, string | number | boolean | undefined>,
): string {
if (params === undefined) {
return generatePath(pattern);
}
const stringified = Object.fromEntries(
Object.entries(params)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => [key, String(value)]),
);
return generatePath(pattern, stringified);
return generatePath(pattern, params);
}

View File

@@ -1,10 +1,10 @@
import { matchPath } from 'react-router';
import { matchPath } from 'react-router-dom';
import type { AppParams, RouteMatch } from './types';
interface MatchRouteOptions {
/**
* Defaults to `false`, matching v5's string form. v7's `end` defaults to
* Defaults to `false`, matching v5's string form. v6's `end` defaults to
* `true`, so callers that pass no options rely on this default and would
* silently flip to exact matching without it.
*/
@@ -18,22 +18,29 @@ export function matchRoute<Key extends string = string>(
): RouteMatch<Key> | null {
const end = options.exact ?? false;
// v7 un-escapes `%2F` in param values itself (and nothing else), which is
// what keeps `buildRoutePath` -> `matchRoute` a round trip for a value
// containing a slash.
const match = matchPath(
{ path: pattern, end, caseSensitive: false },
pathname,
);
const match = matchPath<Record<string, string | undefined>>(pathname, {
path: pattern,
exact: end,
});
if (match === null) {
return null;
}
// v6.30 un-escapes `%2F` in param values (and nothing else); v5 hands them
// back raw. Doing it here keeps `buildRoutePath` -> `matchRoute` a round trip
// for values containing a slash, on both versions.
const params = Object.fromEntries(
Object.entries(match.params).map(([key, value]) => [
key,
value?.replace(/%2F/g, '/'),
]),
);
return {
params: match.params as AppParams<Key>,
pathname: match.pathname,
pathnameBase: match.pathnameBase,
params: params as AppParams<Key>,
pathname: match.url,
pathnameBase: match.url,
pattern: { path: pattern, caseSensitive: false, end },
};
}

View File

@@ -1,114 +1,85 @@
import type { DataRouter } from 'react-router';
import { stripBasePath } from 'utils/basePath';
import history from 'lib/history';
import type {
AppLocation,
NavigateOptions,
NavigationAction,
NavigationBlocker,
To,
} from './types';
import { toAppLocation } from './utils';
import { applyNavigate, retryTransition, toAppLocation } from './utils';
/**
* Imperative navigation, usable from any code. `useAppNavigate` navigates on
* the nearest router instead. Unlike `useSafeNavigate` it does not suppress
* same-URL navigation and has no new-tab branch, because it cannot see the
* current render.
*
* It drives the data router attached last: the app's, or the one a test or a
* story mounts. Callers pass and receive basename-free app paths. The router
* prepends the base path on write, and its `state.location` is the full browser
* path, so reads strip it here.
*
* A navigation commits synchronously only while no matched route has a loader;
* once one does, `router.navigate` settles after the loader.
*/
type Listener = (update: {
location: AppLocation;
action: NavigationAction;
}) => void;
let entryKeys: Array<string | undefined> = [history.location.key];
let currentKey = history.location.key;
const attached: DataRouter[] = [];
const listeners = new Set<Listener>();
let inAppPushCount = 0;
function activeRouter(): DataRouter {
const router = attached[attached.length - 1];
if (!router) {
throw new Error(
'lib/router/navigation: no router is attached. Mount the app router, a TestRouter or a story first.',
);
// history@4 reports a blocked POP's target but not how far away it is, and the
// POP is already reverted by the time `retry` runs. Mirroring its entry keys
// gives the distance back. Like history@4, an entry it never saw (one from
// before a reload) counts as the first one, so a POP whose ends both land on
// index 0 is not reverted.
history.listen((location, action) => {
const index = entryKeys.indexOf(currentKey);
currentKey = location.key;
if (action === 'PUSH') {
entryKeys = [...entryKeys.slice(0, index + 1), location.key];
} else if (action === 'REPLACE' && index !== -1) {
entryKeys[index] = location.key;
}
return router;
}
});
function toAppPath<S>(location: AppLocation<S>): AppLocation<S> {
return { ...location, pathname: stripBasePath(location.pathname) };
function entryIndex(key: string | undefined): number {
return Math.max(0, entryKeys.indexOf(key));
}
/**
* Makes `router` the one this module drives until the returned detach runs.
* Attachments stack, so detaching a router a test mounted hands control back
* to the one below it.
* Imperative navigation on the app's history singleton, usable from any code.
* `useAppNavigate` navigates on the nearest router instead. Unlike
* `useSafeNavigate` it does not suppress same-URL navigation and has no new-tab
* branch, because it cannot see the current render.
*
* Callers pass and receive basename-free app paths.
*/
export function attachRouter(router: DataRouter): () => void {
attached.push(router);
let { location } = router.state;
const unsubscribe = router.subscribe((state) => {
// The router also publishes navigation, fetcher and blocker state; only a
// committed location is a navigation.
if (state.location === location) {
return;
}
location = state.location;
if (attached[attached.length - 1] !== router) {
return;
}
if (state.historyAction === 'PUSH') {
inAppPushCount += 1;
}
const update = {
location: toAppPath(toAppLocation(state.location)),
action: state.historyAction as NavigationAction,
};
listeners.forEach((listener) => listener(update));
});
return (): void => {
unsubscribe();
const index = attached.lastIndexOf(router);
if (index !== -1) {
attached.splice(index, 1);
}
};
}
export function navigate(to: To, options?: NavigateOptions): void {
void activeRouter().navigate(to, {
replace: options?.replace,
state: options?.state,
});
applyNavigate(history, to, options);
}
export function back(): void {
void activeRouter().navigate(-1);
history.goBack();
}
export function getCurrentLocation(): AppLocation {
return toAppPath(toAppLocation(activeRouter().state.location));
return toAppLocation(history.location);
}
export function subscribe(listener: Listener): () => void {
// Wrapped so that subscribing the same function twice yields two
// subscriptions, as `history.listen` did.
const entry: Listener = (update) => listener(update);
listeners.add(entry);
return (): void => {
listeners.delete(entry);
};
export function blockNavigation(blocker: NavigationBlocker): () => void {
return history.block((location, action) => {
const appLocation = toAppLocation(location);
const navigationAction = action as NavigationAction;
const delta = entryIndex(location.key) - entryIndex(currentKey);
const popDelta = delta === 0 ? null : delta;
let deciding = true;
let allowed = false;
blocker({
location: appLocation,
action: navigationAction,
retry: () => {
if (deciding) {
allowed = true;
return;
}
retryTransition(history, appLocation, navigationAction, popDelta);
},
});
deciding = false;
// history@4 cancels the transition when the prompt returns `false`;
// history@5 cancels whenever a blocker is registered and hands control to
// `retry()`. A `retry()` inside the blocker means "let it through", and
// history@4 does that by not cancelling: re-issuing a POP would walk the
// stack twice.
return allowed ? undefined : false;
});
}
export const hasInAppHistory = (): boolean => inAppPushCount > 0;
export { hasInAppHistory } from 'lib/history';

View File

@@ -1,6 +1,7 @@
/**
* Facade contracts for the router. Every shape here is defined the way
* react-router v7 behaves.
* react-router v6+ / history@5 behaves; the v5-backed implementations adapt up
* to it.
*/
export type NavigationAction = 'PUSH' | 'REPLACE' | 'POP';
@@ -10,7 +11,7 @@ export interface AppLocation<S = unknown> {
search: string;
hash: string;
state: S;
/** Always set by v7; optional for callers that build a location by hand. */
/** history@4 leaves this undefined on the initial entry; v6 always sets it. */
key?: string;
}
@@ -23,7 +24,7 @@ export type AppParams<Key extends string = string> = {
};
/**
* Mirrors v7's `useParams` return type so both call shapes keep working:
* Mirrors v6's `useParams` return type so both call shapes keep working:
* a union of param names (`useAppParams<'dashboardId'>()`) or a record
* (`useAppParams<{ dashboardId: string }>()`).
*/
@@ -57,25 +58,15 @@ export interface NavigateOptions {
export type NavigateFn = (to: To, options?: NavigateOptions) => void;
/** The navigation a blocker function is asked about. */
export interface BlockerArgs {
currentLocation: AppLocation;
nextLocation: AppLocation;
export interface BlockedTransition {
location: AppLocation;
action: NavigationAction;
/**
* Called inside the blocker, lets this navigation through and leaves the
* blocker registered. Called later, replays the navigation, so unblock first
* or the blocker catches the replay too.
*/
retry: () => void;
}
export type BlockerFunction = (args: BlockerArgs) => boolean;
/**
* A blocked navigation waits on `proceed` or `reset`. Locations are
* basename-free, like every other location the facade hands out.
*/
export type AppBlocker =
| { state: 'unblocked'; location: undefined }
| {
state: 'blocked';
location: AppLocation;
proceed: () => void;
reset: () => void;
}
| { state: 'proceeding'; location: AppLocation };
export type NavigationBlocker = (transition: BlockedTransition) => void;

View File

@@ -1,58 +0,0 @@
import { useCallback } from 'react';
import {
type BlockerFunction as RouterBlockerFunction,
useBlocker,
} from 'react-router';
import { stripBasePath } from 'utils/basePath';
import type {
AppBlocker,
AppLocation,
BlockerFunction,
NavigationAction,
} from './types';
import { toAppLocation } from './utils';
function toAppPath(location: AppLocation): AppLocation {
return {
...toAppLocation(location),
pathname: stripBasePath(location.pathname),
};
}
/**
* Holds in-app navigations while `shouldBlock` returns true: links, `navigate`
* calls and the browser's back and forward buttons. A page unload is not an
* in-app navigation and needs its own `beforeunload` handler.
*
* The blocker registers in an effect, so it does not cover a navigation made
* during the render that mounts it. A router honours one blocker at a time.
*/
export function useAppBlocker(shouldBlock: BlockerFunction): AppBlocker {
// The router strips the base path from these before calling in.
const blockerFunction = useCallback<RouterBlockerFunction>(
({ currentLocation, nextLocation, historyAction }) =>
shouldBlock({
currentLocation: toAppLocation(currentLocation),
nextLocation: toAppLocation(nextLocation),
action: historyAction as NavigationAction,
}),
[shouldBlock],
);
const blocker = useBlocker(blockerFunction);
switch (blocker.state) {
case 'blocked':
return {
state: 'blocked',
location: toAppPath(blocker.location),
proceed: blocker.proceed,
reset: blocker.reset,
};
case 'proceeding':
return { state: 'proceeding', location: toAppPath(blocker.location) };
default:
return { state: 'unblocked', location: undefined };
}
}

View File

@@ -1,7 +1,7 @@
import { useLocation } from 'react-router';
import { useLocation } from 'react-router-dom';
import type { AppLocation } from './types';
export function useAppLocation<S = unknown>(): AppLocation<S> {
return useLocation() as AppLocation<S>;
return useLocation<S>();
}

View File

@@ -1,20 +1,21 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { useHistory } from 'react-router-dom';
import type { NavigateFn, NavigateOptions, To } from './types';
import { applyNavigate } from './utils';
/**
* Hook-context imperative navigation, for the call sites that only push or
* replace. Prefer `useSafeNavigate` when the call site wants same-URL
* Hook-context imperative navigation, for `useHistory()` call sites that only
* push or replace. Prefer `useSafeNavigate` when the call site wants same-URL
* suppression or new-tab handling.
*/
export function useAppNavigate(): NavigateFn {
const navigate = useNavigate();
const history = useHistory();
return useCallback(
(to: To, options?: NavigateOptions): void => {
navigate(to, { replace: options?.replace, state: options?.state });
applyNavigate(history, to, options);
},
[navigate],
[history],
);
}

View File

@@ -1,10 +1,10 @@
import { useNavigationType } from 'react-router';
import { useNavigationType } from 'react-router-dom-v5-compat';
import type { NavigationAction } from './types';
/**
* The action that produced the current location (`POP` for browser back and
* forward).
* forward). v6 only; v5 exposes this as `history.action`.
*/
export function useAppNavigationType(): NavigationAction {
return useNavigationType();

View File

@@ -1,9 +1,10 @@
import { useParams } from 'react-router';
import { useParams } from 'react-router-dom';
import type { AppParamsResult } from './types';
/**
* Params are typed `string | undefined`, which is what v7 returns.
* Types every param `string | undefined`, as v6 does. v5 types them as required
* strings.
*/
export function useAppParams<
ParamsOrKey extends string | Record<string, string | undefined> = string,

View File

@@ -1,7 +1,8 @@
import { useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router-dom-v5-compat';
/**
* Its setter navigates through the router.
* v6's `useSearchParams`, which has no v5 equivalent. Its setter navigates
* through the router.
*/
export function useAppSearchParams(): ReturnType<typeof useSearchParams> {
return useSearchParams();

View File

@@ -1,13 +1,98 @@
import type { AppLocation } from './types';
import type { History, Location, LocationDescriptor } from 'history';
import { parsePath } from 'history';
import type {
AppLocation,
NavigateOptions,
NavigationAction,
To,
} from './types';
export function toAppLocation<S = unknown>(
location: AppLocation<unknown>,
location: Location<S>,
): AppLocation<S> {
return {
pathname: location.pathname,
search: location.search,
hash: location.hash,
state: location.state as S,
state: location.state,
key: location.key,
};
}
/**
* v5 carries state inside `to`. The string form is parsed first, or search and
* hash would land in `pathname`. Pass `currentLocation` where the target
* renders an href, so a search-only or hash-only string keeps the current
* pathname as it does without state; `history.push` resolves it on its own.
* Not `createLocation`: its `decodeURI` runs again when the link is followed.
*/
export function withState(
to: To,
state: unknown,
currentLocation?: AppLocation,
): LocationDescriptor {
if (state === undefined) {
return to;
}
if (typeof to === 'string') {
const parsed = parsePath(to);
return {
...parsed,
pathname: parsed.pathname || currentLocation?.pathname || '',
state,
};
}
return { ...to, state };
}
/**
* `state` always travels as the second argument, which history@4 and history@5
* both honour for string and object targets. It is omitted rather than passed
* as `undefined`, so a stateless `navigate(path)` reaches the history as the
* bare `push(path)` the call sites used before the facade.
*/
export function applyNavigate(
history: History,
to: To,
options?: NavigateOptions,
): void {
const { replace = false, state } = options ?? {};
const args: Parameters<History['push']> =
state === undefined ? [to] : [to, state];
if (replace) {
history.replace(...args);
} else {
history.push(...args);
}
}
/**
* Replays a transition that a blocker cancelled. history@4 discards the
* transition on cancel, so it has to be re-issued from the location it carried.
* `popDelta` walks the browser from where history@4 reverted the blocked POP
* back to its target. It is `null` when history@4 did not revert, so the
* browser is already on the target and only the router has to catch up.
*/
export function retryTransition(
history: History,
location: AppLocation,
action: NavigationAction,
popDelta: number | null,
): void {
if (action === 'POP') {
if (popDelta === null) {
history.replace(location);
} else if (popDelta !== 0) {
history.go(popDelta);
}
return;
}
if (action === 'REPLACE') {
history.replace(location);
return;
}
history.push(location);
}

View File

@@ -352,6 +352,7 @@ rather than a story that fails at render:
| Module | Replacement | Why |
| --------------------- | ---------------------------------- | ------------------------------------------ |
| `lib/history` | `navigation/history.alias.ts` | keeps a story on its page, see below |
| `store` | `mocks/store.mock.ts` | the singleton answers from the story's store |
| `api/common/logEvent` | `mocks/logEvent.mock.ts` | analytics never leave the iframe |
| `constants/env` | `mocks/env.mock.ts` | pins the API origin the handlers answer on |
@@ -414,17 +415,17 @@ story turns the control that lengthens it rather than a story-only prop.
## Navigation
A story renders one page, so leaving that page would unmount it.
`navigation/pageScope.ts` holds the rule, `navigation/containment.ts` creates the
story's memory data router and guards its `navigate`, which every navigation in
the app reaches, and the two tell a navigation apart by pathname:
`navigation/pageScope.ts` holds the rule, `navigation/containment.ts` is what the
app sees in place of `lib/history`, and the two tell a navigation apart by
pathname:
- **Same page**: a query-param or hash change, which is how tabs, filters,
pagination and time ranges are driven. It is applied, and the page re-renders
the way it does in the app. Anchors are covered too: an in-page `<a href="?tab=x">`
or `<Link to="/home?tab=x">` is intercepted and pushed onto the story's router
or `<Link to="/home?tab=x">` is intercepted and pushed onto the story's history
rather than followed, which would navigate the iframe out of the story.
- **Another page**: a different pathname, an off-site href, `window.open` (what
`useSafeNavigate({ newTab })` calls) or a relative `navigate(-1)`, which carries
`useSafeNavigate({ newTab })` calls) or a relative `go`/`goBack`, which carries
no target to compare against. It is swallowed and reported to
`NavigationBlockedOverlay`, which lists what was attempted. Nothing is silently
dropped.

View File

@@ -1,46 +1,65 @@
import { createMemoryRouter, type DataRouter, parsePath } from 'react-router';
import { hostRoutes } from 'lib/router/HostRouter';
import { fn } from 'storybook/test';
import {
History,
LocationDescriptor,
LocationDescriptorObject,
parsePath,
} from 'history';
import { fn, type Mock } from 'storybook/test';
import { recordBlockedNavigation } from './blockedNavigationStore';
import { navigateWithinPage, setStoryRouter, toHref } from './pageScope';
import { navigateWithinPage, storyHistory, toHref } from './pageScope';
type Navigate = DataRouter['navigate'];
const guardedNavigate = (
via: 'push' | 'replace',
): Mock<(to: LocationDescriptor, state?: unknown) => void> =>
fn((to: LocationDescriptor, state?: unknown): void => {
const target: LocationDescriptorObject =
typeof to === 'string' ? { ...parsePath(to), state } : { state, ...to };
if (navigateWithinPage(target, { replace: via === 'replace' })) {
return;
}
recordBlockedNavigation(via, toHref(to));
}).mockName(`history.${via}`);
const blockedRelativeNavigate = (via: string): Mock<(delta?: number) => void> =>
fn((delta?: number): void => {
recordBlockedNavigation(via, delta === undefined ? via : `${via}(${delta})`);
}).mockName(`history.${via}`);
const overriddenMethods = {
push: guardedNavigate('push'),
replace: guardedNavigate('replace'),
go: blockedRelativeNavigate('go'),
goBack: blockedRelativeNavigate('goBack'),
goForward: blockedRelativeNavigate('goForward'),
} as const;
type OverriddenMethod = keyof typeof overriddenMethods;
const isOverriddenMethod = (prop: string | symbol): prop is OverriddenMethod =>
typeof prop === 'string' && prop in overriddenMethods;
/**
* Every navigation the app makes reaches `router.navigate`: `useNavigate`,
* `<Link>`, `useSearchParams` and `lib/router/navigation` all call it. One that
* stays on the page goes through `pageScope`; whatever would leave the page is
* swallowed and reported to `blockedNavigationStore`.
* What the app sees in place of `lib/history`. Reads (`location`, `action`,
* `listen`) are proxied to the story's memory history so react-router renders
* normally; navigation goes through `pageScope`, and whatever would leave the
* page is swallowed and reported to `blockedNavigationStore`.
* `react-router-dom-v5-compat` drives its `useNavigate` through this same
* object, so `useSafeNavigate` is covered too.
*/
const guardedNavigate = fn(((to, options) => {
if (typeof to === 'number') {
recordBlockedNavigation('go', `go(${to})`);
return Promise.resolve();
}
export const containedHistory: History = new Proxy(storyHistory, {
get(target, prop, receiver) {
if (isOverriddenMethod(prop)) {
return overriddenMethods[prop];
}
return Reflect.get(target, prop, receiver);
},
});
const target = typeof to === 'string' ? parsePath(to) : (to ?? {});
const replace = options?.replace ?? false;
if (!navigateWithinPage(target, { replace, state: options?.state })) {
recordBlockedNavigation(replace ? 'replace' : 'push', toHref(target));
}
return Promise.resolve();
}) as Navigate).mockName('router.navigate');
/** A router for one story, placed at its route before anything renders. */
export const createStoryRouter = (
route: string,
routeState?: unknown,
): DataRouter => {
const router = createMemoryRouter(hostRoutes, {
initialEntries: [{ ...parsePath(route), state: routeState }],
});
setStoryRouter(router);
router.navigate = guardedNavigate as Navigate;
return router;
};
export const hasInAppHistory = (): boolean => false;
export const resetStoryHistory = (): void => {
guardedNavigate.mockClear();
Object.values(overriddenMethods).forEach((method) => method.mockClear());
};

View File

@@ -0,0 +1,20 @@
import {
containedHistory,
hasInAppHistory as containedHasInAppHistory,
} from './containment';
/**
* Replaces `lib/history` in Storybook (aliased in `.storybook/main.ts`). The
* containment rule lives in `containment.ts`; this file only has to keep the
* module's shape, and the annotation is what checks it against the real one.
* An export added to `lib/history` fails to compile here instead of failing at
* render in whichever component imports it.
*/
const libHistory: typeof import('lib/history') = {
default: containedHistory,
hasInAppHistory: containedHasInAppHistory,
};
export default libHistory.default;
export const { hasInAppHistory } = libHistory;

View File

@@ -1,5 +1,9 @@
import { createPath, type DataRouter, parsePath } from 'react-router';
import type { To } from 'lib/router/types';
import {
createMemoryHistory,
LocationDescriptor,
MemoryHistory,
parsePath,
} from 'history';
/**
* Query keys the Storybook preview owns. They live in the iframe's URL, which
@@ -15,18 +19,16 @@ const PREVIEW_PARAMS = [
];
/**
* The story's own router. A story renders one page, so this never leaves it:
* `pageScope` decides what counts as staying, and `containment.ts` creates the
* router and guards its `navigate`. `navigateStoryRouter` is the unguarded one.
* The story's own history. A story renders one page, so this never leaves it:
* `pageScope` decides what counts as staying, and `containment.ts` is what the
* app sees in place of `lib/history`.
*/
let storyRouter: DataRouter | undefined;
let navigateStoryRouter: DataRouter['navigate'] | undefined;
export const storyHistory: MemoryHistory = createMemoryHistory({
initialEntries: ['/'],
});
const currentPathname = (): string =>
storyRouter?.state.location.pathname ?? '/';
export const toHref = (to: To): string =>
typeof to === 'string' ? to : createPath(to);
export const toHref = (to: LocationDescriptor): string =>
typeof to === 'string' ? to : storyHistory.createHref(to);
const withoutPreviewParams = (search: string): URLSearchParams => {
const params = new URLSearchParams(search);
@@ -54,11 +56,9 @@ const mirrorSearchToBrowser = (): void => {
}
});
withoutPreviewParams(storyRouter?.state.location.search ?? '').forEach(
(value, key) => {
mirrored.set(key, value);
},
);
withoutPreviewParams(storyHistory.location.search).forEach((value, key) => {
mirrored.set(key, value);
});
window.history.replaceState(
window.history.state,
@@ -67,23 +67,7 @@ const mirrorSearchToBrowser = (): void => {
);
};
/**
* Makes `router` the story's router. `navigate` is captured before
* `containment.ts` replaces it, so in-page moves skip the guard.
*/
export const setStoryRouter = (router: DataRouter): void => {
storyRouter = router;
navigateStoryRouter = router.navigate;
let { location } = router.state;
router.subscribe((state) => {
if (state.location !== location) {
({ location } = state);
mirrorSearchToBrowser();
}
});
mirrorSearchToBrowser();
};
storyHistory.listen(mirrorSearchToBrowser);
/** `/home/` and `/home` are the same page as far as a story is concerned. */
const normalizePathname = (pathname: string): string =>
@@ -93,7 +77,8 @@ const normalizePathname = (pathname: string): string =>
export const isSamePagePathname = (pathname: string | undefined): boolean =>
!pathname ||
normalizePathname(pathname) === normalizePathname(currentPathname());
normalizePathname(pathname) ===
normalizePathname(storyHistory.location.pathname);
/**
* Applies a navigation that stays on the story's page: a query-param or hash
@@ -101,8 +86,8 @@ export const isSamePagePathname = (pathname: string | undefined): boolean =>
* when the target is another page, leaving the caller to report it as blocked.
*/
export const navigateWithinPage = (
to: To,
{ replace = false, state }: { replace?: boolean; state?: unknown } = {},
to: LocationDescriptor,
{ replace = false }: { replace?: boolean } = {},
): boolean => {
const target = typeof to === 'string' ? parsePath(to) : to;
@@ -113,21 +98,19 @@ export const navigateWithinPage = (
// The preview's params ride along in whatever the app read out of
// `window.location.search`, so they are dropped on the way back in and the
// story's history stays the page's own URL.
void navigateStoryRouter?.(
{
...target,
search: `?${withoutPreviewParams(target.search ?? '')}`,
pathname: currentPathname(),
},
{ replace, state },
);
storyHistory[replace ? 'replace' : 'push']({
...target,
search: `?${withoutPreviewParams(target.search ?? '')}`,
pathname: storyHistory.location.pathname,
});
return true;
};
/** Moves the story to another route without going through the block. */
export const setStoryLocation = (route: string, state?: unknown): void => {
void navigateStoryRouter?.(route, { replace: true, state });
/** Places the story at a route without going through the block. */
export const setStoryLocation = (to: LocationDescriptor): void => {
storyHistory.replace(to);
mirrorSearchToBrowser();
};
/**
@@ -138,7 +121,7 @@ export const isBlockableHref = (href: string): boolean =>
href.length > 0 && !href.startsWith('#') && !href.startsWith('javascript:');
/**
* An anchor href as a location the story's router understands, or `undefined`
* An anchor href as a location the story's history understands, or `undefined`
* when it leads off the page. Relative hrefs (`?tab=logs`) carry no pathname and
* stay on the page; app links do, and are resolved against the iframe so an
* off-site href fails the host check before its path is compared.

View File

@@ -1,4 +1,6 @@
import { ReactNode, useEffect, useMemo } from 'react';
import { Router } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import AppPageProviders from '@/app/AppPageProviders';
import AppProviders from '@/app/AppProviders';
import AppShell from '@/app/AppShell';
@@ -6,7 +8,7 @@ import type { AppLayer } from '@/app/types';
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import { Toaster } from '@signozhq/ui/sonner';
import AppLayout from 'container/AppLayout';
import { HostRouter } from 'lib/router/HostRouter';
import history from 'lib/history';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { AppContext, useAppContext } from 'providers/App/App';
@@ -39,6 +41,12 @@ function StoryContextProbe(): null {
return null;
}
const storyRouter: AppLayer = (children) => (
<Router history={history}>
<CompatRouter>{children}</CompatRouter>
</Router>
);
const appLayout: AppLayer = (children) => <AppLayout>{children}</AppLayout>;
const bareLayout: AppLayer = (children) => (
@@ -58,7 +66,7 @@ function StorybookProviders({
routeState,
reduxState,
}: StorybookProvidersProps): JSX.Element {
const { router, searchParams } = useStoryRoute(route, routeState);
const searchParams = useStoryRoute(route, routeState);
const queryClient = useMemo(createStorybookQueryClient, []);
const store = useMemo(() => {
const created = createStorybookStore(reduxState);
@@ -75,37 +83,36 @@ function StorybookProviders({
useEffect(interceptExternalNavigation, []);
return (
<HostRouter router={router}>
<AppProviders
store={store}
queryClient={queryClient}
appContext={(scoped): ReactNode => (
<AppContext.Provider value={appContextValue}>{scoped}</AppContext.Provider>
)}
searchParams={(scoped): ReactNode => (
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
{scoped}
</NuqsTestingAdapter>
)}
<AppProviders
store={store}
queryClient={queryClient}
appContext={(scoped): ReactNode => (
<AppContext.Provider value={appContextValue}>{scoped}</AppContext.Provider>
)}
searchParams={(scoped): ReactNode => (
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
{scoped}
</NuqsTestingAdapter>
)}
>
<AppShell
router={storyRouter}
overlays={
<>
<StoryContextProbe />
<CmdKPalette userRole={role} />
<NavigationBlockedOverlay />
</>
}
>
<AppShell
overlays={
<>
<StoryContextProbe />
<CmdKPalette userRole={role} />
<NavigationBlockedOverlay />
</>
}
<AppPageProviders
layout={layout === 'app' ? appLayout : bareLayout}
queryBuilder={queryBuilder}
>
<AppPageProviders
layout={layout === 'app' ? appLayout : bareLayout}
queryBuilder={queryBuilder}
>
{children}
</AppPageProviders>
</AppShell>
</AppProviders>
</HostRouter>
{children}
</AppPageProviders>
</AppShell>
</AppProviders>
);
}

View File

@@ -1,51 +1,18 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { attachRouter } from 'lib/router/navigation';
import { useMemo } from 'react';
import { parsePath } from 'history';
import { createStoryRouter } from '../navigation/containment';
import { setStoryLocation } from '../navigation/pageScope';
type StoryRouter = ReturnType<typeof createStoryRouter>;
/**
* Creates the story's router at its route, so the page's first render already
* reads it, and attaches it to `lib/router/navigation`. A later route (a control
* that changes it) moves the same router. Also hands back the search params for
* the nuqs testing adapter.
* Places the story's history at its route before the router mounts and hands
* back the search params for the nuqs testing adapter.
*/
export const useStoryRoute = (
route: string,
routeState?: unknown,
): { router: StoryRouter; searchParams: URLSearchParams } => {
// Attached while rendering: children run their effects first, and one that
// navigates must reach this router.
const [mounted] = useState(() => {
const router = createStoryRouter(route, routeState);
return { router, detach: attachRouter(router) };
});
useEffect(
() => (): void => {
mounted.detach();
mounted.router.dispose();
},
[mounted],
);
const placed = useRef({ route, routeState });
useEffect(() => {
if (
placed.current.route !== route ||
placed.current.routeState !== routeState
) {
placed.current = { route, routeState };
setStoryLocation(route, routeState);
}
}, [route, routeState]);
const searchParams = useMemo(() => {
): URLSearchParams =>
useMemo(() => {
setStoryLocation({ ...parsePath(route), state: routeState });
const [, search = ''] = route.split('?');
return new URLSearchParams(search);
}, [route]);
return { router: mounted.router, searchParams };
};
}, [route, routeState]);

View File

@@ -1,23 +1,17 @@
import type { ComponentType } from 'react';
import { Route, Routes } from 'react-router';
import { Route } from 'react-router-dom';
/**
* A page that reads its params out of the pathname renders under its own route
* rather than being mounted bare. `<Route>` only resolves inside `<Routes>`, so
* the pattern a story declares is mounted here instead of in each meta.
* rather than being mounted bare. Stories declare the pattern here instead of
* mounting a router `<Route>` themselves.
*/
export const renderAtRoute = (
path: string | string[],
Component: ComponentType,
): (() => JSX.Element) => {
function StoryAtRoute(): JSX.Element {
return (
<Routes>
{(Array.isArray(path) ? path : [path]).map((pattern) => (
<Route key={pattern} path={pattern} element={<Component />} />
))}
</Routes>
);
return <Route exact path={path} component={Component} />;
}
return StoryAtRoute;

View File

@@ -97,10 +97,7 @@ render(<Page />, undefined, {
expect(getCurrentLocation().pathname).toBe(ROUTES.HOME);
```
Module-level `navigate()` and `getCurrentLocation()` drive the mounted router and throw
when none is. Render first, or attach one with `attachTestRouter` from `tests/router-utils`.
Never mock the router packages (`react-router`, `react-router-dom`);
Never mock the router packages (`react-router`, `react-router-dom`) or `lib/history`;
`signoz/no-direct-react-router-import` rejects it. When a test cannot render at a route,
or asserts the call itself, spy on the `lib/router` facade with `tests/router-mocks`:
```ts

View File

@@ -1,6 +1,6 @@
// Spies on the `lib/router` facade, for tests that cannot render at a route or
// assert the navigation call itself. Tests never mock the router packages: they
// change at a router upgrade, the facade does not.
// assert the navigation call itself. Tests never mock the router packages or
// `lib/history`: those change at a router upgrade, the facade does not.
// Spies last for the rest of the file unless restored.
import * as navigation from 'lib/router/navigation';

View File

@@ -1,41 +1,6 @@
import { createBrowserRouter, type DataRouter } from 'react-router';
import { createHostRoutes } from 'lib/router/HostRouter';
import { attachRouter } from 'lib/router/navigation';
import { getBasePath, withBasePath } from 'utils/basePath';
import { navigate } from 'lib/router/navigation';
interface AttachedTestRouter {
router: DataRouter;
/** Detaches the router and disposes it. */
release: () => void;
}
/**
* A browser data router over jsdom's `window.history`, attached to
* `lib/router/navigation`. `TestRouter` mounts one; a test of code that
* navigates without rendering can use one directly. With `routePatterns` the
* tree renders only at those patterns and reads their params.
*/
export function attachTestRouter(
initialRoute?: string,
routePatterns?: string[],
): AttachedTestRouter {
if (initialRoute) {
window.history.replaceState(null, '', withBasePath(initialRoute));
}
const router = createBrowserRouter(createHostRoutes(routePatterns), {
basename: getBasePath(),
});
const detach = attachRouter(router);
return {
router,
release: (): void => {
detach();
router.dispose();
},
};
}
/** Returns the browser URL to the state a suite should start each test in. */
/** Returns the history singleton to the state a suite should start each test in. */
export function resetTestRoute(): void {
window.history.replaceState(null, '', withBasePath('/'));
navigate('/', { replace: true });
}

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { navigate } from 'lib/router/navigation';
import { useAppLocation } from 'lib/router/useAppLocation';
import { TestRouter } from './router';
@@ -25,7 +26,7 @@ describe('TestRouter', () => {
});
it('keeps the existing URL when initialRoute is omitted', () => {
window.history.replaceState(null, '', '/existing');
navigate('/existing');
render(
<TestRouter>

View File

@@ -1,26 +1,27 @@
import { ReactElement, ReactNode, useEffect, useState } from 'react';
import { Route, Routes, useInRouterContext } from 'react-router';
import { HostRouter } from 'lib/router/HostRouter';
import { ReactElement, ReactNode, useRef } from 'react';
import { Route, Router } from 'react-router-dom';
import { CompatRouter, useInRouterContext } from 'react-router-dom-v5-compat';
import history from 'lib/history';
import { attachTestRouter, resetTestRoute } from './router-utils';
import { resetTestRoute } from './router-utils';
// Each mount creates a browser data router over jsdom's `window.history`, the
// way `src/AppRoutes/index.tsx` mounts one, and attaches it to
// `lib/router/navigation`, so imperative `navigate()` calls move it and
// `useAppBlocker` works. The base path is applied as the real mount applies it.
// A `TestRouter` inside another renders its children on the outer router and
// cannot take an `initialRoute`: the outer router has already read the URL.
// Mounts on the `lib/history` singleton like `src/AppRoutes/index.tsx`, so
// imperative `navigate()` calls move the router. `CompatRouter` is always
// present for the v6-only facade hooks. A `TestRouter` inside another renders
// its children on the outer router and cannot take an `initialRoute`: the outer
// `CompatRouter` has already read the location, so v6-backed hooks would keep
// the outer route.
interface TestRouterProps {
children: ReactNode;
/**
* Seeded onto `window.history` before the router reads it. Omit to mount at
* whatever URL the test has already set up. `render()` defaults it to `/`.
* Seeded onto the history singleton before the router reads it. Omit to mount
* at whatever URL the test has already set up. `render()` defaults it to `/`.
*/
initialRoute?: string;
/**
* Route pattern(s) to mount the children under, for a component that reads
* `useParams`. Matching is exact, which is `<Routes>`'s default.
* `useParams`. Matching is exact.
*/
routePath?: string | string[];
}
@@ -36,29 +37,34 @@ export function TestRouter({
'A nested TestRouter cannot take initialRoute. Pass it to the outermost TestRouter or to render().',
);
}
const patterns = routePath === undefined ? undefined : [routePath].flat();
// Attached while rendering rather than in an effect: children run their
// effects first, and one that subscribes or navigates must reach this router.
const [mounted] = useState(() =>
nested ? undefined : attachTestRouter(initialRoute, patterns),
);
useEffect(() => (): void => mounted?.release(), [mounted]);
if (mounted) {
return <HostRouter router={mounted.router}>{children}</HostRouter>;
// The seed has to land before `<Router>` reads `history.location`, hence the
// render body rather than an effect. The ref keeps it to the first render, so
// a navigation made by the component under test is not undone.
const seeded = useRef(false);
if (!seeded.current) {
seeded.current = true;
if (initialRoute) {
history.replace(initialRoute);
}
}
if (!patterns) {
return <>{children}</>;
const tree = routePath ? (
<Route exact path={routePath}>
{children}
</Route>
) : (
children
);
if (nested) {
return <>{tree}</>;
}
return (
<Routes>
{patterns.map((pattern) => (
<Route key={pattern} path={pattern} element={children} />
))}
</Routes>
<Router history={history}>
<CompatRouter>{tree}</CompatRouter>
</Router>
);
}

View File

@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { render, RenderOptions, RenderResult } from '@testing-library/react';
import { ResourceProvider } from 'hooks/useResourceAttribute';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppContext } from 'providers/App/App';
import { IAppContext } from 'providers/App/types';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';

View File

@@ -51,12 +51,6 @@ describe('at basePath="/"', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(`${window.location.origin}/logs`);
});
it('stripBasePath is a no-op for any internal path', () => {
expect(m.stripBasePath('/logs')).toBe('/logs');
expect(m.stripBasePath('/logs/explorer')).toBe('/logs/explorer');
expect(m.stripBasePath('/')).toBe('/');
});
it('getBaseUrl returns bare origin', () => {
expect(m.getBaseUrl()).toBe(window.location.origin);
});
@@ -91,39 +85,6 @@ describe('at basePath="/signoz/"', () => {
);
});
it('stripBasePath removes the prefix', () => {
expect(m.stripBasePath('/signoz/logs')).toBe('/logs');
expect(m.stripBasePath('/signoz/logs/explorer')).toBe('/logs/explorer');
});
it('stripBasePath maps the prefix itself to "/"', () => {
expect(m.stripBasePath('/signoz/')).toBe('/');
expect(m.stripBasePath('/signoz')).toBe('/');
});
it('stripBasePath is idempotent, so safe to call twice', () => {
expect(m.stripBasePath(m.stripBasePath('/signoz/logs'))).toBe('/logs');
});
it('stripBasePath leaves a path that only looks like the prefix alone', () => {
expect(m.stripBasePath('/signozzz/logs')).toBe('/signozzz/logs');
});
it('stripBasePath matches the prefix case-insensitively, as the router does', () => {
expect(m.stripBasePath('/SigNoz/logs')).toBe('/logs');
expect(m.stripBasePath('/SIGNOZ')).toBe('/');
});
it('stripBasePath passes through external URLs', () => {
expect(m.stripBasePath('https://example.com/signoz/foo')).toBe(
'https://example.com/signoz/foo',
);
});
it('stripBasePath round-trips withBasePath', () => {
expect(m.stripBasePath(m.withBasePath('/logs?a=1'))).toBe('/logs?a=1');
});
it('getAbsoluteUrl returns origin + prefixed path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(
`${window.location.origin}/signoz/logs`,

View File

@@ -30,32 +30,6 @@ export function withBasePath(path: string): string {
return _basePath + path.slice(1);
}
/**
* Inverse of withBasePath: turns a real browser pathname into an app path.
* Idempotent and safe to call on any value.
*
* stripBasePath('/signoz/logs') → '/logs'
* stripBasePath('/logs') → '/logs' (already stripped)
* stripBasePath('/signoz') → '/' (the base path itself)
*/
export function stripBasePath(path: string): string {
if (!path.startsWith('/')) {
return path;
}
if (_basePath === '/') {
return path;
}
const lowerPath = path.toLowerCase();
const lowerBase = _basePath.toLowerCase();
if (lowerPath === lowerBase || lowerPath === lowerBase.slice(0, -1)) {
return '/';
}
if (lowerPath.startsWith(lowerBase)) {
return path.slice(_basePath.length - 1);
}
return path;
}
/**
* Full absolute URL — for copy-to-clipboard and window.open calls.
* getAbsoluteUrl(ROUTES.LOGS_EXPLORER) → 'https://host/signoz/logs/logs-explorer'

View File

@@ -1,22 +1,20 @@
/**
* Reads the params nuqs has actually published, which `useLocation().search`
* (and `useUrlQuery()`) can lag behind.
* This was introduced to fix a sync bug between Nuqs and react-router-dom
*
* A nuqs write defaults to `shallow: true`, which means it calls
* `history.replaceState` and stops there. `nuqs/adapters/react-router/v7` only
* calls the router's `navigate()` when a write opts into `shallow: false`, and
* nothing here does. A `replaceState` made behind the router's back fires no
* `popstate`, so the router's location does not move. That is nuqs working as
* designed, not a version problem: the v5 -> v6 -> v7 migrations did not change
* it and the adapter swap does not either.
* We are using the wrong adapter for nuqs because the correct one only supports v6/v7,
* and we are at version v5. Nuqs writes params straight to the History API, which
* react-router v5 never observes, so `useLocation().search` (and `useUrlQuery()`) can
* be several nuqs updates behind the real URL.
*
* Use this whenever you need to build a navigation target on top of the current
* params, otherwise stale values get republished and nuqs adopts them back on its
* next flush (it snapshots `window.location.search`).
*
* We can revert this once we migrate react-router-dom to v6.
*/
/**
* Test seam for the nuqs/router sync this module papers over.
* This was created to help testing the regression introduced between nuqs/react-router-dom
*/
type SearchParamsGetter = () => URLSearchParams;
let getter: SearchParamsGetter = (): URLSearchParams =>

5
pkg/cache/cache.go vendored
View File

@@ -16,6 +16,11 @@ type Cache interface {
// Get gets the cacheble entity in the dest entity passed.
Get(ctx context.Context, orgID valuer.UUID, cacheKey string, dest cachetypes.Cacheable) error
// GetMany gets the cacheable entities of cacheKeys into dests, one per key in
// the same order. A key that is not found or could not be read sets its dest
// to nil. The returned error joins the read failures; a miss alone is not one.
GetMany(ctx context.Context, orgID valuer.UUID, cacheKeys []string, dests []cachetypes.Cacheable) error
// Delete deletes the cacheable entity from cache
Delete(ctx context.Context, orgID valuer.UUID, cacheKey string)

View File

@@ -196,6 +196,28 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey s
return errors.NewInternalf(errors.CodeInternal, "unrecognized: (value: \"%s\")", reflect.TypeOf(cachedData).String())
}
func (provider *provider) GetMany(ctx context.Context, orgID valuer.UUID, cacheKeys []string, dests []cachetypes.Cacheable) error {
if len(dests) != len(cacheKeys) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "got %d destinations for %d keys", len(dests), len(cacheKeys))
}
var errs []error
for i, cacheKey := range cacheKeys {
err := provider.Get(ctx, orgID, cacheKey, dests[i])
if err == nil {
continue
}
dests[i] = nil
if !errors.Ast(err, errors.TypeNotFound) {
errs = append(errs, err)
}
}
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, cacheKey string) {
_, span := provider.settings.Tracer().Start(ctx, "memory.delete", trace.WithAttributes(
attribute.String(semconv.AttributeDBSystem, "memory"),

View File

@@ -260,3 +260,27 @@ func TestCloneableConcurrentSetGet(t *testing.T) {
assert.NotSame(t, cachedCloneable, cloneables[i])
}
}
func TestGetMany(t *testing.T) {
cache, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: 1 << 26,
}})
require.NoError(t, err)
orgID := valuer.GenerateUUID()
require.NoError(t, cache.Set(context.Background(), orgID, "first", &CloneableA{Key: "first", Value: 1}, 10*time.Second))
require.NoError(t, cache.Set(context.Background(), orgID, "third", &CloneableA{Key: "third", Value: 3}, 10*time.Second))
// bytes that are not JSON cannot be read into a CloneableA
memory := cache.(*provider)
memory.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), "fourth"}, "::"), []byte("not json"), 1, 10*time.Second)
memory.cc.Wait()
dests := []cachetypes.Cacheable{new(CloneableA), new(CloneableA), new(CloneableA), new(CloneableA)}
err = cache.GetMany(context.Background(), orgID, []string{"first", "second", "third", "fourth"}, dests)
// the unreadable key is reported and nilled, the readable ones are still served
assert.Error(t, err)
assert.Equal(t, []cachetypes.Cacheable{&CloneableA{Key: "first", Value: 1}, nil, &CloneableA{Key: "third", Value: 3}, nil}, dests)
}

View File

@@ -66,6 +66,49 @@ func (c *provider) Get(ctx context.Context, orgID valuer.UUID, cacheKey string,
return nil
}
func (c *provider) GetMany(ctx context.Context, orgID valuer.UUID, cacheKeys []string, dests []cachetypes.Cacheable) error {
if len(dests) != len(cacheKeys) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "got %d destinations for %d keys", len(dests), len(cacheKeys))
}
if len(cacheKeys) == 0 {
return nil
}
updatedCacheKeys := make([]string, len(cacheKeys))
for i, cacheKey := range cacheKeys {
updatedCacheKeys[i] = strings.Join([]string{orgID.StringValue(), cacheKey}, "::")
}
values, err := c.client.MGet(ctx, updatedCacheKeys...).Result()
if err != nil {
for i := range dests {
dests[i] = nil
}
return err
}
var errs []error
for i, value := range values {
// MGET answers a missing key with nil and a present one with its string
switch stored := value.(type) {
case nil:
dests[i] = nil
case string:
if err := dests[i].UnmarshalBinary([]byte(stored)); err != nil {
dests[i] = nil
errs = append(errs, err)
}
default:
dests[i] = nil
errs = append(errs, errors.NewInternalf(errors.CodeInternal, "unexpected value of type %T for key %s", value, cacheKeys[i]))
}
}
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
func (c *provider) Delete(ctx context.Context, orgID valuer.UUID, cacheKey string) {
c.DeleteMany(ctx, orgID, []string{cacheKey})
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/go-redis/redismock/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type CacheableA struct {
@@ -58,3 +59,28 @@ func TestSet(t *testing.T) {
assert.NoError(t, cache.Set(context.Background(), orgID, "key", cacheable, 10*time.Second))
assert.NoError(t, mock.ExpectationsWereMet())
}
func TestGetMany(t *testing.T) {
db, mock := redismock.NewClientMock()
providerSettings := instrumentationtest.New().ToProviderSettings()
cache := &provider{client: db, settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/cache/rediscache")}
orgID := valuer.GenerateUUID()
stored, err := json.Marshal(&CacheableA{Key: "first", Value: 1, Expiry: time.Microsecond})
require.NoError(t, err)
// one round trip for all keys, with the missing one answered as nil and
// the third holding something that is not a CacheableA
mock.ExpectMGet(
strings.Join([]string{orgID.StringValue(), "first"}, "::"),
strings.Join([]string{orgID.StringValue(), "second"}, "::"),
strings.Join([]string{orgID.StringValue(), "third"}, "::"),
).SetVal([]any{string(stored), nil, "not json"})
dests := []cachetypes.Cacheable{new(CacheableA), new(CacheableA), new(CacheableA)}
err = cache.GetMany(context.Background(), orgID, []string{"first", "second", "third"}, dests)
// the unreadable key is reported and nilled, the readable one is still served
assert.Error(t, err)
assert.Equal(t, []cachetypes.Cacheable{&CacheableA{Key: "first", Value: 1, Expiry: time.Microsecond}, nil, nil}, dests)
assert.NoError(t, mock.ExpectationsWereMet())
}

View File

@@ -1,6 +1,7 @@
package querier
import (
"cmp"
"context"
"encoding/json"
"fmt"
@@ -11,11 +12,16 @@ import (
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
// bucketCacheCellSteps is the grid cell width in steps; no cached bucket
// crosses a cell boundary.
const bucketCacheCellSteps = 60
// BucketCache is the interface for bucket-based caching.
type BucketCache interface {
// cached portion + list of gaps to fetch
@@ -58,44 +64,59 @@ func (bc *bucketCache) GetMissRanges(
bc.logger.DebugContext(ctx, "getting miss ranges", slog.String("fingerprint", q.Fingerprint()), slog.Uint64("start", startMs), slog.Uint64("end", endMs))
// Generate cache key
cacheKey := bc.generateCacheKey(q)
bc.logger.DebugContext(ctx, "cache key", slog.String("cache_key", cacheKey))
// Try to get cached data
var data qbtypes.CachedData
err := bc.cache.Get(ctx, orgID, cacheKey, &data)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
bc.logger.ErrorContext(ctx, "error getting cached data", errors.Attr(err))
}
// No cached data, need to fetch entire range
missing = []*qbtypes.TimeRange{{From: startMs, To: endMs}}
return nil, missing
}
// Extract step interval if this is a builder query
stepMs := uint64(step.Milliseconds())
// Find missing ranges with step alignment
missing = bc.findMissingRangesWithStep(data.Buckets, startMs, endMs, stepMs)
bc.logger.DebugContext(ctx, "missing ranges", slog.Any("missing", missing), slog.Uint64("step", stepMs))
// No cached data, need to fetch entire range
missing = []*qbtypes.TimeRange{{From: startMs, To: endMs}}
// If no cached data overlaps with requested range, return empty result
if len(data.Buckets) == 0 {
if endMs <= startMs {
return nil, missing
}
cellWidthMs := bucketCacheCellSteps * stepMs
firstCellIdx, lastCellIdx := deriveCellIdx(startMs, cellWidthMs), deriveCellIdx(endMs-1, cellWidthMs)
// Gather the buckets of every cell the window touches
cellKeys := make([]string, 0, lastCellIdx-firstCellIdx+1)
cellEntries := make([]cachetypes.Cacheable, 0, lastCellIdx-firstCellIdx+1)
for cellIdx := firstCellIdx; cellIdx <= lastCellIdx; cellIdx++ {
cellKeys = append(cellKeys, bc.generateCacheKey(q, cellIdx))
cellEntries = append(cellEntries, &qbtypes.CachedData{})
}
// A cell that could not be read comes back nil like a miss, so the rest
// are still worth serving
if err := bc.cache.GetMany(ctx, orgID, cellKeys, cellEntries); err != nil {
bc.logger.ErrorContext(ctx, "error getting cached data", errors.Attr(err))
}
cachedBuckets := []*qbtypes.CachedBucket{}
cachedWarnings := []string{}
for _, cellEntry := range cellEntries {
if cellEntry == nil {
continue
}
cellData := cellEntry.(*qbtypes.CachedData)
cachedBuckets = append(cachedBuckets, cellData.Buckets...)
cachedWarnings = append(cachedWarnings, cellData.Warnings...)
}
if len(cachedBuckets) == 0 {
return nil, missing
}
// Find missing ranges with step alignment
missing = bc.findMissingRangesWithStep(cachedBuckets, startMs, endMs, stepMs)
bc.logger.DebugContext(ctx, "missing ranges", slog.Any("missing", missing), slog.Uint64("step", stepMs))
// Extract relevant buckets and merge them
relevantBuckets := bc.filterRelevantBuckets(data.Buckets, startMs, endMs)
relevantBuckets := bc.filterRelevantBuckets(cachedBuckets, startMs, endMs)
if len(relevantBuckets) == 0 {
// nothing cached overlaps the window, so all of it has to be fetched.
return nil, []*qbtypes.TimeRange{{From: startMs, To: endMs}}
}
// Merge buckets into a single result
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, data.Warnings)
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, cachedWarnings)
// Filter the merged result to only include values within the requested time range
mergedResult = bc.filterResultToTimeRange(mergedResult, q, startMs, endMs, stepMs)
@@ -130,15 +151,6 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
slog.Uint64("cachable_end", cachableEndMs))
}
// Generate cache key
cacheKey := bc.generateCacheKey(q)
// Get existing cached data
var existingData qbtypes.CachedData
if err := bc.cache.Get(ctx, orgID, cacheKey, &existingData); err != nil {
existingData = qbtypes.CachedData{}
}
// Trim the result to exclude data within flux interval
trimmedResult := bc.trimResultToFluxBoundary(fresh, cachableEndMs)
if trimmedResult == nil {
@@ -174,38 +186,44 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
}
}
// Convert trimmed result to buckets with adjusted boundaries
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs)
// Convert trimmed result to buckets, one per grid cell it spans
cellWidthMs := bucketCacheCellSteps * stepMs
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs, cellWidthMs)
// If no fresh buckets and no existing data, don't cache
if len(freshBuckets) == 0 && len(existingData.Buckets) == 0 {
return
}
// Each cell is its own cache entry, rewritten only when the fresh bucket
// changes what it holds, so an entry's TTL runs from its last new data
for _, freshBucket := range freshBuckets {
cellKey := bc.generateCacheKey(q, deriveCellIdx(freshBucket.StartMs, cellWidthMs))
// Merge with existing buckets
mergedBuckets := bc.mergeAndDeduplicateBuckets(existingData.Buckets, freshBuckets)
var cellEntry qbtypes.CachedData
if err := bc.cache.Get(ctx, orgID, cellKey, &cellEntry); err != nil {
cellEntry = qbtypes.CachedData{}
}
// Update warnings
allWarnings := append(existingData.Warnings, trimmedResult.Warnings...)
uniqueWarnings := bc.deduplicateWarnings(allWarnings)
// Create updated cached data
updatedData := qbtypes.CachedData{
Buckets: mergedBuckets,
Warnings: uniqueWarnings,
}
// Marshal and store in cache
if err := bc.cache.Set(ctx, orgID, cacheKey, &updatedData, bc.cacheTTL); err != nil {
bc.logger.ErrorContext(ctx, "error setting cached data", errors.Attr(err))
cellBuckets, changed := bc.mergeFreshBucketIntoCell(ctx, cellEntry.Buckets, freshBucket)
if !changed {
continue
}
cellEntry.Buckets = cellBuckets
cellEntry.Warnings = bc.deduplicateWarnings(append(cellEntry.Warnings, trimmedResult.Warnings...))
if err := bc.cache.Set(ctx, orgID, cellKey, &cellEntry, bc.cacheTTL); err != nil {
bc.logger.ErrorContext(ctx, "error setting cached data", errors.Attr(err))
}
}
}
// generateCacheKey creates a unique cache key based on query fingerprint.
func (bc *bucketCache) generateCacheKey(q qbtypes.Query) string {
fingerprint := q.Fingerprint()
// generateCacheKey names the cache entry holding one grid cell of a query.
func (bc *bucketCache) generateCacheKey(q qbtypes.Query, cellIdx uint64) string {
return fmt.Sprintf("v6:query:%s:cellidx:%d", q.Fingerprint(), cellIdx)
}
return fmt.Sprintf("v5:query:%s", fingerprint)
// deriveCellIdx returns the index of the grid cell holding startMs; with no grid
// every bucket shares cell 0.
func deriveCellIdx(startMs, cellWidthMs uint64) uint64 {
if cellWidthMs == 0 {
return 0
}
return startMs / cellWidthMs
}
// findMissingRangesWithStep identifies time ranges not covered by cached buckets with step alignment.
@@ -646,7 +664,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
}
// resultToBuckets converts a query result into time-based buckets.
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs uint64) []*qbtypes.CachedBucket {
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs, cellWidthMs uint64) []*qbtypes.CachedBucket {
// Check if result is empty
isEmpty, isFiltered := bc.isEmptyResult(result)
@@ -657,62 +675,74 @@ func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Resu
return nil
}
// For now, create a single bucket for the entire range
// In the future, we could split large ranges into smaller buckets
valueBytes, err := json.Marshal(result.Value)
// Empty filtered results still become buckets, so the range is not re-queried
perCellBuckets, err := result.SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs)
if err != nil {
bc.logger.ErrorContext(ctx, "failed to marshal result value", errors.Attr(err))
bc.logger.ErrorContext(ctx, "failed to convert result to buckets", errors.Attr(err))
return nil
}
// Always create a bucket, even for empty filtered results
// This ensures we don't re-query for data that doesn't exist
return []*qbtypes.CachedBucket{
{
StartMs: startMs,
EndMs: endMs,
Type: result.Type,
Value: valueBytes,
Stats: result.Stats,
},
}
return perCellBuckets
}
// mergeAndDeduplicateBuckets combines and deduplicates bucket lists.
func (bc *bucketCache) mergeAndDeduplicateBuckets(existing, fresh []*qbtypes.CachedBucket) []*qbtypes.CachedBucket {
// Create a map to deduplicate by time range
bucketMap := make(map[string]*qbtypes.CachedBucket)
// Add existing buckets
for _, bucket := range existing {
key := fmt.Sprintf("%d-%d", bucket.StartMs, bucket.EndMs)
bucketMap[key] = bucket
}
// Add/update with fresh buckets
for _, bucket := range fresh {
key := fmt.Sprintf("%d-%d", bucket.StartMs, bucket.EndMs)
bucketMap[key] = bucket
}
// Convert back to slice with pre-allocated capacity
result := make([]*qbtypes.CachedBucket, 0, len(bucketMap))
for _, bucket := range bucketMap {
result = append(result, bucket)
}
// Sort by start time
slices.SortFunc(result, func(a, b *qbtypes.CachedBucket) int {
if a.StartMs < b.StartMs {
return -1
// mergeFreshBucketIntoCell folds a fresh bucket into the buckets of its cell
// and reports whether the cell changed: a bucket already covering the fresh
// one leaves it as is, buckets inside the fresh one are replaced, buckets
// overlapping or edge to edge with it are unioned, and the rest are kept apart.
func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets []*qbtypes.CachedBucket, freshBucket *qbtypes.CachedBucket) ([]*qbtypes.CachedBucket, bool) {
keptBuckets := make([]*qbtypes.CachedBucket, 0, len(cellBuckets)+1)
touchingBuckets := []*qbtypes.CachedBucket{}
for _, cellBucket := range cellBuckets {
switch {
case cellBucket.Contains(freshBucket):
return cellBuckets, false
case cellBucket.Touches(freshBucket):
touchingBuckets = append(touchingBuckets, cellBucket)
default:
keptBuckets = append(keptBuckets, cellBucket)
}
if a.StartMs > b.StartMs {
return 1
}
return 0
}
keptBuckets = append(keptBuckets, bc.unionBuckets(ctx, freshBucket, touchingBuckets))
slices.SortFunc(keptBuckets, func(a, b *qbtypes.CachedBucket) int {
return cmp.Compare(a.StartMs, b.StartMs)
})
return keptBuckets, true
}
return result
// unionBuckets widens the fresh bucket by the buckets it touches. Buckets
// inside it are replaced outright, so only a genuine partial overlap decodes
// anything.
func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.CachedBucket, touchingBuckets []*qbtypes.CachedBucket) *qbtypes.CachedBucket {
bucketsToMerge := []*qbtypes.CachedBucket{freshBucket}
for _, touchingBucket := range touchingBuckets {
if !freshBucket.Contains(touchingBucket) {
bucketsToMerge = append(bucketsToMerge, touchingBucket)
}
}
if len(bucketsToMerge) == 1 {
return freshBucket
}
value, err := json.Marshal(bc.mergeTimeSeriesValues(ctx, bucketsToMerge))
if err != nil {
bc.logger.ErrorContext(ctx, "failed to marshal unioned bucket", errors.Attr(err))
return freshBucket
}
unionBucket := &qbtypes.CachedBucket{
StartMs: freshBucket.StartMs,
EndMs: freshBucket.EndMs,
Type: freshBucket.Type,
Value: value,
Stats: qbtypes.ExecStats{StepIntervals: freshBucket.Stats.StepIntervals},
}
for _, mergedBucket := range bucketsToMerge {
unionBucket.StartMs = min(unionBucket.StartMs, mergedBucket.StartMs)
unionBucket.EndMs = max(unionBucket.EndMs, mergedBucket.EndMs)
unionBucket.Stats.RowsScanned += mergedBucket.Stats.RowsScanned
unionBucket.Stats.BytesScanned += mergedBucket.Stats.BytesScanned
unionBucket.Stats.DurationMS += mergedBucket.Stats.DurationMS
}
return unionBucket
}
// deduplicateWarnings removes duplicate warnings.

View File

@@ -2,6 +2,7 @@ package querier
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"testing"
@@ -1465,3 +1466,161 @@ func TestBucketCache_NoCache(t *testing.T) {
// The actual NoCache logic is implemented in querier.run(), not in bucket cache
// This test verifies that the cache works normally and NoCache bypasses it at a higher level
}
func TestBucketCachePutSlicesAtCellBoundaries(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Second}
// a 1s step gives 60s cells, so 30s to 150s spans three of them
query := &mockQuery{fingerprint: "sliced-query", startMs: 30000, endMs: 150000}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 30000, 150000, 1000),
Stats: qbtypes.ExecStats{RowsScanned: 120, BytesScanned: 1200, DurationMS: 12},
})
type bucketShape struct {
cell, startMs, endMs, rowsScanned uint64
valueCount int
}
shapes := []bucketShape{}
for cell := uint64(0); cell < 3; cell++ {
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(query, cell), &cellEntry))
for _, bucket := range cellEntry.Buckets {
var tsData qbtypes.TimeSeriesData
require.NoError(t, json.Unmarshal(bucket.Value, &tsData))
require.Len(t, tsData.Aggregations, 1)
require.Len(t, tsData.Aggregations[0].Series, 1)
shapes = append(shapes, bucketShape{cell, bucket.StartMs, bucket.EndMs, bucket.Stats.RowsScanned, len(tsData.Aggregations[0].Series[0].Values)})
}
}
assert.Equal(t, []bucketShape{
{0, 30000, 60000, 30, 30},
{1, 60000, 120000, 60, 60},
{2, 120000, 150000, 30, 30},
}, shapes)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.Equal(t, []*qbtypes.TimeRange{}, missing)
assert.Len(t, cached.Value.(*qbtypes.TimeSeriesData).Aggregations[0].Series[0].Values, 120)
assert.Equal(t, uint64(120), cached.Stats.RowsScanned)
}
func TestBucketCachePutAbsorbsWritesWithinACell(t *testing.T) {
type bucketShape struct{ startMs, endMs, rowsScanned uint64 }
testCases := []struct {
name string
firstWindow qbtypes.TimeRange
secondWindow qbtypes.TimeRange
expectedBuckets []bucketShape
expectedMissingRanges []*qbtypes.TimeRange
expectedValueCount int
}{
{
name: "DisjointWrites_StayApart",
firstWindow: qbtypes.TimeRange{From: 1000, To: 3000},
secondWindow: qbtypes.TimeRange{From: 5000, To: 8000},
expectedBuckets: []bucketShape{{1000, 3000, 2}, {5000, 8000, 3}},
expectedMissingRanges: []*qbtypes.TimeRange{{From: 3000, To: 5000}},
expectedValueCount: 5,
},
{
name: "EdgeToEdgeWrites_BecomeOne",
firstWindow: qbtypes.TimeRange{From: 1000, To: 3000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 5000},
expectedBuckets: []bucketShape{{1000, 5000, 4}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 4,
},
{
name: "OverlappingWrites_BecomeOne",
firstWindow: qbtypes.TimeRange{From: 1000, To: 4000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 6000},
expectedBuckets: []bucketShape{{1000, 6000, 6}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 5,
},
{
name: "ContainedWrite_KeepsExistingBucket",
firstWindow: qbtypes.TimeRange{From: 1000, To: 10000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 5000},
expectedBuckets: []bucketShape{{1000, 10000, 9}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 9,
},
{
name: "ContainingWrite_ReplacesExistingBucket",
firstWindow: qbtypes.TimeRange{From: 3000, To: 5000},
secondWindow: qbtypes.TimeRange{From: 1000, To: 10000},
expectedBuckets: []bucketShape{{1000, 10000, 9}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 9,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Second}
for _, window := range []qbtypes.TimeRange{testCase.firstWindow, testCase.secondWindow} {
query := &mockQuery{fingerprint: "absorb-query", startMs: window.From, endMs: window.To}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", window.From, window.To, 1000),
// one row per second, so a bucket's stats read as the seconds it was scanned for
Stats: qbtypes.ExecStats{RowsScanned: (window.To - window.From) / 1000},
})
}
query := &mockQuery{
fingerprint: "absorb-query",
startMs: min(testCase.firstWindow.From, testCase.secondWindow.From),
endMs: max(testCase.firstWindow.To, testCase.secondWindow.To),
}
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(query, 0), &cellEntry))
shapes := make([]bucketShape, 0, len(cellEntry.Buckets))
for _, bucket := range cellEntry.Buckets {
shapes = append(shapes, bucketShape{bucket.StartMs, bucket.EndMs, bucket.Stats.RowsScanned})
}
assert.Equal(t, testCase.expectedBuckets, shapes)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.Equal(t, testCase.expectedMissingRanges, missing)
assert.Len(t, cached.Value.(*qbtypes.TimeSeriesData).Aggregations[0].Series[0].Values, testCase.expectedValueCount)
})
}
}
func TestBucketCachePutLeavesAnUnchangedCellAlone(t *testing.T) {
bc := createTestBucketCache(t)
ctx := context.Background()
orgID := valuer.UUID{}
step := qbtypes.Step{Duration: time.Second}
wide := &mockQuery{fingerprint: "unchanged-query", startMs: 1000, endMs: 10000}
bc.Put(ctx, orgID, wide, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 1000, 10000, 1000),
Warnings: []string{"from the wide write"},
})
// a write already covered by the cell's bucket must not rewrite the entry,
// which shows as its warnings never arriving
inside := &mockQuery{fingerprint: "unchanged-query", startMs: 3000, endMs: 5000}
bc.Put(ctx, orgID, inside, step, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 3000, 5000, 1000),
Warnings: []string{"from the inside write"},
})
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(wide, 0), &cellEntry))
assert.Equal(t, []string{"from the wide write"}, cellEntry.Warnings)
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"maps"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
)
@@ -33,6 +34,15 @@ func (c *CachedBucket) Clone() *CachedBucket {
}
}
func (c *CachedBucket) Contains(other *CachedBucket) bool {
return c.StartMs <= other.StartMs && other.EndMs <= c.EndMs
}
// true if c and other overlap or sit edge to edge, false otherwise.
func (c *CachedBucket) Touches(other *CachedBucket) bool {
return c.StartMs <= other.EndMs && other.StartMs <= c.EndMs
}
// CachedData represents the full cached data for a query.
type CachedData struct {
Buckets []*CachedBucket `json:"buckets"`
@@ -77,3 +87,53 @@ func (c *CachedData) Cost() int64 {
}
return size
}
func (r *Result) SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs uint64) ([]*CachedBucket, error) {
tsData, ok := r.Value.(*TimeSeriesData)
if !ok || tsData == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "result of type %s cannot be bucketed", r.Type.StringValue())
}
cellBuckets := make([]*CachedBucket, 0, 1)
for from := startMs; from < endMs; {
to := endMs
if cellWidthMs > 0 {
to = min(endMs, (from/cellWidthMs+1)*cellWidthMs)
}
// A promql value stamped exactly at the window end is an instant inside
// the window, so the last slice keeps it. A builder value never sits
// there, since the end is step-aligned and partial steps are dropped.
sliceEndMs := to
if to == endMs {
sliceEndMs = endMs + 1
}
value, err := json.Marshal(tsData.FilterToTimeRange(from, sliceEndMs))
if err != nil {
return nil, err
}
cellBuckets = append(cellBuckets, &CachedBucket{
StartMs: from,
EndMs: to,
Type: r.Type,
Value: value,
Stats: r.Stats.prorate(to-from, endMs-startMs),
})
from = to
}
return cellBuckets, nil
}
// prorate returns the share of the stats that partMs out of wholeMs of the
// scanned window accounts for.
func (e ExecStats) prorate(partMs, wholeMs uint64) ExecStats {
ratio := 1.0
if partMs < wholeMs {
ratio = float64(partMs) / float64(wholeMs)
}
return ExecStats{
RowsScanned: uint64(float64(e.RowsScanned) * ratio),
BytesScanned: uint64(float64(e.BytesScanned) * ratio),
DurationMS: uint64(float64(e.DurationMS) * ratio),
StepIntervals: maps.Clone(e.StepIntervals),
}
}

View File

@@ -137,6 +137,28 @@ type TimeSeriesData struct {
Aggregations []*AggregationBucket `json:"aggregations" description:"One bucket per aggregation of the query. The order is not guaranteed; match buckets to aggregations by index."`
}
// FilterToTimeRange copies the aggregation and series structure keeping only
// the values with startMs <= timestamp < endMs.
func (t *TimeSeriesData) FilterToTimeRange(startMs, endMs uint64) *TimeSeriesData {
filtered := &TimeSeriesData{
QueryName: t.QueryName,
Aggregations: make([]*AggregationBucket, 0, len(t.Aggregations)),
}
for _, aggBucket := range t.Aggregations {
filteredBucket := &AggregationBucket{
Index: aggBucket.Index,
Alias: aggBucket.Alias,
Meta: aggBucket.Meta,
Series: make([]*TimeSeries, 0, len(aggBucket.Series)),
}
for _, series := range aggBucket.Series {
filteredBucket.Series = append(filteredBucket.Series, series.filterToTimeRange(startMs, endMs))
}
filtered.Aggregations = append(filtered.Aggregations, filteredBucket)
}
return filtered
}
type AggregationBucket struct {
Index int `json:"index" description:"The zero-based position of the aggregation in the query."`
Alias string `json:"alias" description:"The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given."`
@@ -259,6 +281,21 @@ func (ts *TimeSeries) EvaluableValues() []*TimeSeriesValue {
return result
}
// filterToTimeRange copies the series keeping only the values with
// startMs <= timestamp < endMs.
func (ts *TimeSeries) filterToTimeRange(startMs, endMs uint64) *TimeSeries {
filtered := &TimeSeries{
Labels: ts.Labels,
Values: make([]*TimeSeriesValue, 0, len(ts.Values)),
}
for _, value := range ts.Values {
if timestamp := uint64(value.Timestamp); timestamp >= startMs && timestamp < endMs {
filtered.Values = append(filtered.Values, value)
}
}
return filtered
}
type Label struct {
Key telemetrytypes.TelemetryFieldKey `json:"key" description:"The group-by field this label value belongs to."`
Value any `json:"value" description:"The label value as a string, number or boolean."`

View File

@@ -436,3 +436,97 @@ func TestRoundToNonZeroDecimals(t *testing.T) {
assert.Equal(t, math.Inf(1), roundToNonZeroDecimals(math.Inf(1), 3))
assert.Equal(t, math.Inf(-1), roundToNonZeroDecimals(math.Inf(-1), 3))
}
func TestTimeSeriesDataFilterToTimeRange(t *testing.T) {
testCases := []struct {
name string
timeSeriesData *TimeSeriesData
startMs uint64
endMs uint64
expectedFiltered *TimeSeriesData
}{
{
name: "HalfOpenRange_KeepsStartDropsEnd",
timeSeriesData: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 0,
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{
{Timestamp: 1000, Value: 1},
{Timestamp: 2000, Value: 2},
{Timestamp: 3000, Value: 3},
{Timestamp: 4000, Value: 4},
},
}},
}},
},
startMs: 2000,
endMs: 4000,
expectedFiltered: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 0,
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{
{Timestamp: 2000, Value: 2},
{Timestamp: 3000, Value: 3},
},
}},
}},
},
},
{
// heatmap counts are positional against Meta.Buckets, so the axis
// has to survive even when no value falls in the range
name: "NothingInRange_KeepsSeriesAndHeatmapAxis",
timeSeriesData: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 1,
Alias: "__result_1",
Meta: AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{{Timestamp: 1000, Values: []float64{1, 2, 3, 4}}},
}},
}},
},
startMs: 5000,
endMs: 6000,
expectedFiltered: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{
Index: 1,
Alias: "__result_1",
Meta: AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
Series: []*TimeSeries{{
Labels: []*Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host"}, Value: "node-1"}},
Values: []*TimeSeriesValue{},
}},
}},
},
},
{
name: "NoSeries_KeepsAggregation",
timeSeriesData: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{Index: 0}},
},
startMs: 1000,
endMs: 2000,
expectedFiltered: &TimeSeriesData{
QueryName: "A",
Aggregations: []*AggregationBucket{{Index: 0, Series: []*TimeSeries{}}},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expectedFiltered, testCase.timeSeriesData.FilterToTimeRange(testCase.startMs, testCase.endMs))
})
}
}

View File

@@ -35,12 +35,11 @@ test.describe('Routing — auth guards', () => {
expect(await guest.historyDepth()).toBe(depthAtLogin + 1);
// Back reaches the replaced /login entry, where being logged in redirects
// away. The stashed route was cleared, so the fallback is /home. Poll for
// /home: `goBack()` resolves before the redirect off /login, so a
// "not /login" poll passes on the url the pop started from.
// straight out again.
await guest.goBack();
await expect
.poll(() => guest.url().pathname, { timeout: 15_000 })
.toBe(PATH.home);
.not.toBe(PATH.login);
expect(guest.url().pathname).toBe(PATH.services);
});
});