diff --git a/jest.polyfills.js b/jest.polyfills.js
index a9ee2ed678..a705b3e6a6 100644
--- a/jest.polyfills.js
+++ b/jest.polyfills.js
@@ -3,6 +3,13 @@ const { TextEncoder, TextDecoder } = require('node:util');
Reflect.set(globalThis, 'TextEncoder', TextEncoder);
Reflect.set(globalThis, 'TextDecoder', TextDecoder);
+// Polyfill setImmediate and clearImmediate for undici
+// undici uses these Node.js-specific timers internally, but they don't exist in jsdom
+const { setImmediate: nodeSetImmediate, clearImmediate: nodeClearImmediate } = require('node:timers');
+
+Reflect.set(globalThis, 'setImmediate', nodeSetImmediate);
+Reflect.set(globalThis, 'clearImmediate', nodeClearImmediate);
+
const { Blob } = require('node:buffer');
const { fetch, Request, Response, Headers, FormData } = require('undici');
diff --git a/src/components/Layout/LeftSidebar.tsx b/src/components/Layout/LeftSidebar.tsx
index 1bc7d075ac..0574666f77 100644
--- a/src/components/Layout/LeftSidebar.tsx
+++ b/src/components/Layout/LeftSidebar.tsx
@@ -199,7 +199,7 @@ const LeftSidebar = ({ className, inHeader = false }: LeftSidebarProps) => {
type="multiple"
value={openProducts}
className={cn(
- 'bg-neutral-000 dark:bg-neutral-1300 overflow-y-auto',
+ 'bg-neutral-000 dark:bg-neutral-1300 overflow-x-hidden overflow-y-auto',
inHeader
? 'w-full h-[calc(100dvh-64px-128px)]'
: [
diff --git a/src/components/Layout/MDXWrapper.test.tsx b/src/components/Layout/MDXWrapper.test.tsx
index edb268741d..27b509bb59 100644
--- a/src/components/Layout/MDXWrapper.test.tsx
+++ b/src/components/Layout/MDXWrapper.test.tsx
@@ -1,6 +1,6 @@
-import React, { ReactNode } from 'react';
+import { ReactNode } from 'react';
import { WindowLocation } from '@reach/router';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { Helmet } from 'react-helmet';
import If from './mdx/If';
@@ -66,7 +66,36 @@ jest.mock('@ably/ui/core/Code', () => {
};
});
+// Mock Radix UI Tooltip to avoid act() warnings from async state updates
+jest.mock('@radix-ui/react-tooltip', () => ({
+ Provider: ({ children }: { children: ReactNode }) =>
@@ -276,13 +305,24 @@ describe('MDXWrapper structured data', () => {
template: 'mdx' as const,
},
});
+
+ // Mock fetch to prevent async issues with jsdom teardown
+ global.fetch = jest.fn(() => Promise.reject(new Error('Markdown not available'))) as jest.Mock;
+
+ // Suppress console.error for expected fetch failures
+ jest.spyOn(console, 'error').mockImplementation((message) => {
+ if (typeof message === 'string' && message.includes('Failed to fetch markdown')) {
+ return;
+ }
+ });
});
afterEach(() => {
jest.clearAllMocks();
+ jest.restoreAllMocks();
});
- it('does not generate structured data when only one language is present', () => {
+ it('does not generate structured data when only one language is present', async () => {
render(
@@ -291,13 +331,18 @@ describe('MDXWrapper structured data', () => {
,
);
+ // Wait for any async operations to settle
+ await waitFor(() => {
+ expect(screen.getByText('Test content')).toBeInTheDocument();
+ });
+
const helmet = Helmet.peek();
const jsonLdScript = helmet.scriptTags?.find((tag: { type?: string }) => tag.type === 'application/ld+json');
expect(jsonLdScript).toBeUndefined();
});
- it('generates TechArticle structured data with multiple languages', () => {
+ it('generates TechArticle structured data with multiple languages', async () => {
mockUseLayoutContext.mockReturnValue({
activePage: {
product: 'pubsub',
@@ -320,6 +365,11 @@ describe('MDXWrapper structured data', () => {
,
);
+ // Wait for any async operations to settle
+ await waitFor(() => {
+ expect(screen.getByText('Test content')).toBeInTheDocument();
+ });
+
const helmet = Helmet.peek();
const jsonLdScript = helmet.scriptTags?.find((tag: { type?: string }) => tag.type === 'application/ld+json');
diff --git a/src/components/Layout/mdx/PageHeader.test.tsx b/src/components/Layout/mdx/PageHeader.test.tsx
new file mode 100644
index 0000000000..20ded4a350
--- /dev/null
+++ b/src/components/Layout/mdx/PageHeader.test.tsx
@@ -0,0 +1,151 @@
+import React, { ReactNode } from 'react';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+import { PageHeader } from './PageHeader';
+
+const mockUseLayoutContext = jest.fn(() => ({
+ activePage: {
+ language: 'javascript',
+ languages: ['javascript'],
+ product: 'pubsub',
+ page: {
+ name: 'Test Page',
+ link: '/docs/test-page',
+ },
+ tree: [],
+ template: 'mdx' as const,
+ },
+}));
+
+// Mock the layout context
+jest.mock('src/contexts/layout-context', () => ({
+ useLayoutContext: () => mockUseLayoutContext(),
+}));
+
+// Mock useLocation from @reach/router
+jest.mock('@reach/router', () => ({
+ useLocation: () => ({ pathname: '/docs/test-page' }),
+}));
+
+// Mock Radix UI Tooltip to avoid act() warnings from async state updates
+jest.mock('@radix-ui/react-tooltip', () => ({
+ Provider: ({ children }: { children: ReactNode }) =>
{children}
,
+ Root: ({ children }: { children: ReactNode }) =>
{children}
,
+ Trigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) =>
+ asChild ? children :
{children}
,
+ Portal: ({ children }: { children: ReactNode }) =>
{children}
,
+ Content: ({ children }: { children: ReactNode }) =>
{children}
,
+}));
+
+// Mock Icon component
+jest.mock('@ably/ui/core/Icon', () => {
+ return {
+ __esModule: true,
+ default: ({ name, size }: { name: string; size: string }) => (
+
+ {name}
+
+ ),
+ };
+});
+
+// Mock track function
+jest.mock('@ably/ui/core/insights', () => ({
+ track: jest.fn(),
+}));
+
+// Mock LanguageSelector to avoid its complexity
+jest.mock('../LanguageSelector', () => ({
+ LanguageSelector: () =>
Language Selector
,
+}));
+
+describe('PageHeader Markdown buttons', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ jest.clearAllTimers();
+ });
+
+ it('shows Markdown copy and view buttons when markdown content is available', async () => {
+ const mockMarkdownContent = '# Mock markdown content\n\nThis is a test.';
+
+ // Mock successful fetch response
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: true,
+ headers: {
+ get: (name: string) => (name === 'Content-Type' ? 'text/markdown' : null),
+ },
+ text: () => Promise.resolve(mockMarkdownContent),
+ } as Response),
+ );
+
+ // Mock clipboard API
+ const mockWriteText = jest.fn();
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: mockWriteText,
+ },
+ });
+
+ // Use fake timers to control the setTimeout in resetCopyTooltip
+ jest.useFakeTimers();
+
+ render(
);
+
+ // Wait for the fetch to complete and state to update
+ await screen.findByText('Markdown');
+
+ // Check that the Copy button is present
+ const copyButton = screen.getByLabelText('Copy Markdown');
+ expect(copyButton).toBeInTheDocument();
+
+ // Check that the View link is present
+ const viewLink = screen.getByRole('link', { name: /icon-gui-eye-outline/i });
+ expect(viewLink).toBeInTheDocument();
+ expect(viewLink).toHaveAttribute('href', '/docs/test-page.md');
+
+ // Click the copy button and verify clipboard interaction
+ act(() => {
+ fireEvent.click(copyButton);
+ });
+
+ expect(mockWriteText).toHaveBeenCalledWith(mockMarkdownContent);
+ expect(mockWriteText).toHaveBeenCalledTimes(1);
+
+ // Clean up timers
+ act(() => {
+ jest.runOnlyPendingTimers();
+ });
+ jest.useRealTimers();
+ });
+
+ it('does not show Markdown buttons when markdown content is null', async () => {
+ // Suppress console.error for this test since we're testing a failure case
+ const originalError = console.error;
+ console.error = jest.fn();
+
+ // Mock failed fetch response
+ global.fetch = jest.fn(() =>
+ Promise.resolve({
+ ok: false,
+ status: 404,
+ } as Response),
+ );
+
+ render(
);
+
+ // Wait a bit for any async updates
+ await new Promise
((resolve) => setTimeout(resolve, 100));
+
+ // Check that the Markdown section is not present
+ expect(screen.queryByText('Markdown')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Copy Markdown')).not.toBeInTheDocument();
+
+ // Restore console.error
+ console.error = originalError;
+ });
+});
diff --git a/src/components/Layout/mdx/PageHeader.tsx b/src/components/Layout/mdx/PageHeader.tsx
index e07fffe386..cf03b1d78d 100644
--- a/src/components/Layout/mdx/PageHeader.tsx
+++ b/src/components/Layout/mdx/PageHeader.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from '@reach/router';
import * as Tooltip from '@radix-ui/react-tooltip';
import cn from '@ably/ui/core/utils/cn';
@@ -23,6 +23,7 @@ export const PageHeader: React.FC = ({ title, intro }) => {
const location = useLocation();
const [copyTooltipOpen, setCopyTooltipOpen] = useState(false);
const [copyTooltipContent, setCopyTooltipContent] = useState('Copy');
+ const [markdownContent, setMarkdownContent] = useState(null);
const llmLinks = useMemo(() => {
const prompt = `Tell me more about ${product ? productData[product]?.nav.name : 'Ably'}'s '${page.name}' feature from https://ably.com${page.link}${language ? ` for ${languageInfo[language]?.label}` : ''}`;
@@ -44,27 +45,70 @@ export const PageHeader: React.FC = ({ title, intro }) => {
[activePage.languages, product],
);
- const handleCopyMarkdown = useCallback(async () => {
- try {
- const response = await fetch(`${location.pathname}.md`);
+ useEffect(() => {
+ const abortController = new AbortController();
+ let isMounted = true;
- if (!response.ok) {
- throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`);
- }
+ const fetchMarkdown = async () => {
+ try {
+ const response = await fetch(`${location.pathname}.md`, {
+ signal: abortController.signal,
+ });
+
+ if (!isMounted) {
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`);
+ }
+
+ const contentType = response.headers.get('Content-Type');
+ if (!contentType || !contentType.includes('text/markdown')) {
+ throw new Error(`Invalid content type: expected text/markdown, got ${contentType}`);
+ }
- const contentType = response.headers.get('Content-Type');
- if (!contentType || !contentType.includes('text/markdown')) {
- throw new Error(`Invalid content type: expected text/markdown, got ${contentType}`);
+ const content = await response.text();
+
+ // Only update state if component is still mounted
+ if (isMounted) {
+ setMarkdownContent(content);
+ }
+ } catch (error) {
+ // Ignore all errors if component is unmounted or if it's an abort error
+ if (!isMounted || (error instanceof Error && error.name === 'AbortError')) {
+ return;
+ }
+ console.error('Failed to fetch markdown:', error);
+ setMarkdownContent(null);
}
+ };
+
+ fetchMarkdown();
+
+ return () => {
+ isMounted = false;
+ abortController.abort();
+ };
+ }, [location.pathname]);
+
+ const resetCopyTooltip = useCallback(() => {
+ setCopyTooltipOpen(true);
+ setTimeout(() => {
+ setCopyTooltipOpen(false);
+ setTimeout(() => setCopyTooltipContent('Copy'), 150);
+ }, 2000);
+ }, []);
- const content = await response.text();
- await navigator.clipboard.writeText(content);
+ const handleCopyMarkdown = () => {
+ if (!markdownContent) {
+ return;
+ }
+
+ try {
+ navigator.clipboard.writeText(markdownContent);
setCopyTooltipContent('Copied!');
- setCopyTooltipOpen(true);
- setTimeout(() => {
- setCopyTooltipOpen(false);
- setTimeout(() => setCopyTooltipContent('Copy'), 150);
- }, 2000);
+ resetCopyTooltip();
track('markdown_copy_link_clicked', {
location: location.pathname,
@@ -72,13 +116,9 @@ export const PageHeader: React.FC = ({ title, intro }) => {
} catch (error) {
console.error('Failed to copy markdown:', error);
setCopyTooltipContent('Error!');
- setCopyTooltipOpen(true);
- setTimeout(() => {
- setCopyTooltipOpen(false);
- setTimeout(() => setCopyTooltipContent('Copy'), 150);
- }, 2000);
+ resetCopyTooltip();
}
- }, [location.pathname]);
+ };
return (
@@ -97,39 +137,45 @@ export const PageHeader: React.FC
= ({ title, intro }) => {
)}
-
+ {markdownContent && (
+
+ )}
Open in
{llmLinks.map(({ model, label, icon, link }) => (
diff --git a/src/components/Layout/utils/styles.ts b/src/components/Layout/utils/styles.ts
index 40b060eac2..7e1ffcb1e4 100644
--- a/src/components/Layout/utils/styles.ts
+++ b/src/components/Layout/utils/styles.ts
@@ -13,6 +13,6 @@ export const iconButtonClassName = cn(secondaryButtonClassName, 'w-9 p-0');
export const interactiveButtonClassName = cn(
'flex items-center justify-center bg-neutral-000 dark:bg-neutral-1300 hover:bg-neutral-100 dark:hover:bg-neutral-1200 active:bg-neutral-200 dark:active:bg-neutral-1100 cursor-pointer p-1.5',
- 'text-neutral-900 dark:text-neutral-400 hover:text-neutral-1300 dark:hover:text-neutral-000',
+ 'text-neutral-900 dark:text-neutral-400 hover:text-neutral-1300 dark:hover:text-neutral-000 disabled:text-gui-unavailable dark:disabled:text-gui-unavailable-dark disabled:pointer-events-none',
'focus-base rounded-lg transition-colors',
);