@redocly/theme 0.69.0-next.8 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/lib/components/Accordion/Accordion.js +5 -1
  2. package/lib/components/Catalog/CatalogEntities.js +1 -0
  3. package/lib/components/Catalog/CatalogEntity/CatalogEntityRelations/CatalogEntityRelations.js +5 -3
  4. package/lib/components/Catalog/CatalogViewModeToggle.d.ts +1 -1
  5. package/lib/components/Catalog/CatalogViewModeToggle.js +2 -43
  6. package/lib/components/Filter/FilterInput.js +4 -2
  7. package/lib/components/JsonViewer/Helpers.js +12 -42
  8. package/lib/components/MCP/McpConnectionTabs.js +21 -11
  9. package/lib/components/Markdown/Markdown.js +1 -3
  10. package/lib/components/Markdown/styles/base-table.js +8 -0
  11. package/lib/components/Markdown/variables.js +1 -2
  12. package/lib/components/Panel/Panel.js +5 -1
  13. package/lib/components/Search/FilterFields/SearchFilterFieldSelect.js +22 -32
  14. package/lib/components/Search/SearchAiDialog.js +3 -0
  15. package/lib/components/Search/SearchItem.js +5 -3
  16. package/lib/components/Search/variables.js +1 -0
  17. package/lib/components/Select/Select.js +10 -5
  18. package/lib/components/Sidebar/Sidebar.js +2 -38
  19. package/lib/core/contexts/AccordionGroupContext.d.ts +1 -0
  20. package/lib/core/contexts/AccordionGroupContext.js +6 -0
  21. package/lib/core/contexts/PageActionsContext.d.ts +5 -0
  22. package/lib/core/contexts/PageActionsContext.js +10 -0
  23. package/lib/core/contexts/index.d.ts +2 -0
  24. package/lib/core/contexts/index.js +2 -0
  25. package/lib/core/hooks/code-walkthrough/use-code-walkthrough-controls.js +5 -17
  26. package/lib/core/hooks/code-walkthrough/use-code-walkthrough-steps.js +2 -0
  27. package/lib/core/hooks/menu/use-menu-item-expanded.js +1 -0
  28. package/lib/core/hooks/use-active-heading.js +4 -0
  29. package/lib/core/hooks/use-connect-mcp-button.js +7 -2
  30. package/lib/core/hooks/use-local-state.js +1 -0
  31. package/lib/core/hooks/use-mobile-menu.js +10 -2
  32. package/lib/core/hooks/use-product-picker.js +1 -1
  33. package/lib/core/hooks/use-tabs.js +4 -2
  34. package/lib/core/hooks/use-telemetry-fallback.d.ts +2 -0
  35. package/lib/core/hooks/use-telemetry-fallback.js +2 -0
  36. package/lib/core/styles/global.js +1 -0
  37. package/lib/core/utils/class-names.d.ts +1 -0
  38. package/lib/core/utils/class-names.js +7 -0
  39. package/lib/markdoc/components/Accordion/Accordion.js +6 -6
  40. package/lib/markdoc/components/Accordion/AccordionGroup.d.ts +2 -1
  41. package/lib/markdoc/components/Accordion/AccordionGroup.js +39 -3
  42. package/lib/markdoc/components/Heading/Heading.js +5 -2
  43. package/lib/markdoc/tags/accordion-group.js +3 -0
  44. package/package.json +2 -2
  45. package/src/components/Accordion/Accordion.tsx +7 -3
  46. package/src/components/Catalog/CatalogEntities.tsx +1 -0
  47. package/src/components/Catalog/CatalogEntity/CatalogEntityRelations/CatalogEntityRelations.tsx +6 -4
  48. package/src/components/Catalog/CatalogViewModeToggle.tsx +2 -17
  49. package/src/components/Filter/FilterInput.tsx +5 -3
  50. package/src/components/JsonViewer/Helpers.tsx +13 -9
  51. package/src/components/MCP/McpConnectionTabs.tsx +32 -10
  52. package/src/components/Markdown/Markdown.tsx +1 -3
  53. package/src/components/Markdown/styles/base-table.ts +8 -0
  54. package/src/components/Markdown/variables.ts +1 -2
  55. package/src/components/Panel/Panel.tsx +7 -3
  56. package/src/components/Search/FilterFields/SearchFilterFieldSelect.tsx +32 -40
  57. package/src/components/Search/SearchAiDialog.tsx +3 -0
  58. package/src/components/Search/SearchItem.tsx +6 -1
  59. package/src/components/Search/variables.ts +1 -0
  60. package/src/components/Select/Select.tsx +12 -6
  61. package/src/components/Sidebar/Sidebar.tsx +2 -6
  62. package/src/core/contexts/AccordionGroupContext.tsx +3 -0
  63. package/src/core/contexts/PageActionsContext.tsx +7 -0
  64. package/src/core/contexts/index.ts +2 -0
  65. package/src/core/hooks/__mocks__/use-theme-hooks.ts +2 -0
  66. package/src/core/hooks/code-walkthrough/use-code-walkthrough-controls.ts +6 -20
  67. package/src/core/hooks/code-walkthrough/use-code-walkthrough-steps.ts +2 -0
  68. package/src/core/hooks/menu/use-menu-item-expanded.ts +1 -0
  69. package/src/core/hooks/use-active-heading.ts +4 -0
  70. package/src/core/hooks/use-connect-mcp-button.ts +8 -2
  71. package/src/core/hooks/use-local-state.ts +1 -0
  72. package/src/core/hooks/use-mobile-menu.ts +11 -3
  73. package/src/core/hooks/use-product-picker.ts +2 -2
  74. package/src/core/hooks/use-tabs.ts +4 -2
  75. package/src/core/hooks/use-telemetry-fallback.ts +2 -0
  76. package/src/core/styles/global.ts +1 -0
  77. package/src/core/utils/class-names.ts +8 -0
  78. package/src/markdoc/components/Accordion/Accordion.tsx +8 -2
  79. package/src/markdoc/components/Accordion/AccordionGroup.tsx +9 -2
  80. package/src/markdoc/components/Heading/Heading.tsx +7 -4
  81. package/src/markdoc/tags/accordion-group.ts +3 -0
