Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/site/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,9 +30,11 @@ const RootLayout: FC<PropsWithChildren> = async ({ children }) => {
>
<body suppressHydrationWarning>
<NextIntlClientProvider>
<ThemeProvider>
<BaseLayout>{children}</BaseLayout>
</ThemeProvider>
<HashProvider>
<ThemeProvider>
<BaseLayout>{children}</BaseLayout>
</ThemeProvider>
</HashProvider>
</NextIntlClientProvider>

<a
Expand Down
45 changes: 45 additions & 0 deletions apps/site/hooks/useHash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client';

import { useSyncExternalStore } from 'react';

const listeners = new Set<() => void>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, listeners itself should be a top-level cache, not a top level const

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I just want to make this hook as a general hook. In our senario, we don't need them as it'll contain only the provider. So I could remove them and just dispatch an event like this:

window.dispatchEvent(new Event('hashchange'));

Instead of using a function that iterates over the set listeners, this keeps the hook clean and focused only on returning hash and setHash without any top-level vars, WDUT?

let isListening = false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we not use a top level let? Use React APIs such a memo() or cache()


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 = () =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't use window APIs on ui-components -- use context providers that UI Components use (I guess there's a global one? Like client-context or server-context?) -- otherwise probably have that being passed? I'm unsure if UI components should have environment specific wars. Maybe instead of window.location use at least globalThis or self or have Location be passed from downstream?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah you already have a Provider, have the Provider have a prop of location={Location}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(and ensure doc-kit and Next.js can pass that with their Navigation/Router APIs natively, or instead of Location they pass History, idk

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, I thought of it, but the location on the server will not contain the hash in the URL. This means the server can't read the initial hash to pass it to the Provider, causing an hydration mismatch error. Then we will need to handle it in the provider like this:

const [hash, setHash] = useState('');
useEffect(() => {
  setHash(location.hash);
}, [location.hash]);

and that causes a not needed rerendering. That's why I used the new hook useSyncExternalStore that lets us handle client and server natively without errors or another rendering.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't need to be in the server. But location must come from downstream, not from ui-components.

@ovflowd ovflowd Oct 1, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, you don't need to use useState you can use useRef but again, you can store that / retrieve that directly within a React Provider.

@moshams272 moshams272 Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think there is a small confusion. Both useHash and the provider actually live in apps/site (the downstream), not in ui-components :)

The ui-component only uses the Context and is completely unaware of window as you taught me before

typeof window !== 'undefined' ? window.location.hash.slice(1) : '';
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) => {
if (typeof window !== 'undefined') {
window.history.replaceState(null, '', `#${newHash}`);
onHashChange();
}
};

return [hash, setHash] as const;
};

export default useHash;
13 changes: 13 additions & 0 deletions apps/site/providers/hashProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren> = ({ children }) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Have Location be passed to the HashProvider ;)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, I thought of it, but the location on the server will not contain the hash in the URL. This means the server can't read the initial hash to pass it to the Provider, causing an hydration mismatch error. Then we will need to handle it in the provider like this:

const [hash, setHash] = useState('');
useEffect(() => {
  setHash(location.hash);
}, [location.hash]);

and that causes a not needed rerendering. That's why I used the new hook useSyncExternalStore that lets us handle client and server natively without errors or another rendering.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replied your other comment

const [hash, setHash] = useHash();

return <HashContext value={{ hash, setHash }}>{children}</HashContext>;
};
2 changes: 1 addition & 1 deletion packages/ui-components/src/Common/CodeTabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import styles from './index.module.css';

type CodeTabsProps = Pick<
ComponentProps<typeof Tabs>,
'tabs' | 'defaultValue' | 'children' | 'addons'
'tabs' | 'defaultValue' | 'value' | 'onValueChange' | 'children' | 'addons'
>;

const CodeTabs: FC<CodeTabsProps> = ({ ...props }) => (
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-components/src/Common/Tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import styles from './index.module.css';

type Tab = {
key: string;
id?: string;
label: string;
secondaryLabel?: string;
value?: string;
Expand Down Expand Up @@ -34,6 +35,7 @@ const Tabs: FC<PropsWithChildren<TabsProps>> = ({
{tabs.map(tab => (
<TabsPrimitive.Trigger
key={tab.key}
id={tab.id}
value={tab.value ?? tab.key}
className={classNames(styles.tabsTrigger, triggerClassName)}
>
Expand Down
52 changes: 52 additions & 0 deletions packages/ui-components/src/MDX/CodeTabs/CodeTabsWithHash.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentProps<typeof CodeTabs>, 'tabs'> & {
tabs: Array<{ key: string; label: string; id?: string }>;
};

const CodeTabsWithHash: FC<CodeTabsWithHashProps> = ({
tabs,
defaultValue,
...props
}) => {
const { hash, setHash } = useHashContext();
const [activeTab, setActiveTab] = useState(defaultValue);
const [prevHash, setPrevHash] = useState(hash);

if (hash !== prevHash) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be on the root of a Component, that's a big no-go. Use an Effect.

setPrevHash(hash);
const matched = tabs.find(t => t.id === 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?.id) {
setHash(matched.id);
}
},
[tabs, setHash]
);

return (
<CodeTabs
{...props}
tabs={tabs}
value={activeTab}
onValueChange={handleValueChange}
/>
);
};

export default CodeTabsWithHash;
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import CodeTabs from '#ui/Common/CodeTabs';

import type { FC, ReactElement } from 'react';

import CodeTabsWithHash from './CodeTabsWithHash';

type MDXCodeTabsProps = {
children: Array<ReactElement<unknown>>;
languages: string;
displayNames?: string;
defaultTab?: string;
groupId?: string;
};

const NAME_OVERRIDES: Record<string, string | undefined> = {
Expand All @@ -21,6 +24,7 @@ const MDXCodeTabs: FC<MDXCodeTabsProps> = ({
displayNames: rawDisplayNames,
children: codes,
defaultTab = '0',
groupId,
...props
}) => {
const { tabs, languages } = useMemo(() => {
Expand All @@ -44,14 +48,19 @@ const MDXCodeTabs: FC<MDXCodeTabsProps> = ({
return {
key: `${language}-${index}`,
label,
id:

@ovflowd ovflowd Sep 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, no (as in this is a no-go)

groupId &&
`${groupId}-${language}-${index}`.replace(/[^a-zA-Z0-9-_]/g, '-'),
};
});

return { tabs, languages };
}, [rawLanguages, rawDisplayNames]);
}, [rawLanguages, rawDisplayNames, groupId]);

const Component = groupId ? CodeTabsWithHash : CodeTabs;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be decided here IMO, but on our rehype shiki plugin. The name of the component to be used (CodeTabs) is passed there, so we can do the right pass directly from there, which reduces verbosity. This also removes the need of regular CodeTabs having an optional Group ID param

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WIP


return (
<CodeTabs
<Component
tabs={tabs}
defaultValue={tabs[Number(defaultTab)].key}
{...props}
Expand All @@ -65,7 +74,7 @@ const MDXCodeTabs: FC<MDXCodeTabsProps> = ({
{codes[index]}
</TabsPrimitive.Content>
))}
</CodeTabs>
</Component>
);
};

Expand Down
15 changes: 15 additions & 0 deletions packages/ui-components/src/contexts/HashContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';

import { createContext, use } from 'react';

type HashContextType = {
hash: string;
setHash: (newHash: string) => void;
};

export const HashContext = createContext<HashContextType>({
hash: '',
setHash: () => {},
});

export const useHashContext = () => use(HashContext);