Skip to content

Commit b416530

Browse files
committed
Refactor: read error page from markdown
1 parent f2d8160 commit b416530

File tree

5 files changed

+258
-29
lines changed

5 files changed

+258
-29
lines changed

src/components/ErrorDecoder.tsx renamed to src/components/MDX/ErrorDecoder.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {useEffect, useState} from 'react';
2+
import {useErrorDecoder} from './ErrorDecoderContext';
23

34
function replaceArgs(
45
msg: string,
@@ -66,11 +67,9 @@ function parseQueryString(search: string): Array<string | undefined> {
6667
return args;
6768
}
6869

69-
interface ErrorDecoderProps {
70-
errorMessages: string | null;
71-
}
70+
export default function ErrorDecoder() {
71+
const errorMessages = useErrorDecoder();
7272

73-
export default function ErrorDecoder({errorMessages}: ErrorDecoderProps) {
7473
/** error messages that contain %s require reading location.search */
7574
const [message, setMessage] = useState<React.ReactNode | null>(() =>
7675
errorMessages ? urlify(errorMessages) : null
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Error Decoder requires reading pregenerated error message from getStaticProps,
2+
// but MDX component doesn't support props. So we use React Context to populate
3+
// the value without prop-drilling.
4+
// TODO: Replace with React.cache + React.use when migrating to Next.js App Router
5+
6+
import {createContext, useContext} from 'react';
7+
8+
export const ErrorDecoderContext = createContext<string | null>(null);
9+
10+
export const useErrorDecoder = () => {
11+
const errorMessages = useContext(ErrorDecoderContext);
12+
13+
if (errorMessages === null) {
14+
throw new Error('useErrorDecoder must be used in error decoder pages only');
15+
}
16+
17+
return errorMessages;
18+
};

src/components/MDX/MDXComponents.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {TocContext} from './TocContext';
3131
import type {Toc, TocItem} from './TocContext';
3232
import {TeamMember} from './TeamMember';
3333

34+
import ErrorDecoder from './ErrorDecoder';
35+
3436
function CodeStep({children, step}: {children: any; step: number}) {
3537
return (
3638
<span
@@ -435,6 +437,8 @@ export const MDXComponents = {
435437
Solution,
436438
CodeStep,
437439
YouTubeIframe,
440+
441+
ErrorDecoder,
438442
};
439443

440444
for (let key in MDXComponents) {

src/content/errors/default.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<Intro>
2+
3+
In the minified production build of React, we avoid sending down full error messages in order to reduce the number of bytes sent overthe wire.
4+
5+
</Intro>
6+
7+
8+
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.
9+
10+
11+
<ErrorDecoder />

src/pages/errors/[error_code].tsx

Lines changed: 222 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,80 @@
1+
import {Fragment, useMemo} from 'react';
12
import {Page} from 'components/Layout/Page';
23
import {MDXComponents} from 'components/MDX/MDXComponents';
3-
import ErrorDecoder from 'components/ErrorDecoder';
44
import sidebarLearn from 'sidebarLearn.json';
55
import type {RouteItem} from 'components/Layout/getRouteMeta';
66
import {GetStaticPaths, GetStaticProps, InferGetStaticPropsType} from 'next';
7-
8-
const {MaxWidth, p: P, Intro} = MDXComponents;
7+
import {ErrorDecoderContext} from '../../components/MDX/ErrorDecoderContext';
98

109
interface ErrorDecoderProps {
1110
errorCode: string;
1211
errorMessages: string;
12+
content: string;
1313
}
1414

1515
export default function ErrorDecoderPage({
1616
errorMessages,
17+
content,
1718
}: InferGetStaticPropsType<typeof getStaticProps>) {
19+
const parsedContent = useMemo<React.ReactNode>(
20+
() => JSON.parse(content, reviveNodeOnClient),
21+
[content]
22+
);
23+
1824
return (
19-
<Page
20-
toc={[]}
21-
meta={{title: 'Error Decoder'}}
22-
routeTree={sidebarLearn as RouteItem}
23-
section="unknown">
24-
<MaxWidth>
25-
<Intro>
25+
<ErrorDecoderContext.Provider value={errorMessages}>
26+
<Page
27+
toc={[]}
28+
meta={{title: 'Error Decoder'}}
29+
routeTree={sidebarLearn as RouteItem}
30+
section="unknown">
31+
{parsedContent}
32+
{/* <MaxWidth>
2633
<P>
27-
In the minified production build of React, we avoid sending down
28-
full error messages in order to reduce the number of bytes sent over
29-
the wire.
34+
We highly recommend using the development build locally when debugging
35+
your app since it tracks additional debug info and provides helpful
36+
warnings about potential problems in your apps, but if you encounter
37+
an exception while using the production build, this page will
38+
reassemble the original error message.
3039
</P>
31-
</Intro>
32-
<P>
33-
We highly recommend using the development build locally when debugging
34-
your app since it tracks additional debug info and provides helpful
35-
warnings about potential problems in your apps, but if you encounter
36-
an exception while using the production build, this page will
37-
reassemble the original error message.
38-
</P>
39-
<ErrorDecoder errorMessages={errorMessages} />
40-
</MaxWidth>
41-
</Page>
40+
<ErrorDecoder />
41+
</MaxWidth> */}
42+
</Page>
43+
</ErrorDecoderContext.Provider>
4244
);
4345
}
4446

47+
// Deserialize a client React tree from JSON.
48+
function reviveNodeOnClient(key: unknown, val: any) {
49+
if (Array.isArray(val) && val[0] == '$r') {
50+
// Assume it's a React element.
51+
let type = val[1];
52+
let key = val[2];
53+
let props = val[3];
54+
if (type === 'wrapper') {
55+
type = Fragment;
56+
props = {children: props.children};
57+
}
58+
if (type in MDXComponents) {
59+
type = MDXComponents[type as keyof typeof MDXComponents];
60+
}
61+
if (!type) {
62+
console.error('Unknown type: ' + type);
63+
type = Fragment;
64+
}
65+
return {
66+
$$typeof: Symbol.for('react.element'),
67+
type: type,
68+
key: key,
69+
ref: null,
70+
props: props,
71+
_owner: null,
72+
};
73+
} else {
74+
return val;
75+
}
76+
}
77+
4578
/**
4679
* Next.js Page Router doesn't have a way to cache specific data fetching request.
4780
* But since Next.js uses limited number of workers, keep "cachedErrorCodes" as a
@@ -68,15 +101,179 @@ export const getStaticProps: GetStaticProps<ErrorDecoderProps> = async ({
68101
};
69102
}
70103

71-
return {
104+
/**
105+
* Read Markdown files from disk and render into MDX, then into JSON
106+
*
107+
* This is copied from [[...markdownPath]].js
108+
*
109+
* TODO: build a shared function
110+
*/
111+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
112+
// ~~~~ IMPORTANT: BUMP THIS IF YOU CHANGE ANY CODE BELOW ~~~
113+
const DISK_CACHE_BREAKER = 0;
114+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
115+
const fs = require('fs');
116+
const {
117+
prepareMDX,
118+
PREPARE_MDX_CACHE_BREAKER,
119+
} = require('../../utils/prepareMDX');
120+
const rootDir = process.cwd() + '/src/content/errors';
121+
const mdxComponentNames = Object.keys(MDXComponents);
122+
123+
// Read MDX from the file.
124+
let path = params?.error_code || 'default';
125+
let mdx;
126+
try {
127+
mdx = fs.readFileSync(rootDir + path + '.md', 'utf8');
128+
} catch {
129+
// if error_code.md is not found, fallback to default.md
130+
mdx = fs.readFileSync(rootDir + '/default.md', 'utf8');
131+
}
132+
133+
// See if we have a cached output first.
134+
const {FileStore, stableHash} = require('metro-cache');
135+
const store = new FileStore({
136+
root: process.cwd() + '/node_modules/.cache/react-docs-mdx/',
137+
});
138+
const hash = Buffer.from(
139+
stableHash({
140+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
141+
// ~~~~ IMPORTANT: Everything that the code below may rely on.
142+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
143+
mdx,
144+
mdxComponentNames,
145+
DISK_CACHE_BREAKER,
146+
PREPARE_MDX_CACHE_BREAKER,
147+
lockfile: fs.readFileSync(process.cwd() + '/yarn.lock', 'utf8'),
148+
})
149+
);
150+
const cached = await store.get(hash);
151+
if (cached) {
152+
console.log(
153+
'Reading compiled MDX for /' + path + ' from ./node_modules/.cache/'
154+
);
155+
return cached;
156+
}
157+
if (process.env.NODE_ENV === 'production') {
158+
console.log(
159+
'Cache miss for MDX for /' + path + ' from ./node_modules/.cache/'
160+
);
161+
}
162+
163+
// If we don't add these fake imports, the MDX compiler
164+
// will insert a bunch of opaque components we can't introspect.
165+
// This will break the prepareMDX() call below.
166+
let mdxWithFakeImports =
167+
mdx +
168+
'\n\n' +
169+
mdxComponentNames
170+
.map((key) => 'import ' + key + ' from "' + key + '";\n')
171+
.join('\n');
172+
173+
console.log({mdxComponentNames});
174+
175+
// Turn the MDX we just read into some JS we can execute.
176+
const {remarkPlugins} = require('../../../plugins/markdownToHtml');
177+
const {compile: compileMdx} = await import('@mdx-js/mdx');
178+
const visit = (await import('unist-util-visit')).default;
179+
const jsxCode = await compileMdx(mdxWithFakeImports, {
180+
remarkPlugins: [
181+
...remarkPlugins,
182+
(await import('remark-gfm')).default,
183+
(await import('remark-frontmatter')).default,
184+
],
185+
rehypePlugins: [
186+
// Support stuff like ```js App.js {1-5} active by passing it through.
187+
function rehypeMetaAsAttributes() {
188+
return (tree) => {
189+
visit(tree, 'element', (node) => {
190+
if (
191+
'tagName' in node &&
192+
typeof node.tagName === 'string' &&
193+
node.tagName === 'code' &&
194+
node.data &&
195+
node.data.meta
196+
) {
197+
// @ts-expect-error -- properties is a valid property
198+
node.properties.meta = node.data.meta;
199+
}
200+
});
201+
};
202+
},
203+
],
204+
});
205+
const {transform} = require('@babel/core');
206+
const jsCode = await transform(jsxCode, {
207+
plugins: ['@babel/plugin-transform-modules-commonjs'],
208+
presets: ['@babel/preset-react'],
209+
}).code;
210+
211+
// Prepare environment for MDX.
212+
let fakeExports = {};
213+
const fakeRequire = (name: string) => {
214+
if (name === 'react/jsx-runtime') {
215+
return require('react/jsx-runtime');
216+
} else {
217+
// For each fake MDX import, give back the string component name.
218+
// It will get serialized later.
219+
return name;
220+
}
221+
};
222+
const evalJSCode = new Function('require', 'exports', jsCode);
223+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
224+
// THIS IS A BUILD-TIME EVAL. NEVER DO THIS WITH UNTRUSTED MDX (LIKE FROM CMS)!!!
225+
// In this case it's okay because anyone who can edit our MDX can also edit this file.
226+
evalJSCode(fakeRequire, fakeExports);
227+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
228+
// @ts-expect-error -- default exports is existed after eval
229+
const reactTree = fakeExports.default({});
230+
231+
// Pre-process MDX output and serialize it.
232+
let {toc, children} = prepareMDX(reactTree.props.children);
233+
if (path === 'index') {
234+
toc = [];
235+
}
236+
237+
// Parse Frontmatter headers from MDX.
238+
const fm = require('gray-matter');
239+
const meta = fm(mdx).data;
240+
241+
const output = {
72242
props: {
243+
content: JSON.stringify(children, stringifyNodeOnServer),
73244
errorCode: code,
74245
errorMessages: errorCodes[code],
246+
toc: JSON.stringify(toc, stringifyNodeOnServer),
247+
meta,
75248
},
76249
};
250+
251+
// Serialize a server React tree node to JSON.
252+
function stringifyNodeOnServer(key: unknown, val: any) {
253+
if (val != null && val.$$typeof === Symbol.for('react.element')) {
254+
// Remove fake MDX props.
255+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
256+
const {mdxType, originalType, parentName, ...cleanProps} = val.props;
257+
return [
258+
'$r',
259+
typeof val.type === 'string' ? val.type : mdxType,
260+
val.key,
261+
cleanProps,
262+
];
263+
} else {
264+
return val;
265+
}
266+
}
267+
268+
// Cache it on the disk.
269+
await store.set(hash, output);
270+
return output;
77271
};
78272

79273
export const getStaticPaths: GetStaticPaths = async () => {
274+
/**
275+
* Fetch error codes from GitHub
276+
*/
80277
const errorCodes = (cachedErrorCodes ||= await (
81278
await fetch(
82279
'https://raw.githubusercontent.com/facebook/react/main/scripts/error-codes/codes.json'

0 commit comments

Comments
 (0)