Skip to content

Commit

Permalink
fix: Get rid of remaining hydration errors (#11072)
Browse files Browse the repository at this point in the history
  • Loading branch information
chargome committed Aug 14, 2024
1 parent e50a059 commit b082398
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 102 deletions.
60 changes: 41 additions & 19 deletions src/components/codeContext.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use client';

import {createContext, useEffect, useReducer, useState} from 'react';
import {createContext, useEffect, useState} from 'react';
import Cookies from 'js-cookie';

import {isLocalStorageAvailable} from 'sentry-docs/utils';

type ProjectCodeKeywords = {
API_URL: string;
DSN: string;
Expand Down Expand Up @@ -89,15 +91,20 @@ export const DEFAULTS: CodeKeywords = {
};

type SelectedCodeTabs = Record<string, string | undefined>;
type CodeSelection = {
groupId: string;
selection: string;
};

type CodeContextType = {
codeKeywords: CodeKeywords;
isLoading: boolean;
sharedCodeSelection: [SelectedCodeTabs, React.Dispatch<[string, string]>];
sharedKeywordSelection: [
Record<string, number>,
React.Dispatch<Record<string, number>>,
];
storedCodeSelection: SelectedCodeTabs;
updateCodeSelection: (selection: CodeSelection) => void;
};

export const CodeContext = createContext<CodeContextType | null>(null);
Expand Down Expand Up @@ -277,10 +284,24 @@ export async function createOrgAuthToken({
}
}

const getLocallyStoredSelections = (): SelectedCodeTabs => {
if (isLocalStorageAvailable()) {
return Object.fromEntries(
Object.entries(localStorage).filter(([key]) => key.startsWith('Tabgroup:'))
);
}
return {};
};

export function CodeContextProvider({children}: {children: React.ReactNode}) {
const [codeKeywords, setCodeKeywords] = useState(cachedCodeKeywords ?? DEFAULTS);

const [isLoading, setIsLoading] = useState<boolean>(cachedCodeKeywords ? false : true);
const [storedCodeSelection, setStoredCodeSelection] = useState<SelectedCodeTabs>({});

// populate state using localstorage
useEffect(() => {
setStoredCodeSelection(getLocallyStoredSelections());
}, []);

useEffect(() => {
if (cachedCodeKeywords === null) {
Expand All @@ -293,31 +314,32 @@ export function CodeContextProvider({children}: {children: React.ReactNode}) {
}
}, [setIsLoading, setCodeKeywords]);

const updateCodeSelection = ({groupId, selection}: CodeSelection) => {
// update context state
setStoredCodeSelection(current => {
return {
...current,
[groupId]: selection,
};
});

// update local storage
if (isLocalStorageAvailable()) {
localStorage.setItem(groupId, selection);
}
};

// sharedKeywordSelection maintains a global mapping for each "keyword"
// namespace to the index of the selected item.
//
// NOTE: This ONLY does anything for the `PROJECT` keyword namespace, since
// that is the only namespace that actually has a list
const sharedKeywordSelection = useState<Record<string, number>>({});

const storedSelections = Object.fromEntries(
Object.entries(
// default to an empty object if localStorage is not available on the server
typeof localStorage === 'undefined' ? {} : localStorage
).filter(([key]) => key.startsWith('Tabgroup:'))
);

// Maintains the global selection for which code block tab is selected
const sharedCodeSelection = useReducer(
(tabs: SelectedCodeTabs, [groupId, value]: [string, string]) => {
return {...tabs, [groupId]: value};
},
storedSelections
);

const result: CodeContextType = {
codeKeywords,
sharedCodeSelection,
storedCodeSelection,
updateCodeSelection,
sharedKeywordSelection,
isLoading,
};
Expand Down
96 changes: 28 additions & 68 deletions src/components/codeTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const HUMAN_LANGUAGE_NAMES = {
javascript: 'JavaScript',
json: 'JSON',
jsx: 'JSX',
tsx: 'TSX',
php: 'PHP',
powershell: 'PowerShell',
typescript: 'TypeScript',
Expand All @@ -31,31 +32,6 @@ interface CodeTabProps {
export function CodeTabs({children}: CodeTabProps) {
const codeBlocks = Array.isArray(children) ? [...children] : [children];

// the idea here is that we have two selection states. The shared selection
// always wins unless what is in the shared selection does not exist on the
// individual code block. In that case the local selection overrides. The
// final selection is what is then rendered.

const codeContext = useContext(CodeContext);
const [localSelection, setLocalSelection] = useState<string | null>(null);
const [lastScrollOffset, setLastScrollOffset] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);

// When the selection switches we scroll so that the box that was toggled
// stays scrolled like it was before. This is because boxes above the changed
// box might also toggle and change height.
useEffect(() => {
if (containerRef.current === null) {
return;
}

if (lastScrollOffset !== null) {
const diff = containerRef.current.getBoundingClientRect().y - lastScrollOffset;
window.scroll(window.scrollX, window.scrollY + diff);
setLastScrollOffset(null);
}
}, [lastScrollOffset]);

// The title is what we use for sorting and also for remembering the
// selection. If there is no title fall back to the title cased language name
// (or override from `LANGUAGES`).
Expand All @@ -72,64 +48,48 @@ export function CodeTabs({children}: CodeTabProps) {

return language[0].toUpperCase() + language.substring(1);
});

// disambiguate duplicates by enumerating them.
const tabTitleSeen: Record<string, number> = {};

possibleChoices.forEach((tabTitle, index) => {
const hasMultiple = possibleChoices.filter(x => x === tabTitle).length > 1;

if (hasMultiple) {
tabTitleSeen[tabTitle] ??= 0;
tabTitleSeen[tabTitle] += 1;
possibleChoices[index] = `${tabTitle} ${tabTitleSeen[tabTitle]}`;
}
});

// The groupId is used to store the selection in localStorage.
// It is a unique identifier based on the tab titles.
const groupId = 'Tabgroup:' + possibleChoices.slice().sort().join('|');

const [sharedSelections, setSharedSelections] = codeContext?.sharedCodeSelection ?? [];

const sharedSelectionChoice = sharedSelections
? possibleChoices.find(x => x === sharedSelections[groupId])
: null;
const localSelectionChoice = localSelection
? possibleChoices.find(x => x === localSelection)
: null;

// Prioritize sharedSelectionChoice over the local selection
const finalSelection =
sharedSelectionChoice ?? localSelectionChoice ?? possibleChoices[0];
const codeContext = useContext(CodeContext);
const [selectedTabIndex, setSelectedTabIndex] = useState<number>(0);
const [lastScrollOffset, setLastScrollOffset] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);

// Save the selected tab for Tabgroup to localStorage whenever it changes
// When the selection switches we scroll so that the box that was toggled
// stays scrolled like it was before. This is because boxes above the changed
// box might also toggle and change height.
useEffect(() => {
if (possibleChoices.length > 1) {
localStorage.setItem(groupId, finalSelection);
if (containerRef.current === null) {
return;
}
}, [finalSelection, groupId, possibleChoices]);

// Whenever local selection and the final selection are not in sync, the local
// selection is updated from the final one. This means that when the shared
// selection moves to something that is unsupported by the block it stays on
// its last selection.
useEffect(() => setLocalSelection(finalSelection), [finalSelection]);
if (lastScrollOffset !== null) {
const diff = containerRef.current.getBoundingClientRect().y - lastScrollOffset;
window.scroll(window.scrollX, window.scrollY + diff);
setLastScrollOffset(null);
}
}, [lastScrollOffset]);

const selectedIndex = possibleChoices.indexOf(finalSelection);
const code = codeBlocks[selectedIndex];
// Update local tab state whenever global context changes
useEffect(() => {
const newSelection = possibleChoices.findIndex(
choice => codeContext?.storedCodeSelection[groupId] === choice
);
if (newSelection !== -1) {
setSelectedTabIndex(newSelection);
}
}, [codeContext?.storedCodeSelection, groupId, possibleChoices]);

const buttons = possibleChoices.map((choice, idx) => (
<TabButton
key={idx}
data-active={choice === finalSelection || possibleChoices.length === 1}
data-active={choice === possibleChoices[selectedTabIndex]}
onClick={() => {
if (containerRef.current) {
// see useEffect above.
setLastScrollOffset(containerRef.current.getBoundingClientRect().y);
}
setSharedSelections?.([groupId, choice]);
setLocalSelection(choice);
codeContext?.updateCodeSelection({groupId, selection: choice});
}}
>
{choice}
Expand All @@ -140,7 +100,7 @@ export function CodeTabs({children}: CodeTabProps) {
<Container ref={containerRef}>
<TabBar>{buttons}</TabBar>
<div className="relative" data-sentry-mask>
{code}
{codeBlocks[selectedTabIndex]}
</div>
</Container>
);
Expand Down
28 changes: 13 additions & 15 deletions src/components/docImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,18 @@ export default function DocImage({
.map(s => parseInt(s, 10));

return (
<div style={{textAlign: 'center'}}>
<a href={imgPath} target="_blank" rel="noreferrer">
<Image
{...props}
src={src}
width={width}
height={height}
style={{
width: '100%',
height: 'auto',
}}
alt={props.alt ?? ''}
/>
</a>
</div>
<a href={imgPath} target="_blank" rel="noreferrer">
<Image
{...props}
src={src}
width={width}
height={height}
style={{
width: '100%',
height: 'auto',
}}
alt={props.alt ?? ''}
/>
</a>
);
}
2 changes: 2 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,5 @@ export function captureException(exception: unknown): void {
export function isTruthy<T>(value: T | undefined | null): value is T {
return value !== undefined && value !== null;
}

export const isLocalStorageAvailable = () => typeof localStorage !== 'undefined';

0 comments on commit b082398

Please sign in to comment.