Feat: error-decoder (#6214)

* Feat: port error-decoder

* Fix: do not choke on empty invariant

* Refactor: read url query from `useRouter`

* Fix: argsList can contains `undefined`

* Fix: handle empty string arg

* Feat: move error decoder to the separate page

* Fix: wrap error decoder header in <Intro />

* Perf: cache GitHub RAW requests

* Refactor: apply code review suggestions

* Fix: build error

* Refactor: apply code review suggestions

* Discard changes to src/content/index.md

* Fix: animation duration/delay

* Refactor: read error page from markdown

* Fix lint

* Fix /error being 404

* Prevent `_default.md` being included in `[[...markdownPath]].md`

* Fix custom error markdown reading

* Updates

---------

Co-authored-by: Ricky Hanlon <rickhanlonii@gmail.com>
This commit is contained in:
Sukka
2024-01-13 05:18:21 +08:00
committed by GitHub
parent 6987f0fb30
commit 8d2664b806
11 changed files with 504 additions and 135 deletions

View File

@@ -0,0 +1,23 @@
// Error Decoder requires reading pregenerated error message from getStaticProps,
// but MDX component doesn't support props. So we use React Context to populate
// the value without prop-drilling.
// TODO: Replace with React.cache + React.use when migrating to Next.js App Router
import {createContext, useContext} from 'react';
const notInErrorDecoderContext = Symbol('not in error decoder context');
export const ErrorDecoderContext = createContext<
| {errorMessage: string | null; errorCode: string | null}
| typeof notInErrorDecoderContext
>(notInErrorDecoderContext);
export const useErrorDecoderParams = () => {
const params = useContext(ErrorDecoderContext);
if (params === notInErrorDecoderContext) {
throw new Error('useErrorDecoder must be used in error decoder pages only');
}
return params;
};

View File

@@ -0,0 +1,107 @@
import {useEffect, useState} from 'react';
import {useErrorDecoderParams} from '../ErrorDecoderContext';
import cn from 'classnames';
function replaceArgs(
msg: string,
argList: Array<string | undefined>,
replacer = '[missing argument]'
): string {
let argIdx = 0;
return msg.replace(/%s/g, function () {
const arg = argList[argIdx++];
// arg can be an empty string: ?args[0]=&args[1]=count
return arg === undefined || arg === '' ? replacer : arg;
});
}
/**
* Sindre Sorhus <https://sindresorhus.com>
* Released under MIT license
* https://github.com/sindresorhus/linkify-urls/blob/edd75a64a9c36d7025f102f666ddbb6cf0afa7cd/index.js#L4C25-L4C137
*
* The regex is used to extract URL from the string for linkify.
*/
const urlRegex =
/((?<!\+)https?:\/\/(?:www\.)?(?:[-\w.]+?[.@][a-zA-Z\d]{2,}|localhost)(?:[-\w.:%+~#*$!?&/=@]*?(?:,(?!\s))*?)*)/g;
// When the message contains a URL (like https://fb.me/react-refs-must-have-owner),
// make it a clickable link.
function urlify(str: string): React.ReactNode[] {
const segments = str.split(urlRegex);
return segments.map((message, i) => {
if (i % 2 === 1) {
return (
<a
key={i}
target="_blank"
className="underline"
rel="noopener noreferrer"
href={message}>
{message}
</a>
);
}
return message;
});
}
// `?args[]=foo&args[]=bar`
// or `// ?args[0]=foo&args[1]=bar`
function parseQueryString(search: string): Array<string | undefined> {
const rawQueryString = search.substring(1);
if (!rawQueryString) {
return [];
}
const args: Array<string | undefined> = [];
const queries = rawQueryString.split('&');
for (let i = 0; i < queries.length; i++) {
const query = decodeURIComponent(queries[i]);
if (query.startsWith('args[')) {
args.push(query.slice(query.indexOf(']=') + 2));
}
}
return args;
}
export default function ErrorDecoder() {
const {errorMessage} = useErrorDecoderParams();
/** error messages that contain %s require reading location.search */
const hasParams = errorMessage?.includes('%s');
const [message, setMessage] = useState<React.ReactNode | null>(() =>
errorMessage ? urlify(errorMessage) : null
);
const [isReady, setIsReady] = useState(errorMessage == null || !hasParams);
useEffect(() => {
if (errorMessage == null || !hasParams) {
return;
}
setMessage(
urlify(
replaceArgs(
errorMessage,
parseQueryString(window.location.search),
'[missing argument]'
)
)
);
setIsReady(true);
}, [hasParams, errorMessage]);
return (
<code
className={cn(
'block bg-red-100 text-red-600 py-4 px-6 mt-5 rounded-lg',
isReady ? 'opacity-100' : 'opacity-0'
)}>
<b>{message}</b>
</code>
);
}

View File

@@ -31,6 +31,8 @@ import {TocContext} from './TocContext';
import type {Toc, TocItem} from './TocContext';
import {TeamMember} from './TeamMember';
import ErrorDecoder from './ErrorDecoder';
function CodeStep({children, step}: {children: any; step: number}) {
return (
<span
@@ -441,6 +443,7 @@ export const MDXComponents = {
Solution,
CodeStep,
YouTubeIframe,
ErrorDecoder,
};
for (let key in MDXComponents) {

13
src/content/errors/377.md Normal file
View File

@@ -0,0 +1,13 @@
<Intro>
In the minified production build of React, we avoid sending down full error messages in order to reduce the number of bytes sent over the wire.
</Intro>
We highly recommend using the development build locally when debugging your app since it tracks additional debug info and provides helpful warnings about potential problems in your apps, but if you encounter an exception while using the production build, this page will reassemble the original error message.
The full text of the error you just encountered is:
<ErrorDecoder />
This error occurs when you pass a BigInt value from a Server Component to a Client Component.

View File

@@ -0,0 +1,11 @@
<Intro>
In the minified production build of React, we avoid sending down full error messages in order to reduce the number of bytes sent over the wire.
</Intro>
We highly recommend using the development build locally when debugging your app since it tracks additional debug info and provides helpful warnings about potential problems in your apps, but if you encounter an exception while using the production build, this page will reassemble the original error message.
The full text of the error you just encountered is:
<ErrorDecoder />

View File

@@ -0,0 +1,10 @@
<Intro>
In the minified production build of React, we avoid sending down full error messages in order to reduce the number of bytes sent over the wire.
</Intro>
We highly recommend using the development build locally when debugging your app since it tracks additional debug info and provides helpful warnings about potential problems in your apps, but if you encounter an exception while using the production build, the error message will include just a link to the docs for the error.
For an example, see: [https://react.dev/errors/149](/errors/421).

View File

@@ -4,14 +4,14 @@
import {Fragment, useMemo} from 'react';
import {useRouter} from 'next/router';
import {MDXComponents} from 'components/MDX/MDXComponents';
import {Page} from 'components/Layout/Page';
import sidebarHome from '../sidebarHome.json';
import sidebarLearn from '../sidebarLearn.json';
import sidebarReference from '../sidebarReference.json';
import sidebarCommunity from '../sidebarCommunity.json';
import sidebarBlog from '../sidebarBlog.json';
import {MDXComponents} from 'components/MDX/MDXComponents';
import compileMDX from 'utils/compileMDX';
export default function Layout({content, toc, meta}) {
const parsedContent = useMemo(
() => JSON.parse(content, reviveNodeOnClient),
@@ -94,20 +94,10 @@ function reviveNodeOnClient(key, val) {
}
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~ IMPORTANT: BUMP THIS IF YOU CHANGE ANY CODE BELOW ~~~
const DISK_CACHE_BREAKER = 7;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Put MDX output into JSON for client.
export async function getStaticProps(context) {
const fs = require('fs');
const {
prepareMDX,
PREPARE_MDX_CACHE_BREAKER,
} = require('../utils/prepareMDX');
const rootDir = process.cwd() + '/src/content/';
const mdxComponentNames = Object.keys(MDXComponents);
// Read MDX from the file.
let path = (context.params.markdownPath || []).join('/') || 'index';
@@ -118,132 +108,14 @@ export async function getStaticProps(context) {
mdx = fs.readFileSync(rootDir + path + '/index.md', 'utf8');
}
// See if we have a cached output first.
const {FileStore, stableHash} = require('metro-cache');
const store = new FileStore({
root: process.cwd() + '/node_modules/.cache/react-docs-mdx/',
});
const hash = Buffer.from(
stableHash({
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~ IMPORTANT: Everything that the code below may rely on.
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
mdx,
mdxComponentNames,
DISK_CACHE_BREAKER,
PREPARE_MDX_CACHE_BREAKER,
lockfile: fs.readFileSync(process.cwd() + '/yarn.lock', 'utf8'),
})
);
const cached = await store.get(hash);
if (cached) {
console.log(
'Reading compiled MDX for /' + path + ' from ./node_modules/.cache/'
);
return cached;
}
if (process.env.NODE_ENV === 'production') {
console.log(
'Cache miss for MDX for /' + path + ' from ./node_modules/.cache/'
);
}
// If we don't add these fake imports, the MDX compiler
// will insert a bunch of opaque components we can't introspect.
// This will break the prepareMDX() call below.
let mdxWithFakeImports =
mdx +
'\n\n' +
mdxComponentNames
.map((key) => 'import ' + key + ' from "' + key + '";\n')
.join('\n');
// Turn the MDX we just read into some JS we can execute.
const {remarkPlugins} = require('../../plugins/markdownToHtml');
const {compile: compileMdx} = await import('@mdx-js/mdx');
const visit = (await import('unist-util-visit')).default;
const jsxCode = await compileMdx(mdxWithFakeImports, {
remarkPlugins: [
...remarkPlugins,
(await import('remark-gfm')).default,
(await import('remark-frontmatter')).default,
],
rehypePlugins: [
// Support stuff like ```js App.js {1-5} active by passing it through.
function rehypeMetaAsAttributes() {
return (tree) => {
visit(tree, 'element', (node) => {
if (node.tagName === 'code' && node.data && node.data.meta) {
node.properties.meta = node.data.meta;
}
});
};
},
],
});
const {transform} = require('@babel/core');
const jsCode = await transform(jsxCode, {
plugins: ['@babel/plugin-transform-modules-commonjs'],
presets: ['@babel/preset-react'],
}).code;
// Prepare environment for MDX.
let fakeExports = {};
const fakeRequire = (name) => {
if (name === 'react/jsx-runtime') {
return require('react/jsx-runtime');
} else {
// For each fake MDX import, give back the string component name.
// It will get serialized later.
return name;
}
};
const evalJSCode = new Function('require', 'exports', jsCode);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// THIS IS A BUILD-TIME EVAL. NEVER DO THIS WITH UNTRUSTED MDX (LIKE FROM CMS)!!!
// In this case it's okay because anyone who can edit our MDX can also edit this file.
evalJSCode(fakeRequire, fakeExports);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
const reactTree = fakeExports.default({});
// Pre-process MDX output and serialize it.
let {toc, children} = prepareMDX(reactTree.props.children);
if (path === 'index') {
toc = [];
}
// Parse Frontmatter headers from MDX.
const fm = require('gray-matter');
const meta = fm(mdx).data;
const output = {
const {toc, content, meta} = await compileMDX(mdx, path, {});
return {
props: {
content: JSON.stringify(children, stringifyNodeOnServer),
toc: JSON.stringify(toc, stringifyNodeOnServer),
toc,
content,
meta,
},
};
// Serialize a server React tree node to JSON.
function stringifyNodeOnServer(key, val) {
if (val != null && val.$$typeof === Symbol.for('react.element')) {
// Remove fake MDX props.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {mdxType, originalType, parentName, ...cleanProps} = val.props;
return [
'$r',
typeof val.type === 'string' ? val.type : mdxType,
val.key,
cleanProps,
];
} else {
return val;
}
}
// Cache it on the disk.
await store.set(hash, output);
return output;
}
// Collect all MDX files for static generation.
@@ -266,7 +138,12 @@ export async function getStaticPaths() {
: res.slice(rootDir.length + 1);
})
);
return files.flat().filter((file) => file.endsWith('.md'));
return (
files
.flat()
// ignores `errors/*.md`, they will be handled by `pages/errors/[errorCode].tsx`
.filter((file) => file.endsWith('.md') && !file.startsWith('errors/'))
);
}
// 'foo/bar/baz.md' -> ['foo', 'bar', 'baz']
@@ -280,6 +157,7 @@ export async function getStaticPaths() {
}
const files = await getFiles(rootDir);
const paths = files.map((file) => ({
params: {
markdownPath: getSegments(file),

View File

@@ -0,0 +1,157 @@
import {Fragment, useMemo} from 'react';
import {Page} from 'components/Layout/Page';
import {MDXComponents} from 'components/MDX/MDXComponents';
import sidebarLearn from 'sidebarLearn.json';
import type {RouteItem} from 'components/Layout/getRouteMeta';
import {GetStaticPaths, GetStaticProps, InferGetStaticPropsType} from 'next';
import {ErrorDecoderContext} from 'components/ErrorDecoderContext';
import compileMDX from 'utils/compileMDX';
interface ErrorDecoderProps {
errorCode: string | null;
errorMessage: string | null;
content: string;
toc: string;
meta: any;
}
export default function ErrorDecoderPage({
errorMessage,
errorCode,
content,
}: InferGetStaticPropsType<typeof getStaticProps>) {
const parsedContent = useMemo<React.ReactNode>(
() => JSON.parse(content, reviveNodeOnClient),
[content]
);
return (
<ErrorDecoderContext.Provider value={{errorMessage, errorCode}}>
<Page
toc={[]}
meta={{
title: errorCode
? `Minified React error #${errorCode}`
: 'Minified Error Decoder',
}}
routeTree={sidebarLearn as RouteItem}
section="unknown">
{parsedContent}
{/* <MaxWidth>
<P>
We highly recommend using the development build locally when debugging
your app since it tracks additional debug info and provides helpful
warnings about potential problems in your apps, but if you encounter
an exception while using the production build, this page will
reassemble the original error message.
</P>
<ErrorDecoder />
</MaxWidth> */}
</Page>
</ErrorDecoderContext.Provider>
);
}
// Deserialize a client React tree from JSON.
function reviveNodeOnClient(key: unknown, val: any) {
if (Array.isArray(val) && val[0] == '$r') {
// Assume it's a React element.
let type = val[1];
let key = val[2];
let props = val[3];
if (type === 'wrapper') {
type = Fragment;
props = {children: props.children};
}
if (type in MDXComponents) {
type = MDXComponents[type as keyof typeof MDXComponents];
}
if (!type) {
console.error('Unknown type: ' + type);
type = Fragment;
}
return {
$$typeof: Symbol.for('react.element'),
type: type,
key: key,
ref: null,
props: props,
_owner: null,
};
} else {
return val;
}
}
/**
* Next.js Page Router doesn't have a way to cache specific data fetching request.
* But since Next.js uses limited number of workers, keep "cachedErrorCodes" as a
* module level memory cache can reduce the number of requests down to once per worker.
*
* TODO: use `next/unstable_cache` when migrating to Next.js App Router
*/
let cachedErrorCodes: Record<string, string> | null = null;
export const getStaticProps: GetStaticProps<ErrorDecoderProps> = async ({
params,
}) => {
const errorCodes: {[key: string]: string} = (cachedErrorCodes ||= await (
await fetch(
'https://raw.githubusercontent.com/facebook/react/main/scripts/error-codes/codes.json'
)
).json());
const code = typeof params?.errorCode === 'string' ? params?.errorCode : null;
if (code && !errorCodes[code]) {
return {
notFound: true,
};
}
const fs = require('fs');
const rootDir = process.cwd() + '/src/content/errors';
// Read MDX from the file.
let path = params?.errorCode || 'index';
let mdx;
try {
mdx = fs.readFileSync(rootDir + '/' + path + '.md', 'utf8');
} catch {
// if [errorCode].md is not found, fallback to generic.md
mdx = fs.readFileSync(rootDir + '/generic.md', 'utf8');
}
const {content, toc, meta} = await compileMDX(mdx, path, {code, errorCodes});
return {
props: {
content,
toc,
meta,
errorCode: code,
errorMessage: code ? errorCodes[code] : null,
},
};
};
export const getStaticPaths: GetStaticPaths = async () => {
/**
* Fetch error codes from GitHub
*/
const errorCodes = (cachedErrorCodes ||= await (
await fetch(
'https://raw.githubusercontent.com/facebook/react/main/scripts/error-codes/codes.json'
)
).json());
const paths = Object.keys(errorCodes).map((code) => ({
params: {
errorCode: code,
},
}));
return {
paths,
fallback: 'blocking',
};
};

View File

@@ -0,0 +1,3 @@
import ErrorDecoderPage from './[errorCode]';
export default ErrorDecoderPage;
export {getStaticProps} from './[errorCode]';

153
src/utils/compileMDX.ts Normal file
View File

@@ -0,0 +1,153 @@
import {MDXComponents} from 'components/MDX/MDXComponents';
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~ IMPORTANT: BUMP THIS IF YOU CHANGE ANY CODE BELOW ~~~
const DISK_CACHE_BREAKER = 8;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
export default async function compileMDX(
mdx: string,
path: string | string[],
params: {[key: string]: any}
): Promise<{content: string; toc: string; meta: any}> {
const fs = require('fs');
const {
prepareMDX,
PREPARE_MDX_CACHE_BREAKER,
} = require('../utils/prepareMDX');
const mdxComponentNames = Object.keys(MDXComponents);
// See if we have a cached output first.
const {FileStore, stableHash} = require('metro-cache');
const store = new FileStore({
root: process.cwd() + '/node_modules/.cache/react-docs-mdx/',
});
const hash = Buffer.from(
stableHash({
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~ IMPORTANT: Everything that the code below may rely on.
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
mdx,
...params,
mdxComponentNames,
DISK_CACHE_BREAKER,
PREPARE_MDX_CACHE_BREAKER,
lockfile: fs.readFileSync(process.cwd() + '/yarn.lock', 'utf8'),
})
);
const cached = await store.get(hash);
if (cached) {
console.log(
'Reading compiled MDX for /' + path + ' from ./node_modules/.cache/'
);
return cached;
}
if (process.env.NODE_ENV === 'production') {
console.log(
'Cache miss for MDX for /' + path + ' from ./node_modules/.cache/'
);
}
// If we don't add these fake imports, the MDX compiler
// will insert a bunch of opaque components we can't introspect.
// This will break the prepareMDX() call below.
let mdxWithFakeImports =
mdx +
'\n\n' +
mdxComponentNames
.map((key) => 'import ' + key + ' from "' + key + '";\n')
.join('\n');
// Turn the MDX we just read into some JS we can execute.
const {remarkPlugins} = require('../../plugins/markdownToHtml');
const {compile: compileMdx} = await import('@mdx-js/mdx');
const visit = (await import('unist-util-visit')).default;
const jsxCode = await compileMdx(mdxWithFakeImports, {
remarkPlugins: [
...remarkPlugins,
(await import('remark-gfm')).default,
(await import('remark-frontmatter')).default,
],
rehypePlugins: [
// Support stuff like ```js App.js {1-5} active by passing it through.
function rehypeMetaAsAttributes() {
return (tree) => {
visit(tree, 'element', (node) => {
if (
// @ts-expect-error -- tagName is a valid property
node.tagName === 'code' &&
node.data &&
node.data.meta
) {
// @ts-expect-error -- properties is a valid property
node.properties.meta = node.data.meta;
}
});
};
},
],
});
const {transform} = require('@babel/core');
const jsCode = await transform(jsxCode, {
plugins: ['@babel/plugin-transform-modules-commonjs'],
presets: ['@babel/preset-react'],
}).code;
// Prepare environment for MDX.
let fakeExports = {};
const fakeRequire = (name: string) => {
if (name === 'react/jsx-runtime') {
return require('react/jsx-runtime');
} else {
// For each fake MDX import, give back the string component name.
// It will get serialized later.
return name;
}
};
const evalJSCode = new Function('require', 'exports', jsCode);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// THIS IS A BUILD-TIME EVAL. NEVER DO THIS WITH UNTRUSTED MDX (LIKE FROM CMS)!!!
// In this case it's okay because anyone who can edit our MDX can also edit this file.
evalJSCode(fakeRequire, fakeExports);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// @ts-expect-error -- default exports is existed after eval
const reactTree = fakeExports.default({});
// Pre-process MDX output and serialize it.
let {toc, children} = prepareMDX(reactTree.props.children);
if (path === 'index') {
toc = [];
}
// Parse Frontmatter headers from MDX.
const fm = require('gray-matter');
const meta = fm(mdx).data;
const output = {
content: JSON.stringify(children, stringifyNodeOnServer),
toc: JSON.stringify(toc, stringifyNodeOnServer),
meta,
};
// Serialize a server React tree node to JSON.
function stringifyNodeOnServer(key: unknown, val: any) {
if (val != null && val.$$typeof === Symbol.for('react.element')) {
// Remove fake MDX props.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {mdxType, originalType, parentName, ...cleanProps} = val.props;
return [
'$r',
typeof val.type === 'string' ? val.type : mdxType,
val.key,
cleanProps,
];
} else {
return val;
}
}
// Cache it on the disk.
await store.set(hash, output);
return output;
}

View File

@@ -106,6 +106,7 @@ module.exports = {
marquee2: 'marquee2 40s linear infinite',
'large-marquee': 'large-marquee 80s linear infinite',
'large-marquee2': 'large-marquee2 80s linear infinite',
'fade-up': 'fade-up 1s 100ms both',
},
keyframes: {
shimmer: {
@@ -143,6 +144,16 @@ module.exports = {
'0%': {transform: 'translateX(200%)'},
'100%': {transform: 'translateX(0%)'},
},
'fade-up': {
'0%': {
opacity: '0',
transform: 'translateY(2rem)',
},
'100%': {
opacity: '1',
transform: 'translateY(0)',
},
},
},
colors,
gridTemplateColumns: {