From b0823982469e612b38fbe3664308bf3ebb328f80 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 14 Aug 2024 16:58:13 +0200 Subject: [PATCH] fix: Get rid of remaining hydration errors (#11072) --- src/components/codeContext.tsx | 60 ++++++++++++++------- src/components/codeTabs.tsx | 96 ++++++++++------------------------ src/components/docImage.tsx | 28 +++++----- src/utils.ts | 2 + 4 files changed, 84 insertions(+), 102 deletions(-) diff --git a/src/components/codeContext.tsx b/src/components/codeContext.tsx index d19c912e053a4..357dd8454462e 100644 --- a/src/components/codeContext.tsx +++ b/src/components/codeContext.tsx @@ -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; @@ -89,15 +91,20 @@ export const DEFAULTS: CodeKeywords = { }; type SelectedCodeTabs = Record; +type CodeSelection = { + groupId: string; + selection: string; +}; type CodeContextType = { codeKeywords: CodeKeywords; isLoading: boolean; - sharedCodeSelection: [SelectedCodeTabs, React.Dispatch<[string, string]>]; sharedKeywordSelection: [ Record, React.Dispatch>, ]; + storedCodeSelection: SelectedCodeTabs; + updateCodeSelection: (selection: CodeSelection) => void; }; export const CodeContext = createContext(null); @@ -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(cachedCodeKeywords ? false : true); + const [storedCodeSelection, setStoredCodeSelection] = useState({}); + + // populate state using localstorage + useEffect(() => { + setStoredCodeSelection(getLocallyStoredSelections()); + }, []); useEffect(() => { if (cachedCodeKeywords === null) { @@ -293,6 +314,21 @@ 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. // @@ -300,24 +336,10 @@ export function CodeContextProvider({children}: {children: React.ReactNode}) { // that is the only namespace that actually has a list const sharedKeywordSelection = useState>({}); - 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, }; diff --git a/src/components/codeTabs.tsx b/src/components/codeTabs.tsx index 742da5a4be62e..e43401ba6d347 100644 --- a/src/components/codeTabs.tsx +++ b/src/components/codeTabs.tsx @@ -17,6 +17,7 @@ const HUMAN_LANGUAGE_NAMES = { javascript: 'JavaScript', json: 'JSON', jsx: 'JSX', + tsx: 'TSX', php: 'PHP', powershell: 'PowerShell', typescript: 'TypeScript', @@ -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(null); - const [lastScrollOffset, setLastScrollOffset] = useState(null); - const containerRef = useRef(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`). @@ -72,64 +48,48 @@ export function CodeTabs({children}: CodeTabProps) { return language[0].toUpperCase() + language.substring(1); }); - - // disambiguate duplicates by enumerating them. - const tabTitleSeen: Record = {}; - - 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(0); + const [lastScrollOffset, setLastScrollOffset] = useState(null); + const containerRef = useRef(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) => ( { if (containerRef.current) { // see useEffect above. setLastScrollOffset(containerRef.current.getBoundingClientRect().y); } - setSharedSelections?.([groupId, choice]); - setLocalSelection(choice); + codeContext?.updateCodeSelection({groupId, selection: choice}); }} > {choice} @@ -140,7 +100,7 @@ export function CodeTabs({children}: CodeTabProps) { {buttons}
- {code} + {codeBlocks[selectedTabIndex]}
); diff --git a/src/components/docImage.tsx b/src/components/docImage.tsx index 13921d86f37fb..cef75042244ce 100644 --- a/src/components/docImage.tsx +++ b/src/components/docImage.tsx @@ -40,20 +40,18 @@ export default function DocImage({ .map(s => parseInt(s, 10)); return ( -
- - {props.alt - -
+ + {props.alt + ); } diff --git a/src/utils.ts b/src/utils.ts index 38ce852b67e68..743e441c4ffb5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -92,3 +92,5 @@ export function captureException(exception: unknown): void { export function isTruthy(value: T | undefined | null): value is T { return value !== undefined && value !== null; } + +export const isLocalStorageAvailable = () => typeof localStorage !== 'undefined';