From 65fc29b8783438d7eb4eecce912b3bb366ab04e0 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 29 Jun 2026 11:15:47 -0700 Subject: [PATCH 1/4] Add the React code-tabs component (dormant) (#61777) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/frame/components/CodeTabsGroup.tsx | 164 ++++++++++++++++++ .../ui/MarkdownContent/MarkdownContent.tsx | 5 + 2 files changed, 169 insertions(+) create mode 100644 src/frame/components/CodeTabsGroup.tsx diff --git a/src/frame/components/CodeTabsGroup.tsx b/src/frame/components/CodeTabsGroup.tsx new file mode 100644 index 000000000000..2d326d26eb03 --- /dev/null +++ b/src/frame/components/CodeTabsGroup.tsx @@ -0,0 +1,164 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useState, + isValidElement, + Children, + cloneElement, + type ReactElement, + type ReactNode, + type MouseEvent as ReactMouseEvent, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react' +import { useRouter } from 'next/router' +import { UnderlineNav } from '@primer/react' +import cx from 'classnames' + +import Cookies from '@/frame/components/lib/cookies' +import { CODE_SAMPLE_LANGUAGE_COOKIE_NAME } from '@/frame/lib/constants' +import { sendEvent } from '@/events/components/events' +import { EventType } from '@/events/types' +import { useTranslation } from '@/languages/components/useTranslation' + +// React-native replacement for the imperative CodeTabs enhancer (#6619). The old +// component scanned `#article-contents` for `.ghd-codetabs`, inserted a foreign +// `.ghd-codetabs-nav` mountPoint as the container's first child, portaled a nav +// into it, and toggled panel attributes — destructive surgery on React-owned +// nodes that breaks on client-side navigation teardown. Instead, the article body +// hast maps each `.ghd-codetabs` container to , which reads its +// `.ghd-codetab` panel children straight from props and renders the nav + panels +// itself. No DOM scanning, no portal, no foreign nodes. +// +// The selected language lives in CodeLanguageContext so multiple code-tab groups +// on one page stay in sync and share the language cookie, matching the previous +// single-component behavior. + +type CodeLanguageContextT = { + language: string + setLanguage: (value: string) => void +} + +const CodeLanguageContext = createContext({ + language: '', + setLanguage: () => {}, +}) + +export function CodeTabsProvider({ children }: { children: ReactNode }) { + // Start empty so server + first client render select each group's first tab + // (deterministic, hydration-safe). The cookie preference is applied after + // hydration, the same moment the old imperative enhancer used to run. + const [language, setLanguageState] = useState('') + + useEffect(() => { + const cookieValue = Cookies.get(CODE_SAMPLE_LANGUAGE_COOKIE_NAME) + if (cookieValue) setLanguageState(cookieValue) + }, []) + + const setLanguage = useCallback((value: string) => { + setLanguageState(value) + Cookies.set(CODE_SAMPLE_LANGUAGE_COOKIE_NAME, value) + sendEvent({ + type: EventType.preference, + preference_name: 'code_language', + preference_value: value, + }) + }, []) + + const value = useMemo( + () => ({ language, setLanguage }), + [language, setLanguage], + ) + + return {children} +} + +type PanelTab = { + key: string + label: string + panel: ReactElement<{ className?: string }> +} + +function hasClass(className: unknown, target: string): boolean { + return String(className || '') + .split(/\s+/) + .includes(target) +} + +function getActiveKey(tabs: PanelTab[], selectedLanguage: string): string { + return tabs.some((tab) => tab.key === selectedLanguage) ? selectedLanguage : (tabs[0]?.key ?? '') +} + +type CodeTabsGroupProps = { + className?: string + children?: ReactNode + [key: string]: unknown +} + +export function CodeTabsGroup({ className, children, ...rest }: CodeTabsGroupProps) { + const router = useRouter() + const { t } = useTranslation('code_tabs') + const { language, setLanguage } = useContext(CodeLanguageContext) + const baseId = useId() + + // Pull the `.ghd-codetab` panel children straight from the converted hast. Fail + // open (render the original markup) if the expected metadata isn't present. + const tabs: PanelTab[] = Children.toArray(children) + .filter((child): child is ReactElement<{ className?: string }> => isValidElement(child)) + .filter((child) => hasClass(child.props.className, 'ghd-codetab')) + .map((panel) => { + const props = panel.props as { 'data-lang'?: string; 'data-label'?: string } + const key = props['data-lang'] + const label = props['data-label'] + if (!key || !label) return null + return { key, label, panel } + }) + .filter((tab): tab is PanelTab => tab !== null) + + if (!tabs.length) { + return ( +
+ {children} +
+ ) + } + + const activeKey = getActiveKey(tabs, language) + + return ( +
+
+ {/* key on asPath works around a Primer UnderlineNav re-render bug. */} + + {tabs.map((tab) => ( + { + event.preventDefault() + setLanguage(tab.key) + }} + > + {tab.label} + + ))} + +
+ {tabs.map((tab, index) => { + const isActive = tab.key === activeKey + return cloneElement(tab.panel, { + key: tab.key, + id: `${baseId}-panel-${index}`, + role: 'tabpanel', + tabIndex: 0, + hidden: !isActive, + className: cx(tab.panel.props.className, { 'ghd-codetab-hidden': !isActive }), + } as Record) + })} +
+ ) +} diff --git a/src/frame/components/ui/MarkdownContent/MarkdownContent.tsx b/src/frame/components/ui/MarkdownContent/MarkdownContent.tsx index 123058a88eff..ef2102c7aaef 100644 --- a/src/frame/components/ui/MarkdownContent/MarkdownContent.tsx +++ b/src/frame/components/ui/MarkdownContent/MarkdownContent.tsx @@ -6,6 +6,7 @@ import type { Root as HastRoot } from 'hast' import cx from 'classnames' import { CopyButton } from '@/frame/components/CopyButton' +import { CodeTabsGroup } from '@/frame/components/CodeTabsGroup' import { ToggleableContent } from '@/tools/components/ToggleableContent' import { isToggleClass } from '@/tools/components/SelectionContext' import styles from './MarkdownContent.module.scss' @@ -35,6 +36,10 @@ const markdownComponents = { // and runs first, so only the handful of toggleable elements become context // consumers; every other div/span renders as a plain element with no hook. div(props: ComponentProps<'div'>) { + const classes = String(props.className || '').split(/\s+/) + if (classes.includes('ghd-codetabs')) { + return + } if (isToggleClass(props.className)) { return } From 4e8381e26622cba547961f872202177bc25030c5 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 29 Jun 2026 11:57:48 -0700 Subject: [PATCH 2/4] Fix dark mode flash with a pre-paint theme script (#61911) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/color-schemes/README.md | 12 ++- src/color-schemes/components/useTheme.ts | 4 +- src/color-schemes/lib/color-mode-script.ts | 38 +++++++++ src/color-schemes/tests/color-mode-script.ts | 81 ++++++++++++++++++++ src/frame/middleware/helmet.ts | 20 ++++- src/frame/pages/app.tsx | 31 -------- src/pages/_document.tsx | 11 ++- 7 files changed, 157 insertions(+), 40 deletions(-) create mode 100644 src/color-schemes/lib/color-mode-script.ts create mode 100644 src/color-schemes/tests/color-mode-script.ts diff --git a/src/color-schemes/README.md b/src/color-schemes/README.md index 54a41ac2a914..6a51c7c47d4d 100644 --- a/src/color-schemes/README.md +++ b/src/color-schemes/README.md @@ -35,6 +35,15 @@ Primer React uses slightly different terminology than the underlying CSS or the - CSS `light` -> Component `day` - CSS `dark` -> Component `night` +### Pre-paint Inline Script + +`useTheme` only runs after the React bundle hydrates, so the page would first paint with the SSR default theme and then switch, causing a visible flash. To avoid that, `src/color-schemes/lib/color-mode-script.ts` exports `colorModeScript`: a small synchronous script that `_document.tsx` inlines in the ``. It runs before the first paint, reads the `color_mode` cookie, and sets `data-color-mode`, `data-light-theme`, and `data-dark-theme` on ``. + +Key properties: +- **Cache-safe**: The script is identical for every request, so the HTML stays shared-cacheable in the CDN. The theme is never server-rendered from the cookie (that would vary per user and poison the cache). +- **No drift**: Its validation allowlists and defaults are derived from the same `CssColorMode`, `SupportedTheme`, and `defaultCSSTheme` exports used by `useTheme`. A test in `tests/color-mode-script.ts` runs the script against a fake `document` and asserts parity with `getCssTheme`. +- **CSP**: Because the script is inline, `src/frame/middleware/helmet.ts` adds its `sha256` hash to the `script-src` directive. The hash is computed from the exact script string at startup, so it never needs manual maintenance, and a hash (not a nonce) keeps the response cacheable. + ## Setup & Usage To access the current theme in a component: @@ -66,7 +75,8 @@ This hook is primarily used at the root of the application (e.g., in `src/frame/ ## Current State & Known Issues -- **Hydration Mismatch / Flash of Unstyled Content**: Since the theme is read from a cookie on the client side (in `useEffect`), there can be a brief moment where the default theme is applied before the user's preference loads. +- **Page background flash (fixed)**: The page-level theme (the `` `data-*` attributes that drive the background color) is now set before first paint by the inline `colorModeScript`, so there is no longer a light-to-dark flash of the page background on load. +- **Primer component theming**: Primer React components still resolve their theme from the post-hydration `useTheme` state, so component-level theming applies slightly after the page background. The `setTimeout` workaround below is still required for that path. - **Race Condition Workaround**: There is a `setTimeout` hack in `useTheme.ts` to delay the theme application. This is necessary to prevent Primer React's internal logic from overriding the user's preference with `auto` on initial load. - *Reference*: [Primer React Issue #2229](https://github.com/primer/react/issues/2229) - **Future**: The long-term goal is to rely entirely on CSS variables, removing the need for complex JavaScript state management for theming. \ No newline at end of file diff --git a/src/color-schemes/components/useTheme.ts b/src/color-schemes/components/useTheme.ts index b5e9fea3aa3e..cb0ad4c841e8 100644 --- a/src/color-schemes/components/useTheme.ts +++ b/src/color-schemes/components/useTheme.ts @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import Cookies from '../../frame/components/lib/cookies' import { COLOR_MODE_COOKIE_NAME } from '@/frame/lib/constants' -enum CssColorMode { +export enum CssColorMode { auto = 'auto', light = 'light', dark = 'dark', @@ -14,7 +14,7 @@ enum ComponentColorMode { night = 'night', } -enum SupportedTheme { +export enum SupportedTheme { light = 'light', dark = 'dark', dark_dimmed = 'dark_dimmed', diff --git a/src/color-schemes/lib/color-mode-script.ts b/src/color-schemes/lib/color-mode-script.ts new file mode 100644 index 000000000000..c5272af904d0 --- /dev/null +++ b/src/color-schemes/lib/color-mode-script.ts @@ -0,0 +1,38 @@ +import { COLOR_MODE_COOKIE_NAME } from '@/frame/lib/constants' +import { CssColorMode, SupportedTheme, defaultCSSTheme } from '@/color-schemes/components/useTheme' + +// A tiny script that runs synchronously in the document , before the +// browser's first paint. It reads the `color_mode` cookie (set by github.com, +// not HttpOnly) and writes the matching `data-color-mode`, `data-light-theme`, +// and `data-dark-theme` attributes onto the element. Without this, the +// page first paints with the SSR default theme and only switches to the user's +// real theme after the React bundle hydrates, causing a visible flash. +// +// The output is identical for every request, so the HTML stays shared-cacheable +// in our CDN. The validation allowlists and defaults are derived from the same +// enums used by `useTheme`, so they can't drift, and `helmet.ts` hashes this +// exact string for the CSP `script-src` allowance (no nonce, no unsafe-inline). +const modes = JSON.stringify(Object.values(CssColorMode)) +const themes = JSON.stringify(Object.values(SupportedTheme)) +const defaults = JSON.stringify(defaultCSSTheme) +const cookieName = JSON.stringify(COLOR_MODE_COOKIE_NAME) + +export const colorModeScript = `(function(){ +var MODES=${modes},THEMES=${themes},D=${defaults}; +var css=D; +try{ +var m=document.cookie.match(new RegExp('(?:^|; )'+${cookieName}+'=([^;]*)')); +if(m){ +var p=JSON.parse(decodeURIComponent(m[1])); +var fMode=function(x){return MODES.indexOf(x)>-1?x:null;}; +var fTheme=function(t){if(!t)return null;if(THEMES.indexOf(t.name)>-1)return t.name;if(THEMES.indexOf(t.color_mode)>-1)return t.color_mode;return null;}; +css={colorMode:fMode(p.color_mode)||D.colorMode,lightTheme:fTheme(p.light_theme)||D.lightTheme,darkTheme:fTheme(p.dark_theme)||D.darkTheme}; +} +}catch(e){} +try{ +var h=document.documentElement; +h.setAttribute('data-color-mode',css.colorMode); +h.setAttribute('data-light-theme',css.lightTheme); +h.setAttribute('data-dark-theme',css.darkTheme); +}catch(e){} +})();` diff --git a/src/color-schemes/tests/color-mode-script.ts b/src/color-schemes/tests/color-mode-script.ts new file mode 100644 index 000000000000..cd83d53c030f --- /dev/null +++ b/src/color-schemes/tests/color-mode-script.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest' + +import { colorModeScript } from '../lib/color-mode-script' +import { getCssTheme } from '../components/useTheme' + +// The inline script can't import the React `useTheme` module at runtime (it +// runs before any bundle loads), so it reimplements the same validation. These +// tests run the script against a fake `document` and assert it produces the +// exact same result as `getCssTheme`, which keeps the two in sync. +function runScript(rawCookie: string) { + const attrs: Record = {} + const fakeDocument = { + cookie: rawCookie, + documentElement: { + setAttribute(name: string, value: string) { + attrs[name] = value + }, + }, + } + new Function('document', colorModeScript)(fakeDocument) + return attrs +} + +function cookieFor(value: object) { + // The real cookie value is URL-encoded JSON, like the browser stores it. + return `color_mode=${encodeURIComponent(JSON.stringify(value))}` +} + +function expectMatchesGetCssTheme(rawCookie: string, cookieValue: string) { + const css = getCssTheme(cookieValue) + expect(runScript(rawCookie)).toEqual({ + 'data-color-mode': css.colorMode, + 'data-light-theme': css.lightTheme, + 'data-dark-theme': css.darkTheme, + }) +} + +describe('colorModeScript', () => { + test('falls back to defaults when no cookie is set', () => { + expectMatchesGetCssTheme('', '') + }) + + test('falls back to defaults on junk cookie values', () => { + expectMatchesGetCssTheme('color_mode=not-valid-json', '') + }) + + test('respects a valid color_mode cookie', () => { + const value = { + color_mode: 'dark', + light_theme: { name: 'light_colorblind', color_mode: 'light' }, + dark_theme: { name: 'dark_tritanopia', color_mode: 'dark' }, + } + expectMatchesGetCssTheme(cookieFor(value), JSON.stringify(value)) + }) + + test('honors supported named themes', () => { + const value = { + color_mode: 'auto', + light_theme: { name: 'light', color_mode: 'light' }, + dark_theme: { name: 'dark_dimmed', color_mode: 'dark' }, + } + expectMatchesGetCssTheme(cookieFor(value), JSON.stringify(value)) + }) + + test('ignores unknown modes and themes', () => { + const value = { + color_mode: 'sepia', + light_theme: { name: 'rainbow', color_mode: 'rainbow' }, + dark_theme: { name: 'midnight', color_mode: 'midnight' }, + } + expectMatchesGetCssTheme(cookieFor(value), JSON.stringify(value)) + }) + + test('reads the cookie even when other cookies are present', () => { + const value = { color_mode: 'light' } + const rawCookie = `_octo=GH1.1; color_mode=${encodeURIComponent( + JSON.stringify(value), + )}; logged_in=no` + expectMatchesGetCssTheme(rawCookie, JSON.stringify(value)) + }) +}) diff --git a/src/frame/middleware/helmet.ts b/src/frame/middleware/helmet.ts index c5c083ee9a79..6e7e1b1501d4 100644 --- a/src/frame/middleware/helmet.ts +++ b/src/frame/middleware/helmet.ts @@ -3,8 +3,18 @@ import { languagePrefixPathRegex } from '@/languages/lib/languages-server' import versionSatisfiesRange from '@/versions/lib/version-satisfies-range' import type { NextFunction, Request, Response } from 'express' import helmet from 'helmet' +import { createHash } from 'crypto' + +import { colorModeScript } from '@/color-schemes/lib/color-mode-script' const isDev = process.env.NODE_ENV === 'development' + +// The pre-paint theme script in `_document.tsx` is inlined, so it needs an +// explicit CSP `script-src` allowance. We hash the exact script string rather +// than using a nonce, because a nonce would have to vary per response and would +// break the shared CDN cache. The script is identical for every request, so its +// hash is stable and the HTML stays cacheable. +const colorModeScriptHash = `'sha256-${createHash('sha256').update(colorModeScript).digest('base64')}'` const GITHUB_DOMAINS = [ "'self'", 'github.com', @@ -36,9 +46,13 @@ const DEFAULT_OPTIONS = { // For use during development only! // `unsafe-eval` allows us to use a performant webpack devtool setting (eval) // https://webpack.js.org/configuration/devtool/#devtool - scriptSrc: [...GITHUB_DOMAINS, "'self'", 'data:', isDev && "'unsafe-eval'"].filter( - Boolean, - ) as string[], + scriptSrc: [ + ...GITHUB_DOMAINS, + "'self'", + 'data:', + colorModeScriptHash, + isDev && "'unsafe-eval'", + ].filter(Boolean) as string[], scriptSrcAttr: ["'self'"], frameSrc: [ ...GITHUB_DOMAINS, diff --git a/src/frame/pages/app.tsx b/src/frame/pages/app.tsx index 357532d2cec4..979be3598c20 100644 --- a/src/frame/pages/app.tsx +++ b/src/frame/pages/app.tsx @@ -86,37 +86,6 @@ const MyApp = ({ Component, pageProps, languagesContext, stagingName }: MyAppPro } }, [router, router.query, pageProps.mainContext]) - useEffect(() => { - // The CSS from primer looks something like this: - // - // @media (prefers-color-scheme: dark) [data-color-mode=auto][data-dark-theme=dark] { - // --color-canvas-default: black; - // } - // html { - // background-color: var(--color-canvas-default); - // } - // - // So if that `[data-color-mode][data-dark-theme=dark]` isn't present - // on the html, but on a top-level wrapping `
` then the `` - // doesn't get the right CSS. - // Normally, with Primer you make sure you set these things in the - // `` tag and you can use `_document.tsx` for that but that's - // only something you can do in server-side rendering. So, - // we use a hook to assure that the `` tag has the correct - // dataset attribute values. - const html = document.querySelector('html') - if (html) { - // Note, this is the same as setting `` - // But you can't do `html.dataset['color-mode']` so you use the - // camelCase variant and you get the same effect. - // Appears Next.js can't modify after server rendering: - // https://stackoverflow.com/a/54774431 - html.dataset.colorMode = theme.css.colorMode - html.dataset.darkTheme = theme.css.darkTheme - html.dataset.lightTheme = theme.css.lightTheme - } - }, [theme]) - return ( <> diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index 168d735433dd..b1fbb30b3088 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -1,19 +1,24 @@ import Document, { Html, Head, Main, NextScript } from 'next/document' import { defaultCSSTheme } from '@/color-schemes/components/useTheme' +import { colorModeScript } from '@/color-schemes/lib/color-mode-script' export default class MyDocument extends Document { render() { return ( - + +