@@ -3,6 +3,8 @@ import styled from 'styled-components';
3
3
 
4
4
  import type { JSX, ReactNode } from 'react';
5
5
  import type { TFunction } from '@redocly/theme/core/types';
6
+ import type { AsyncApiRealmUI } from '@redocly/realm-asyncapi-sdk';
7
+ import type { CodeBlockControlsProps } from '@redocly/theme/components/CodeBlock/CodeBlockControls';
6
8
 
7
9
  import { useMCPConfig, useThemeHooks } from '@redocly/theme/core/hooks';
8
10
  import {
@@ -10,6 +12,7 @@ import {
10
12
  generateClaudeDesktopConfig,
11
13
  generateCodexCommand,
12
14
  generateMcpInstallPrompt,
15
+ getBaseDataAttributes,
13
16
  } from '@redocly/theme/core/utils';
14
17
  import { CodeBlock } from '@redocly/theme/components/CodeBlock/CodeBlock';
15
18
  import { ClaudeIcon } from '@redocly/theme/icons/ClaudeIcon/ClaudeIcon';
@@ -47,15 +50,18 @@ const TAB_LABELS: Record<
47
50
 
48
51
  type TabClient = { headline: string; content: ReactNode };
49
52
 
50
- // The report control is docs feedback, not useful on the connection page.
51
- const CODE_BLOCK_HEADER = { controls: { copy: {}, report: { hidden: true } } };
53
+ type SnippetId =
54
+ AsyncApiRealmUI.EventPayload<'com.redocly.mcpConnectionSnippet.copied'>[0]['snippet'];
55
+
56
+ type SnippetHeader = (client: McpConnectionTab, snippet: SnippetId) => CodeBlockControlsProps;
52
57
 
53
58
  export function McpConnectionTabs({
54
59
  options = DEFAULT_TAB_OPTIONS,
55
60
  className,
56
61
  }: McpConnectionTabsProps): JSX.Element | null {
57
- const { useTranslate } = useThemeHooks();
62
+ const { useTranslate, useTelemetry } = useThemeHooks();
58
63
  const { translate } = useTranslate();
64
+ const telemetry = useTelemetry();
59
65
  const { serverName, serverUrl, isMcpDisabled } = useMCPConfig();
60
66
  // The SSR markup of error pages is cached with the build-time origin, so the
61
67
  // tabs remount after hydration to pick up the real browser origin.
@@ -69,6 +75,19 @@ export function McpConnectionTabs({
69
75
 
70
76
  const connection = { serverName, url: serverUrl };
71
77
 
78
+ const snippetHeader: SnippetHeader = (client, snippet) => ({
79
+ controls: {
80
+ copy: {
81
+ onClick: () =>
82
+ telemetry.sendMcpConnectionSnippetCopiedMessage([
83
+ { ...getBaseDataAttributes('mcpConnectionSnippet', 'button'), client, snippet },
84
+ ]),
85
+ },
86
+ // The report control is docs feedback, not useful on the connection page.
87
+ report: { hidden: true },
88
+ },
89
+ });
90
+
72
91
  return (
73
92
  <McpConnectionTabsWrapper
74
93
  key={hydrated ? 'client' : 'ssr'}
@@ -86,7 +105,7 @@ export function McpConnectionTabs({
86
105
  label={translate(translationKey, defaultLabel)}
87
106
  icon={TabIcon ? <TabIcon /> : undefined}
88
107
  >
89
- {renderTabContent(option, connection, translate)}
108
+ {renderTabContent(option, connection, translate, snippetHeader)}
90
109
  </TabItemFragment>
91
110
  );
92
111
  })}
@@ -99,6 +118,7 @@ function renderTabContent(
99
118
  option: McpConnectionTab,
100
119
  connection: { serverName: string; url: string },
101
120
  translate: TFunction,
121
+ snippetHeader: SnippetHeader,
102
122
  ): ReactNode {
103
123
  const terminalHint = (
104
124
  <TabHint>
@@ -106,7 +126,9 @@ function renderTabContent(
106
126
  </TabHint>
107
127
  );
108
128
 
109
- const urlBlock = <CodeBlock lang="text" source={connection.url} header={CODE_BLOCK_HEADER} />;
129
+ const urlBlock = (
130
+ <CodeBlock lang="text" source={connection.url} header={snippetHeader(option, 'chatgpt-app')} />
131
+ );
110
132
 
111
133
  const assistantPromptClient: TabClient = {
112
134
  headline: translate('page.mcpNotAllowed.snippets.prompt.headline', 'Prompt'),
@@ -121,7 +143,7 @@ function renderTabContent(
121
143
  <CodeBlock
122
144
  lang="text"
123
145
  source={generateMcpInstallPrompt(connection)}
124
- header={CODE_BLOCK_HEADER}
146
+ header={snippetHeader(option, 'prompt')}
125
147
  wrapContents
126
148
  />
127
149
  </>
@@ -139,7 +161,7 @@ function renderTabContent(
139
161
  <CodeBlock
140
162
  lang="shell"
141
163
  source={generateClaudeCodeCommand(connection)}
142
- header={CODE_BLOCK_HEADER}
164
+ header={snippetHeader(option, 'claude-code')}
143
165
  wrapContents
144
166
  />
145
167
  </>
@@ -161,7 +183,7 @@ function renderTabContent(
161
183
  <CodeBlock
162
184
  lang="json"
163
185
  source={generateClaudeDesktopConfig(connection)}
164
- header={CODE_BLOCK_HEADER}
186
+ header={snippetHeader(option, 'claude-desktop')}
165
187
  />
166
188
  </>
167
189
  ),
@@ -177,7 +199,7 @@ function renderTabContent(
177
199
  <CodeBlock
178
200
  lang="shell"
179
201
  source={generateCodexCommand(connection)}
180
- header={CODE_BLOCK_HEADER}
202
+ header={snippetHeader(option, 'codex')}
181
203
  wrapContents
182
204
  />
183
205
  </>
@@ -218,7 +240,7 @@ function renderTabContent(
218
240
  null,
219
241
  2,
220
242
  )}
221
- header={CODE_BLOCK_HEADER}
243
+ header={snippetHeader(option, 'json')}
222
244
  />
223
245
  </>
224
246
  );
@@ -167,10 +167,8 @@ export const Markdown = styled.main
167
167
  }
168
168
 
169
169
  hr {
170
- border-top: 1px solid var(--md-hr-border-color);
171
- background-color: var(--md-hr-bg-color);
172
- height: var(--md-hr-height);
173
170
  margin: var(--md-hr-margin-vertical) 0;
171
+ border: var(--md-hr-border);
174
172
  }
175
173
 
176
174
  details:not(${ACCORDION_SELECTOR}) {
@@ -45,6 +45,14 @@ export const markdownBaseTableCss = css`
45
45
  background-color: var(--md-table-header-bg-color);
46
46
  }
47
47
 
48
+ /* hide the header row when every cell is empty; one labelled cell keeps it visible */
49
+ thead:has(th:empty) {
50
+ display: none;
51
+ }
52
+ thead:has(th:not(:empty)) {
53
+ display: table-header-group;
54
+ }
55
+
48
56
  tr {
49
57
  background-color: var(--md-table-stripe-bg-color);
50
58
  }
@@ -60,9 +60,8 @@ export const markdown = css`
60
60
  * @tokens Markdown Horizontal Rule
61
61
  */
62
62
 
63
- --md-hr-bg-color: transparent; // @presenter Color
64
63
  --md-hr-border-color: var(--border-color-secondary); // @presenter Color
65
- --md-hr-height: unset;
64
+ --md-hr-border: 0.5px solid var(--md-hr-border-color); // @presenter Border
66
65
  --md-hr-margin-vertical: 20px; // @presenter Spacing
67
66
 
68
67
  /**
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React, { useState } from 'react';
2
2
  import styled from 'styled-components';
3
3
 
4
4
  import type { BaseSyntheticEvent, PropsWithChildren, ReactNode, JSX } from 'react';
@@ -34,6 +34,12 @@ export function Panel({
34
34
  }: PropsWithChildren<PanelProps>): JSX.Element {
35
35
  const [isExpanded, setExpanded] = useState(expanded);
36
36
  const [animate, setAnimate] = useState(false);
37
+ const [prevExpanded, setPrevExpanded] = useState(expanded);
38
+
39
+ if (prevExpanded !== expanded) {
40
+ setPrevExpanded(expanded);
41
+ setExpanded(expanded);
42
+ }
37
43
 
38
44
  const toggle = ({ target }: BaseSyntheticEvent): void => {
39
45
  if (target instanceof HTMLAnchorElement || !isExpandable) return;
@@ -42,8 +48,6 @@ export function Panel({
42
48
  onToggle?.(!isExpanded);
43
49
  };
44
50
 
45
- useEffect(() => setExpanded(expanded), [expanded]);
46
-
47
51
  return (
48
52
  <PanelWrapper
49
53
  className={className}
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React, { useMemo } from 'react';
2
2
  import styled from 'styled-components';
3
3
 
4
4
  import type { SearchFacet, SearchFacetCount, SearchFilterItem } from '@redocly/theme/core/types';
@@ -27,15 +27,8 @@ export function SearchFilterFieldSelect({
27
27
  const { useFacetQuery, useTranslate } = useThemeHooks();
28
28
  const { translate } = useTranslate();
29
29
  const { searchFacet, setSearchFacet, setSearchFacetQuery } = useFacetQuery(facet.field);
30
- /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
31
- const [selectOptions, setSelectOptions] = useState<any[]>([]);
32
30
  const isMultiple = facet.type === 'multi-select';
33
31
 
34
- useEffect(() => {
35
- setSelectOptions(getSelectOptions());
36
- // eslint-disable-next-line react-hooks/exhaustive-deps
37
- }, [searchFacet, facet]);
38
-
39
32
  const onSearch = (value: string | null) => {
40
33
  if (value === null) {
41
34
  setSearchFacet(null);
@@ -49,38 +42,10 @@ export function SearchFilterFieldSelect({
49
42
  }
50
43
  };
51
44
 
52
- const getSelectOptions = () => {
53
- return searchFacet
54
- ? searchFacet.values.map(facetValueToSelectOption)
55
- : facet.values.map(facetValueToSelectOption);
56
- };
57
-
58
- const facetValueToSelectOption = (facetValue?: SearchFacetCount | string) => {
59
- if (!facetValue) {
60
- return { element: null, value: '' };
61
- }
62
- if (typeof facetValue === 'string') {
63
- return {
64
- element: (
65
- <FilterSelectOptionWrapper>
66
- <FilterSelectOptionText>{facetValue}</FilterSelectOptionText>
67
- </FilterSelectOptionWrapper>
68
- ),
69
- value: facetValue,
70
- };
71
- } else {
72
- const { value, count, isCounterVisible } = facetValue as SearchFacetCount;
73
- return {
74
- element: (
75
- <FilterSelectOptionWrapper>
76
- <FilterSelectOptionText>{value}</FilterSelectOptionText>
77
- {isCounterVisible && <CounterTag>{count}</CounterTag>}
78
- </FilterSelectOptionWrapper>
79
- ),
80
- value: value,
81
- };
82
- }
83
- };
45
+ const selectOptions = useMemo(
46
+ () => (searchFacet ? searchFacet.values : facet.values).map(facetValueToSelectOption),
47
+ [searchFacet, facet],
48
+ );
84
49
 
85
50
  return (
86
51
  <Select
@@ -113,6 +78,33 @@ export function SearchFilterFieldSelect({
113
78
  );
114
79
  }
115
80
 
81
+ function facetValueToSelectOption(facetValue?: SearchFacetCount | string) {
82
+ if (!facetValue) {
83
+ return { element: null, value: '' };
84
+ }
85
+ if (typeof facetValue === 'string') {
86
+ return {
87
+ element: (
88
+ <FilterSelectOptionWrapper>
89
+ <FilterSelectOptionText>{facetValue}</FilterSelectOptionText>
90
+ </FilterSelectOptionWrapper>
91
+ ),
92
+ value: facetValue,
93
+ };
94
+ } else {
95
+ const { value, count, isCounterVisible } = facetValue as SearchFacetCount;
96
+ return {
97
+ element: (
98
+ <FilterSelectOptionWrapper>
99
+ <FilterSelectOptionText>{value}</FilterSelectOptionText>
100
+ {isCounterVisible && <CounterTag>{count}</CounterTag>}
101
+ </FilterSelectOptionWrapper>
102
+ ),
103
+ value: value,
104
+ };
105
+ }
106
+ }
107
+
116
108
  const FilterSelectOptionWrapper = styled.div`
117
109
  width: 100%;
118
110
  display: flex;
@@ -94,6 +94,7 @@ export function SearchAiDialog({
94
94
  if (!initialMessage?.trim().length) {
95
95
  return;
96
96
  }
97
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- appends the initial message into the conversation state lifted to the search root once the dialog mounts
97
98
  setConversation((prev) => [
98
99
  ...prev,
99
100
  { role: AiSearchConversationRole.USER, content: initialMessage },
@@ -105,6 +106,7 @@ export function SearchAiDialog({
105
106
  return;
106
107
  }
107
108
 
109
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- merges the streamed assistant response into the lifted conversation state as chunks arrive
108
110
  setConversation((prev) => {
109
111
  const lastMessage = prev[prev.length - 1];
110
112
  const content: string = response || '';
@@ -146,6 +148,7 @@ export function SearchAiDialog({
146
148
 
147
149
  useEffect(() => {
148
150
  if (error) {
151
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- drops the pending message from the lifted conversation state once the request errors
149
152
  setConversation((prev) => prev.slice(0, -1));
150
153
  }
151
154
  }, [error, setConversation]);
@@ -114,7 +114,12 @@ export function SearchItem({
114
114
  <div>
115
115
  {searchHighlight(parameter.place)}
116
116
  {` → `}
117
- {parameter.path?.length ? searchHighlight(parameter.path?.join(' → ')) + ' → ' : ''}
117
+ {parameter.path?.length ? (
118
+ <>
119
+ {searchHighlight(parameter.path.join(' → '))}
120
+ {` → `}
121
+ </>
122
+ ) : null}
118
123
  {searchHighlight(parameter.name)}
119
124
  </div>
120
125
  <div>{searchHighlight(parameter.description)}</div>
@@ -97,6 +97,7 @@ export const search = css`
97
97
  --search-message-line-height: var(--line-height-lg); // @presenter LineHeight
98
98
  --search-message-text-color: var(--text-color-secondary); // @presenter Color
99
99
  --search-message-gap: var(--spacing-md);
100
+ --search-message-padding: var(--spacing-md);
100
101
 
101
102
  --search-header-buttons-gap: var(--spacing-sm);
102
103
  --search-header-buttons-padding-left: var(--spacing-sm);
@@ -1,4 +1,4 @@
1
- import React, { Fragment, useEffect, useId, useRef, useState } from 'react';
1
+ import React, { Fragment, useId, useRef, useState } from 'react';
2
2
  import styled from 'styled-components';
3
3
 
4
4
  import type { JSX } from 'react';
@@ -68,14 +68,20 @@ export function Select<T>(props: SelectProps<T>): JSX.Element {
68
68
  setDropdownActive(false);
69
69
  });
70
70
 
71
- useEffect(() => {
71
+ const [prevOptions, setPrevOptions] = useState(options);
72
+ const [prevValue, setPrevValue] = useState(value);
73
+ const [prevMultiple, setPrevMultiple] = useState(multiple);
74
+
75
+ if (prevOptions !== options) {
76
+ setPrevOptions(options);
72
77
  setFilteredOptions(options);
73
- }, [options]);
78
+ }
74
79
 
75
- useEffect(() => {
80
+ if (prevValue !== value || prevMultiple !== multiple) {
81
+ setPrevValue(value);
82
+ setPrevMultiple(multiple);
76
83
  setSelectedOptions(getSelectedOptionsFromPropsValue());
77
- // eslint-disable-next-line react-hooks/exhaustive-deps
78
- }, [multiple, value]);
84
+ }
79
85
 
80
86
  const changeSearchValue = (newSearchValue: string | null) => {
81
87
  if (newSearchValue === searchValue) {
@@ -1,4 +1,4 @@
1
- import React, { useEffect } from 'react';
1
+ import React from 'react';
2
2
  import styled from 'styled-components';
3
3
 
4
4
  import type { JSX } from 'react';
@@ -36,11 +36,7 @@ export function Sidebar({
36
36
  menuItemsAnimation,
37
37
  }: SidebarProps): JSX.Element | null {
38
38
  const { isOpen } = useMobileMenu();
39
- const [mappedCollapsed, setMappedCollapsed] = React.useState(collapsed);
40
-
41
- useEffect(() => {
42
- setMappedCollapsed(collapsed && !isOpen);
43
- }, [isOpen, collapsed, setMappedCollapsed]);
39
+ const mappedCollapsed = collapsed && !isOpen;
44
40
 
45
41
  const { sidebar } = useThemeConfig();
46
42
 
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react';
2
+
3
+ export const AccordionGroupContext = createContext<string | null>(null);
@@ -0,0 +1,7 @@
1
+ import { createContext } from 'react';
2
+
3
+ /**
4
+ * Whether the first heading inside the enclosing `Markdown` mounts the page actions.
5
+ * Without a provider `Heading` falls back to the page type in the page metadata.
6
+ */
7
+ export const PageActionsContext = createContext<boolean | null>(null);
@@ -1,7 +1,9 @@
1
1
  export * from './ThemeDataContext';
2
2
  export * from './MarkdownLinkContext';
3
+ export * from './PageActionsContext';
3
4
  export * from './CodeWalkthrough/CodeWalkthroughControlsContext';
4
5
  export * from './CodeWalkthrough/CodeWalkthroughStepsContext';
6
+ export * from './AccordionGroupContext';
5
7
  export * from './CodeSnippetContext';
6
8
  export * from './SearchContext';
7
9
  export * from './Toast/ToastContext';
@@ -18,6 +18,8 @@ export const useThemeHooks = vi.fn(() => ({
18
18
  useTelemetry: vi.fn(() => ({
19
19
  send: vi.fn(),
20
20
  sendCodeSnippetReportedMessage: vi.fn(),
21
+ sendConnectMcpClickedMessage: vi.fn(),
22
+ sendMcpConnectionSnippetCopiedMessage: vi.fn(),
21
23
  sendPageActionsButtonClickedMessage: vi.fn(),
22
24
  sendColorModeSwitchedMessage: vi.fn(),
23
25
  })),
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useMemo, useRef } from 'react';
1
+ import { useState, useEffect, useMemo } from 'react';
2
2
  import { useLocation, useNavigate } from 'react-router';
3
3
 
4
4
  import type {
@@ -50,10 +50,6 @@ export function useCodeWalkthroughControls(
50
50
  const navigate = useNavigate();
51
51
  const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
52
52
 
53
- const filtersRef = useRef(filters);
54
- const inputsRef = useRef(inputs);
55
- const togglesRef = useRef(toggles);
56
-
57
53
  const getInitialState = () => {
58
54
  const state: CodeWalkthroughControlsState = {};
59
55
 
@@ -90,20 +86,11 @@ export function useCodeWalkthroughControls(
90
86
 
91
87
  const [controlsState, setControlsState] = useState(getInitialState);
92
88
 
93
- useEffect(() => {
94
- const sameProps = [
95
- JSON.stringify(filters) === JSON.stringify(filtersRef.current),
96
- JSON.stringify(inputs) === JSON.stringify(inputsRef.current),
97
- JSON.stringify(toggles) === JSON.stringify(togglesRef.current),
98
- ];
99
-
100
- if (sameProps.every(Boolean)) {
101
- return;
102
- }
89
+ const controlsKey = JSON.stringify([filters, inputs, toggles]);
90
+ const [prevControlsKey, setPrevControlsKey] = useState(controlsKey);
103
91
 
104
- filtersRef.current = filters;
105
- inputsRef.current = inputs;
106
- togglesRef.current = toggles;
92
+ if (prevControlsKey !== controlsKey) {
93
+ setPrevControlsKey(controlsKey);
107
94
 
108
95
  const newState = getInitialState();
109
96
 
@@ -120,8 +107,7 @@ export function useCodeWalkthroughControls(
120
107
  });
121
108
 
122
109
  setControlsState(newState);
123
- // eslint-disable-next-line react-hooks/exhaustive-deps
124
- }, [filters, inputs, toggles, enableDeepLink]);
110
+ }
125
111
 
126
112
  const changeControlState = (id: string, value: string | boolean) => {
127
113
  setControlsState((prev) => {
@@ -95,6 +95,7 @@ export function useCodeWalkthroughSteps({
95
95
  });
96
96
  }
97
97
 
98
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- marker geometry is derived from post-layout measurements (offsetTop, clientHeight, window.innerHeight)
98
99
  setMarkers(
99
100
  markers.reduce(
100
101
  (acc, marker, index) => {
@@ -252,6 +253,7 @@ export function useCodeWalkthroughSteps({
252
253
 
253
254
  const rootTopOffset = root.current?.offsetTop ?? 0;
254
255
  if (!activeStep && rootTopOffset <= options.navbarHeight) {
256
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-derived-state -- seeded from root.current.offsetTop, a post-layout measurement
255
257
  setActiveStep(visibleSteps[0]?.id || null);
256
258
  }
257
259
 
@@ -16,6 +16,7 @@ export function useMenuItemExpanded(item: ItemState): [boolean, Dispatch<SetStat
16
16
  const [controlledValue, setControlledValue] = useState(isItemExpanded(item));
17
17
 
18
18
  useEffect(() => {
19
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-derived-state -- deferred a commit on purpose so CSS transitions still run
19
20
  setIsExpanded(controlledValue);
20
21
  }, [controlledValue]);
21
22
 
@@ -286,18 +286,22 @@ export function useActiveHeading(
286
286
  return;
287
287
  }
288
288
  const headers = findHeaders(contentElement);
289
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- headings and the initial active heading come from DOM queries and scroll measurement, available only after layout
289
290
  setHeadingElements(headers);
290
291
 
291
292
  if (headers.length > 0 && !heading) {
292
293
  if (isTopOfPage()) {
294
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- see above
293
295
  setHeading(headers[0].id);
294
296
  } else {
295
297
  const totalHeight = scrollY + window.innerHeight;
296
298
  if (totalHeight >= document.body.scrollHeight - 10) {
299
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- see above
297
300
  setHeading(headers[headers.length - 1]?.id || undefined);
298
301
  } else {
299
302
  const triggerOffset = getTriggerOffset();
300
303
  const activeHeader = findActiveHeaderByVirtualPosition(headers, scrollY, triggerOffset);
304
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-chain-state-updates -- see above
301
305
  setHeading(activeHeader.id);
302
306
  }
303
307
  }
@@ -8,6 +8,7 @@ import { useMCPConfig } from './use-mcp-config';
8
8
  import { ClipboardService } from '../utils/clipboard-service';
9
9
  import { DEFAULT_MCP_OPTIONS } from '../constants/mcp';
10
10
  import { generateClaudeCodeCommand, generateCodexCommand } from '../utils/mcp';
11
+ import { getBaseDataAttributes } from '../utils/telemetry/get-base-data-attributes';
11
12
 
12
13
  export type McpButtonOptions = {
13
14
  options?: MCPOption[];
@@ -27,8 +28,9 @@ const COPIED_RESET_TIMEOUT = 1000;
27
28
  export function useConnectMCPButton({
28
29
  options = DEFAULT_MCP_OPTIONS,
29
30
  }: McpButtonOptions = {}): McpButtonSettings {
30
- const { useTranslate } = useThemeHooks();
31
+ const { useTranslate, useTelemetry } = useThemeHooks();
31
32
  const { translate } = useTranslate();
33
+ const telemetry = useTelemetry();
32
34
  const { serverName, serverUrl, cursorUrl, vscodeUrl } = useMCPConfig();
33
35
  const [copiedOption, setCopiedOption] = useState<MCPOption | null>(null);
34
36
 
@@ -40,6 +42,10 @@ export function useConnectMCPButton({
40
42
 
41
43
  const handleAction = useCallback(
42
44
  (action: MCPOption) => {
45
+ telemetry.sendConnectMcpClickedMessage([
46
+ { ...getBaseDataAttributes('connectMcp', 'button'), option: action },
47
+ ]);
48
+
43
49
  const connection = { serverName, url: serverUrl };
44
50
  const copyTextMap: Partial<Record<MCPOption, string>> = {
45
51
  copy: JSON.stringify(
@@ -66,7 +72,7 @@ export function useConnectMCPButton({
66
72
 
67
73
  window.open(urlMap[action], '_blank');
68
74
  },
69
- [cursorUrl, vscodeUrl, serverUrl, serverName, resetCopied],
75
+ [cursorUrl, vscodeUrl, serverUrl, serverName, resetCopied, telemetry],
70
76
  );
71
77
 
72
78
  const triggerButtonText = useMemo(
@@ -21,6 +21,7 @@ export function useLocalState<T>(key: string, initialValue: T): [T, (value: T) =
21
21
  // then client loads the actual stored value without hydration mismatch
22
22
  useEffect(() => {
23
23
  if (!isBrowser()) return;
24
+ // oxlint-disable-next-line react-you-might-not-need-an-effect/no-derived-state -- localStorage is read after mount so SSR and client hydrate from the same value
24
25
  setValue(getStoredValue(key, initialValue));
25
26
  // eslint-disable-next-line react-hooks/exhaustive-deps
26
27
  }, [key]);
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useState } from 'react';
2
2
  import { useLocation } from 'react-router';
3
3
 
4
4
  import { useModalScrollLock } from './use-modal-scroll-lock';
@@ -6,11 +6,19 @@ import { useModalScrollLock } from './use-modal-scroll-lock';
6
6
  export function useMobileMenu(initialState = false) {
7
7
  const location = useLocation();
8
8
  const [isOpen, setIsOpen] = useState(initialState);
9
+ const locationKey = `${location.pathname}${location.hash}`;
10
+ const [prevLocationKey, setPrevLocationKey] = useState(locationKey);
9
11
 
10
- useEffect(() => setIsOpen(false), [location.pathname, location.hash]);
12
+ if (isOpen && prevLocationKey !== locationKey) {
13
+ setPrevLocationKey(locationKey);
14
+ setIsOpen(false);
15
+ }
11
16
  useModalScrollLock(isOpen);
12
17
 
13
- const openMobileMenu = () => setIsOpen(true);
18
+ const openMobileMenu = () => {
19
+ setPrevLocationKey(locationKey);
20
+ setIsOpen(true);
21
+ };
14
22
  const closeMobileMenu = () => setIsOpen(false);
15
23
 
16
24
  return { isOpen, openMobileMenu, closeMobileMenu };
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router';
4
4
  import { getProductClassName } from '@redocly/theme/components/Product/utils';
5
5
 
6
6
  import { useThemeHooks } from './use-theme-hooks';
7
- import { withPathPrefix, generateBeforeAfterContext } from '../utils';
7
+ import { withPathPrefix, generateBeforeAfterContext, getSafeClassName } from '../utils';
8
8
 
9
9
  export function useProductPicker() {
10
10
  const { useCurrentProduct, useProducts, useTelemetry, useLoadAndNavigate } = useThemeHooks();
@@ -36,7 +36,7 @@ export function useProductPicker() {
36
36
  Array.from(root.classList)
37
37
  .filter((c) => c.startsWith('product-'))
38
38
  .forEach((c) => root.classList.remove(c));
39
- root.classList.add(getProductClassName(freshProduct.name));
39
+ root.classList.add(getSafeClassName(getProductClassName(freshProduct.name)));
40
40
  }
41
41
 
42
42
  loadAndNavigate({ navigate, to: withPathPrefix(freshProduct.link) });
@@ -289,14 +289,16 @@ export const useActiveTab = ({ initialTab, tabsId }: UseActiveTabProps) => {
289
289
  const [activeTab, setActiveTab] = useState<string | undefined>(undefined);
290
290
  const resolvedActiveTab = activeTab ?? initialTab;
291
291
  const prevActiveTabRef = useRef(resolvedActiveTab);
292
+ const isTabResolvedFromHashRef = useRef(false);
292
293
 
293
294
  useEffect(() => {
294
- if (activeTab !== undefined) {
295
+ if (isTabResolvedFromHashRef.current) {
295
296
  return;
296
297
  }
298
+ isTabResolvedFromHashRef.current = true;
297
299
 
298
300
  setActiveTab(getInitialTab({ initialTab, hash: location.hash, tabsId }));
299
- }, [activeTab, initialTab, location.hash, tabsId]);
301
+ }, [initialTab, location.hash, tabsId]);
300
302
 
301
303
  useEffect(() => {
302
304
  const hasActiveTabChanged = prevActiveTabRef.current !== resolvedActiveTab;
@@ -36,6 +36,8 @@ export const useTelemetryFallback = () => ({
36
36
  sendProductPickerChangedMessage: () => {},
37
37
  sendLogoClickedMessage: () => {},
38
38
  sendCodeSnippetReportedMessage: () => {},
39
+ sendConnectMcpClickedMessage: () => {},
40
+ sendMcpConnectionSnippetCopiedMessage: () => {},
39
41
  sendMobileMenuClosedMessage: () => {},
40
42
  sendMobileMenuOpenedMessage: () => {},
41
43
  sendPageViewedMessage: () => {},