From 55de7aa0768cddc44d885c0e7ecc950ed66e45e5 Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Sun, 13 Sep 2026 20:01:11 +0300 Subject: [PATCH 1/5] feat(ui): implement deep linking in CodeTabs via URL hash --- .../src/Common/CodeTabs/index.tsx | 2 +- packages/ui-components/src/MDX/CodeTabs.tsx | 50 +++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/ui-components/src/Common/CodeTabs/index.tsx b/packages/ui-components/src/Common/CodeTabs/index.tsx index 12ff05973037e..910fda8abfc58 100644 --- a/packages/ui-components/src/Common/CodeTabs/index.tsx +++ b/packages/ui-components/src/Common/CodeTabs/index.tsx @@ -6,7 +6,7 @@ import styles from './index.module.css'; type CodeTabsProps = Pick< ComponentProps, - 'tabs' | 'defaultValue' | 'children' | 'addons' + 'tabs' | 'defaultValue' | 'value' | 'onValueChange' | 'children' | 'addons' >; const CodeTabs: FC = ({ ...props }) => ( diff --git a/packages/ui-components/src/MDX/CodeTabs.tsx b/packages/ui-components/src/MDX/CodeTabs.tsx index a4092ae6ece73..ccb1bacade6f8 100644 --- a/packages/ui-components/src/MDX/CodeTabs.tsx +++ b/packages/ui-components/src/MDX/CodeTabs.tsx @@ -1,5 +1,7 @@ +'use client'; + import * as TabsPrimitive from '@radix-ui/react-tabs'; -import { useMemo } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; import CodeTabs from '#ui/Common/CodeTabs'; @@ -10,6 +12,7 @@ type MDXCodeTabsProps = { languages: string; displayNames?: string; defaultTab?: string; + groupId?: string; }; const NAME_OVERRIDES: Record = { @@ -21,8 +24,12 @@ const MDXCodeTabs: FC = ({ displayNames: rawDisplayNames, children: codes, defaultTab = '0', + groupId, ...props }) => { + const id = useId(); + const prefix = groupId ? `tab-${groupId}` : `tab-${id}`; + const { tabs, languages } = useMemo(() => { const occurrences: Record = {}; @@ -43,17 +50,54 @@ const MDXCodeTabs: FC = ({ return { key: `${language}-${index}`, + anchorId: `${prefix}-${language}-${index}`.replace( + /[^a-zA-Z0-9-_]/g, + '-' + ), label, }; }); return { tabs, languages }; - }, [rawLanguages, rawDisplayNames]); + }, [rawLanguages, rawDisplayNames, prefix]); + + const [activeTab, setActiveTab] = useState(() => { + if (typeof window !== 'undefined' && window.location.hash) { + const hash = window.location.hash.slice(1); + const matched = tabs.find(t => t.anchorId === hash); + if (matched) { + return matched.key; + } + } + return tabs[Number(defaultTab)]?.key ?? tabs[0].key; + }); + + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash.slice(1); + const matched = tabs.find(t => t.anchorId === hash); + if (matched) { + setActiveTab(matched.key); + } + }; + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, [tabs]); + + const handleValueChange = (value: string) => { + setActiveTab(value); + const matched = tabs.find(t => t.key === value); + if (matched) { + window.history.replaceState(null, '', `#${matched.anchorId}`); + } + }; return ( {languages.map((_, index) => ( From 9b3d73989e9d3b0a3856a0810c9857d9f69cca90 Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Tue, 15 Sep 2026 18:52:44 +0300 Subject: [PATCH 2/5] feat(site,ui): add HashContext and HashProvider for global URL state --- apps/site/app/[locale]/layout.tsx | 9 ++-- apps/site/hooks/useHash.ts | 44 +++++++++++++++++++ apps/site/providers/hashProvider.tsx | 13 ++++++ .../src/contexts/HashContext.tsx | 15 +++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 apps/site/hooks/useHash.ts create mode 100644 apps/site/providers/hashProvider.tsx create mode 100644 packages/ui-components/src/contexts/HashContext.tsx diff --git a/apps/site/app/[locale]/layout.tsx b/apps/site/app/[locale]/layout.tsx index 3e1c358b1103f..6f46973abc806 100644 --- a/apps/site/app/[locale]/layout.tsx +++ b/apps/site/app/[locale]/layout.tsx @@ -6,6 +6,7 @@ import { getLocale } from 'next-intl/server'; import BaseLayout from '#site/layouts/Base'; import { IBM_PLEX_MONO, OPEN_SANS } from '#site/next.fonts'; +import { HashProvider } from '#site/providers/hashProvider'; import { ThemeProvider } from '#site/providers/themeProvider'; import type { FC, PropsWithChildren } from 'react'; @@ -29,9 +30,11 @@ const RootLayout: FC = async ({ children }) => { > - - {children} - + + + {children} + + void>(); +let isListening = false; + +const onHashChange = () => { + listeners.forEach(rerender => rerender()); +}; + +const subscribe = (callback: () => void) => { + listeners.add(callback); + if (!isListening && typeof window !== 'undefined') { + window.addEventListener('hashchange', onHashChange); + isListening = true; + } + return () => { + listeners.delete(callback); + if (listeners.size === 0 && typeof window !== 'undefined') { + window.removeEventListener('hashchange', onHashChange); + isListening = false; + } + }; +}; + +const getSnapshot = () => + typeof window !== 'undefined' ? window.location.hash.slice(1) : ''; +const getServerSnapshot = () => ''; + +const useHash = () => { + const hash = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const setHash = (newHash: string) => { + if (typeof window !== 'undefined') { + window.history.replaceState(null, '', `#${newHash}`); + onHashChange(); + } + }; + + return [hash, setHash] as const; +}; + +export default useHash; diff --git a/apps/site/providers/hashProvider.tsx b/apps/site/providers/hashProvider.tsx new file mode 100644 index 0000000000000..ad4439dfb8d01 --- /dev/null +++ b/apps/site/providers/hashProvider.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { HashContext } from '@node-core/ui-components/contexts/HashContext'; + +import useHash from '#site/hooks/useHash'; + +import type { FC, PropsWithChildren } from 'react'; + +export const HashProvider: FC = ({ children }) => { + const [hash, setHash] = useHash(); + + return {children}; +}; diff --git a/packages/ui-components/src/contexts/HashContext.tsx b/packages/ui-components/src/contexts/HashContext.tsx new file mode 100644 index 0000000000000..7a738fd53eea7 --- /dev/null +++ b/packages/ui-components/src/contexts/HashContext.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { createContext, use } from 'react'; + +type HashContextType = { + hash: string; + setHash: (newHash: string) => void; +}; + +export const HashContext = createContext({ + hash: '', + setHash: () => {}, +}); + +export const useHashContext = () => use(HashContext); From 6707e8c7d5c1c5bf1012146d56f63b07cc8e8891 Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Tue, 15 Sep 2026 18:53:57 +0300 Subject: [PATCH 3/5] refactor(ui): isolate window hash logic from CodeTabs --- .../src/MDX/CodeTabs/CodeTabsWithHash.tsx | 52 +++++++++++++++++++ .../MDX/{CodeTabs.tsx => CodeTabs/index.tsx} | 48 +++-------------- 2 files changed, 60 insertions(+), 40 deletions(-) create mode 100644 packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx rename packages/ui-components/src/MDX/{CodeTabs.tsx => CodeTabs/index.tsx} (58%) diff --git a/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx b/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx new file mode 100644 index 0000000000000..a4f7838745727 --- /dev/null +++ b/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +import CodeTabs from '#ui/Common/CodeTabs'; +import { useHashContext } from '#ui/contexts/HashContext'; + +import type { ComponentProps, FC } from 'react'; + +type CodeTabsWithHashProps = Omit, 'tabs'> & { + tabs: Array<{ key: string; label: string; anchorId: string }>; +}; + +const CodeTabsWithHash: FC = ({ + tabs, + defaultValue, + ...props +}) => { + const { hash, setHash } = useHashContext(); + const [activeTab, setActiveTab] = useState(defaultValue); + const [prevHash, setPrevHash] = useState(hash); + + if (hash !== prevHash) { + setPrevHash(hash); + const matched = tabs.find(t => t.anchorId === hash); + if (matched && matched.key !== activeTab) { + setActiveTab(matched.key); + } + } + + const handleValueChange = useCallback( + (value: string) => { + setActiveTab(value); + const matched = tabs.find(t => t.key === value); + if (matched) { + setHash(matched.anchorId); + } + }, + [tabs, setHash] + ); + + return ( + + ); +}; + +export default CodeTabsWithHash; diff --git a/packages/ui-components/src/MDX/CodeTabs.tsx b/packages/ui-components/src/MDX/CodeTabs/index.tsx similarity index 58% rename from packages/ui-components/src/MDX/CodeTabs.tsx rename to packages/ui-components/src/MDX/CodeTabs/index.tsx index ccb1bacade6f8..b2af7bb20ef0d 100644 --- a/packages/ui-components/src/MDX/CodeTabs.tsx +++ b/packages/ui-components/src/MDX/CodeTabs/index.tsx @@ -1,12 +1,12 @@ -'use client'; - import * as TabsPrimitive from '@radix-ui/react-tabs'; -import { useEffect, useId, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import CodeTabs from '#ui/Common/CodeTabs'; import type { FC, ReactElement } from 'react'; +import CodeTabsWithHash from './CodeTabsWithHash'; + type MDXCodeTabsProps = { children: Array>; languages: string; @@ -27,8 +27,7 @@ const MDXCodeTabs: FC = ({ groupId, ...props }) => { - const id = useId(); - const prefix = groupId ? `tab-${groupId}` : `tab-${id}`; + const prefix = groupId ?? 'tab'; const { tabs, languages } = useMemo(() => { const occurrences: Record = {}; @@ -61,43 +60,12 @@ const MDXCodeTabs: FC = ({ return { tabs, languages }; }, [rawLanguages, rawDisplayNames, prefix]); - const [activeTab, setActiveTab] = useState(() => { - if (typeof window !== 'undefined' && window.location.hash) { - const hash = window.location.hash.slice(1); - const matched = tabs.find(t => t.anchorId === hash); - if (matched) { - return matched.key; - } - } - return tabs[Number(defaultTab)]?.key ?? tabs[0].key; - }); - - useEffect(() => { - const handleHashChange = () => { - const hash = window.location.hash.slice(1); - const matched = tabs.find(t => t.anchorId === hash); - if (matched) { - setActiveTab(matched.key); - } - }; - - window.addEventListener('hashchange', handleHashChange); - return () => window.removeEventListener('hashchange', handleHashChange); - }, [tabs]); - - const handleValueChange = (value: string) => { - setActiveTab(value); - const matched = tabs.find(t => t.key === value); - if (matched) { - window.history.replaceState(null, '', `#${matched.anchorId}`); - } - }; + const Component = groupId ? CodeTabsWithHash : CodeTabs; return ( - {languages.map((_, index) => ( @@ -109,7 +77,7 @@ const MDXCodeTabs: FC = ({ {codes[index]} ))} - + ); }; From 15d5e2aa2122e6b77a057a124ca6a7056f95f97f Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Tue, 15 Sep 2026 23:27:25 +0300 Subject: [PATCH 4/5] chore: improve code documentation for useHash hook --- apps/site/hooks/useHash.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/site/hooks/useHash.ts b/apps/site/hooks/useHash.ts index d771455f09b11..ff6b852b20140 100644 --- a/apps/site/hooks/useHash.ts +++ b/apps/site/hooks/useHash.ts @@ -29,6 +29,7 @@ const getSnapshot = () => const getServerSnapshot = () => ''; const useHash = () => { + // Works in both client and server environments, provides the initial value for the store during server rendering and hydration avoiding hydration mismatches const hash = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const setHash = (newHash: string) => { From aaba3ab77f0ce7bd74bb44501ed6440d39d39cce Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Wed, 16 Sep 2026 00:10:33 +0300 Subject: [PATCH 5/5] feat(ui): use native DOM id for CodeTabs scrolling --- packages/ui-components/src/Common/Tabs/index.tsx | 2 ++ .../src/MDX/CodeTabs/CodeTabsWithHash.tsx | 8 ++++---- packages/ui-components/src/MDX/CodeTabs/index.tsx | 11 ++++------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/ui-components/src/Common/Tabs/index.tsx b/packages/ui-components/src/Common/Tabs/index.tsx index bd2f769b596a6..c20e40eeaccf2 100644 --- a/packages/ui-components/src/Common/Tabs/index.tsx +++ b/packages/ui-components/src/Common/Tabs/index.tsx @@ -7,6 +7,7 @@ import styles from './index.module.css'; type Tab = { key: string; + id?: string; label: string; secondaryLabel?: string; value?: string; @@ -34,6 +35,7 @@ const Tabs: FC> = ({ {tabs.map(tab => ( diff --git a/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx b/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx index a4f7838745727..0794ded34a742 100644 --- a/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx +++ b/packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx @@ -8,7 +8,7 @@ import { useHashContext } from '#ui/contexts/HashContext'; import type { ComponentProps, FC } from 'react'; type CodeTabsWithHashProps = Omit, 'tabs'> & { - tabs: Array<{ key: string; label: string; anchorId: string }>; + tabs: Array<{ key: string; label: string; id?: string }>; }; const CodeTabsWithHash: FC = ({ @@ -22,7 +22,7 @@ const CodeTabsWithHash: FC = ({ if (hash !== prevHash) { setPrevHash(hash); - const matched = tabs.find(t => t.anchorId === hash); + const matched = tabs.find(t => t.id === hash); if (matched && matched.key !== activeTab) { setActiveTab(matched.key); } @@ -32,8 +32,8 @@ const CodeTabsWithHash: FC = ({ (value: string) => { setActiveTab(value); const matched = tabs.find(t => t.key === value); - if (matched) { - setHash(matched.anchorId); + if (matched?.id) { + setHash(matched.id); } }, [tabs, setHash] diff --git a/packages/ui-components/src/MDX/CodeTabs/index.tsx b/packages/ui-components/src/MDX/CodeTabs/index.tsx index b2af7bb20ef0d..1042bcaff57ed 100644 --- a/packages/ui-components/src/MDX/CodeTabs/index.tsx +++ b/packages/ui-components/src/MDX/CodeTabs/index.tsx @@ -27,8 +27,6 @@ const MDXCodeTabs: FC = ({ groupId, ...props }) => { - const prefix = groupId ?? 'tab'; - const { tabs, languages } = useMemo(() => { const occurrences: Record = {}; @@ -49,16 +47,15 @@ const MDXCodeTabs: FC = ({ return { key: `${language}-${index}`, - anchorId: `${prefix}-${language}-${index}`.replace( - /[^a-zA-Z0-9-_]/g, - '-' - ), label, + id: + groupId && + `${groupId}-${language}-${index}`.replace(/[^a-zA-Z0-9-_]/g, '-'), }; }); return { tabs, languages }; - }, [rawLanguages, rawDisplayNames, prefix]); + }, [rawLanguages, rawDisplayNames, groupId]); const Component = groupId ? CodeTabsWithHash : CodeTabs;