diff --git a/apps/code-infra-dashboard/app/(dashboard)/reactions/page.tsx b/apps/code-infra-dashboard/app/(dashboard)/reactions/page.tsx
new file mode 100644
index 000000000..eb30e346a
--- /dev/null
+++ b/apps/code-infra-dashboard/app/(dashboard)/reactions/page.tsx
@@ -0,0 +1,9 @@
+import * as React from 'react';
+import type { Metadata } from 'next';
+import Reactions from '@/views/Reactions';
+
+export const metadata: Metadata = { title: 'GitHub Reactions' };
+
+export default function ReactionsPage() {
+ return ;
+}
diff --git a/apps/code-infra-dashboard/src/hooks/useSearchParamsState.ts b/apps/code-infra-dashboard/src/hooks/useSearchParamsState.ts
index 75744e514..a35344559 100644
--- a/apps/code-infra-dashboard/src/hooks/useSearchParamsState.ts
+++ b/apps/code-infra-dashboard/src/hooks/useSearchParamsState.ts
@@ -86,13 +86,38 @@ export interface UseSearchParamsStateOptions {
* // Functional updates
* setParams(prev => ({ page: prev.page + 1 }));
*/
+const EMPTY_SEARCH_PARAMS = new URLSearchParams();
+const emptySubscribe = () => () => {};
+const getHydratedSnapshot = () => true;
+const getServerSnapshot = () => false;
+
+/**
+ * Return the real `useSearchParams()` result only after hydration. During SSR
+ * and the first client hydration render, returns an empty `URLSearchParams`.
+ *
+ * This avoids hydration mismatches when the app is rendered with
+ * `dynamic = 'force-static'`: Next.js silently returns empty search params
+ * during the static prerender but returns the real URL params on the client,
+ * which normally causes SSR/client divergence for any component that reads
+ * from the search params.
+ */
+function useHydrationSafeSearchParams() {
+ const hydrated = React.useSyncExternalStore(
+ emptySubscribe,
+ getHydratedSnapshot,
+ getServerSnapshot,
+ );
+ const real = useSearchParams();
+ return hydrated ? real : EMPTY_SEARCH_PARAMS;
+}
+
export function useSearchParamsState(
config: { [K in keyof C]: ParamConfig },
defaultOptions?: UseSearchParamsStateOptions,
): [C, SetState] {
const router = useRouter();
const pathname = usePathname();
- const searchParams = useSearchParams();
+ const searchParams = useHydrationSafeSearchParams();
// Track previous state to preserve value references
const prevStateRef = React.useRef(null);
diff --git a/apps/code-infra-dashboard/src/utils/github.ts b/apps/code-infra-dashboard/src/utils/github.ts
index 76961d665..f2e96c3b1 100644
--- a/apps/code-infra-dashboard/src/utils/github.ts
+++ b/apps/code-infra-dashboard/src/utils/github.ts
@@ -12,3 +12,36 @@ export function parseRepo(input: string): { owner: string; repo: string } {
}
return { owner, repo };
}
+
+export interface IssueReactionTarget {
+ owner: string;
+ repo: string;
+ number: number;
+}
+
+const ISSUE_PATH_RE = /^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)\/?$/;
+
+/**
+ * Parse a GitHub issue or pull request URL.
+ * Returns null if the URL is not a recognized issue/PR resource (comment URLs are rejected).
+ */
+export function parseIssueUrl(input: string): IssueReactionTarget | null {
+ let url: URL;
+ try {
+ url = new URL(input.trim());
+ } catch {
+ return null;
+ }
+
+ if (url.hostname !== 'github.com' || url.hash) {
+ return null;
+ }
+
+ const pathMatch = ISSUE_PATH_RE.exec(url.pathname);
+ if (!pathMatch) {
+ return null;
+ }
+
+ const [, owner, repo, numberStr] = pathMatch;
+ return { owner, repo, number: Number(numberStr) };
+}
diff --git a/apps/code-infra-dashboard/src/views/Landing.tsx b/apps/code-infra-dashboard/src/views/Landing.tsx
index c366dd419..01b345a96 100644
--- a/apps/code-infra-dashboard/src/views/Landing.tsx
+++ b/apps/code-infra-dashboard/src/views/Landing.tsx
@@ -11,6 +11,7 @@ import Box from '@mui/material/Box';
import NextLink from 'next/link';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import GitHubIcon from '@mui/icons-material/GitHub';
+import ThumbsUpDownIcon from '@mui/icons-material/ThumbsUpDown';
import BuildIcon from '@mui/icons-material/Build';
import CompareIcon from '@mui/icons-material/Compare';
import AssessmentIcon from '@mui/icons-material/Assessment';
@@ -73,6 +74,12 @@ const tools: Tool[] = [
icon: ,
path: '/github-triage',
},
+ {
+ name: 'GitHub Reactions',
+ description: 'List every emoji reaction on a public GitHub issue or pull request',
+ icon: ,
+ path: '/reactions',
+ },
];
export default function Landing() {
diff --git a/apps/code-infra-dashboard/src/views/Reactions.tsx b/apps/code-infra-dashboard/src/views/Reactions.tsx
new file mode 100644
index 000000000..840215fb4
--- /dev/null
+++ b/apps/code-infra-dashboard/src/views/Reactions.tsx
@@ -0,0 +1,240 @@
+'use client';
+
+import * as React from 'react';
+import { useQuery } from '@tanstack/react-query';
+import Alert from '@mui/material/Alert';
+import NextLink from 'next/link';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Link from '@mui/material/Link';
+import TextField from '@mui/material/TextField';
+import Typography from '@mui/material/Typography';
+import {
+ DataGridPremium,
+ useGridApiRef,
+ useKeepGroupedColumnsHidden,
+ type GridColDef,
+} from '@mui/x-data-grid-premium';
+import Heading from '../components/Heading';
+import ErrorDisplay from '../components/ErrorDisplay';
+import { useSearchParamsState } from '../hooks/useSearchParamsState';
+import { octokit, parseIssueUrl, type IssueReactionTarget } from '../utils/github';
+
+const EXAMPLES = [
+ { label: 'mui-design-kits#10', url: 'https://github.com/mui/mui-design-kits/issues/10' },
+ { label: 'mui-design-kits#111', url: 'https://github.com/mui/mui-design-kits/issues/111' },
+].map((example) => ({
+ ...example,
+ href: `/reactions?${new URLSearchParams({ url: example.url })}`,
+}));
+
+const EMOJI: Record = {
+ '+1': '👍',
+ '-1': '👎',
+ laugh: '😄',
+ confused: '😕',
+ heart: '❤️',
+ hooray: '🎉',
+ rocket: '🚀',
+ eyes: '👀',
+};
+
+interface ReactionRow {
+ id: number;
+ content: string;
+ user: string;
+ userUrl: string;
+}
+
+interface ReactionsResult {
+ rows: ReactionRow[];
+ truncated: boolean;
+}
+
+const MAX_PAGES = 3;
+const PAGE_SIZE = 100;
+
+async function fetchReactions(
+ target: IssueReactionTarget,
+ unbounded: boolean,
+): Promise {
+ const iterator = octokit.paginate.iterator(octokit.rest.reactions.listForIssue, {
+ owner: target.owner,
+ repo: target.repo,
+ issue_number: target.number,
+ per_page: PAGE_SIZE,
+ });
+
+ const rows: ReactionRow[] = [];
+ let pages = 0;
+ let truncated = false;
+ for await (const response of iterator) {
+ pages += 1;
+ for (const reaction of response.data) {
+ rows.push({
+ id: reaction.id,
+ content: reaction.content,
+ user: reaction.user?.login ?? '(unknown)',
+ userUrl: reaction.user?.html_url ?? '',
+ });
+ }
+ if (!unbounded && pages >= MAX_PAGES && response.data.length === PAGE_SIZE) {
+ truncated = true;
+ break;
+ }
+ }
+
+ return { rows, truncated };
+}
+
+const COLUMNS: GridColDef[] = [
+ {
+ field: 'content',
+ headerName: 'Reaction',
+ width: 140,
+ valueFormatter: (value: string) => `${EMOJI[value] ?? ''} ${value}`.trim(),
+ },
+ {
+ field: 'user',
+ headerName: 'User',
+ flex: 1,
+ minWidth: 200,
+ renderCell: (cellParams) =>
+ cellParams.row.userUrl ? (
+
+ @{cellParams.value}
+
+ ) : (
+ cellParams.value
+ ),
+ },
+];
+
+function targetKey(target: IssueReactionTarget): string {
+ return `${target.owner}/${target.repo}#${target.number}`;
+}
+
+export default function Reactions() {
+ const [searchParams, setSearchParams] = useSearchParamsState(
+ { url: { defaultValue: '' }, all: { defaultValue: '' } },
+ { replace: true },
+ );
+
+ const unbounded = searchParams.all === '1';
+ const loadAllHref = `/reactions?${new URLSearchParams({ url: searchParams.url, all: '1' })}`;
+
+ const [draft, setDraft] = React.useState(searchParams.url);
+ React.useEffect(() => {
+ setDraft(searchParams.url);
+ }, [searchParams.url]);
+
+ const target = React.useMemo(
+ () => (searchParams.url ? parseIssueUrl(searchParams.url) : null),
+ [searchParams.url],
+ );
+ const parseError = Boolean(searchParams.url) && target === null;
+
+ const query = useQuery({
+ queryKey: ['reactions', target ? targetKey(target) : null, unbounded],
+ queryFn: () => fetchReactions(target!, unbounded),
+ enabled: Boolean(target),
+ staleTime: 60 * 1000,
+ retry: false,
+ });
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ setSearchParams({ url: draft.trim(), all: '' });
+ };
+
+ const apiRef = useGridApiRef();
+ const rowGroupingModel = React.useMemo(() => ['content'], []);
+ const initialState = useKeepGroupedColumnsHidden({ apiRef, rowGroupingModel });
+
+ return (
+
+ GitHub Reactions
+
+ Paste a public GitHub issue or pull request URL to list every reaction and the users who
+ left them. Only public repositories are supported. Examples:{' '}
+ {EXAMPLES.map((example, index) => (
+
+ {index > 0 ? ', ' : null}
+
+ {example.label}
+
+
+ ))}
+
+
+
+ setDraft(event.target.value)}
+ />
+
+
+
+ {parseError ? (
+
+ Not a recognized GitHub URL. Expected an issue or pull request link.
+
+ ) : null}
+
+ {query.data?.truncated ? (
+
+ Showing the first {MAX_PAGES * PAGE_SIZE} reactions. This issue has more —{' '}
+
+ load all reactions
+
+ . This will use more of your hourly GitHub API budget.
+
+ ) : null}
+
+ {query.isError ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/tools-public/toolpad/pages/design_kits_issue_111/page.yml b/apps/tools-public/toolpad/pages/design_kits_issue_111/page.yml
index c42c6dcfe..e5816857f 100644
--- a/apps/tools-public/toolpad/pages/design_kits_issue_111/page.yml
+++ b/apps/tools-public/toolpad/pages/design_kits_issue_111/page.yml
@@ -4,50 +4,17 @@ apiVersion: v1
kind: page
spec:
title: design_kits_issue_111
- display: shell
- queries:
- - name: query
- query:
- kind: rest
- url: ' https://api.github.com/repos/mui/mui-design-kits/issues/10/reactions'
- headers:
- - name: X-GitHub-Api-Version
- value: 2022-11-28
- - name: Accept
- value: application/vnd.github+json
- - name: Authorization
- value:
- $$jsExpression: |
- `Bearer ${parameters.GITHUB_TOKEN}`
- method: GET
- searchParams: []
- parameters:
- - name: GITHUB_TOKEN
- value:
- $$env: GITHUB_TOKEN
+ alias:
+ - _-clGLR
+ displayName: design_kits_issue_111
content:
- component: Text
name: text
layout:
columnSize: 1
props:
- value: https://github.com/mui/mui-design-kits/issues/111 upvotes
- variant: h4
- - component: DataGrid
- name: dataGrid
- layout:
- columnSize: 1
- props:
- rows:
- $$jsExpression: |
- query.data
- .filter((reaction) => reaction.content === "+1")
- .map((reaction) => ({ user: reaction.user.html_url }))
- columns:
- - field: user
- type: link
- width: 379
- align: left
- height: 588
- alias:
- - _-clGLR
+ mode: markdown
+ value:
+ "This page has moved to the code-infra-dashboard:
+ [GitHub Reactions](https://code-infra-dashboard.onrender.com/reactions?url=https%3A%2F%2Fgithub.com%2Fmui%2Fmui-design-kits%2Fissues%2F10)"
+ display: shell