Skip to content

Commit 8d2664b

Browse files
SukkaWrickhanlonii
andauthored
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 <[email protected]>
1 parent 6987f0f commit 8d2664b

File tree

11 files changed

+504
-135
lines changed

11 files changed

+504
-135
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
const notInErrorDecoderContext = Symbol('not in error decoder context');
9+
10+
export const ErrorDecoderContext = createContext<
11+
| {errorMessage: string | null; errorCode: string | null}
12+
| typeof notInErrorDecoderContext
13+
>(notInErrorDecoderContext);
14+
15+
export const useErrorDecoderParams = () => {
16+
const params = useContext(ErrorDecoderContext);
17+
18+
if (params === notInErrorDecoderContext) {
19+
throw new Error('useErrorDecoder must be used in error decoder pages only');
20+
}
21+
22+
return params;
23+
};

src/components/MDX/ErrorDecoder.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {useEffect, useState} from 'react';
2+
import {useErrorDecoderParams} from '../ErrorDecoderContext';
3+
import cn from 'classnames';
4+
5+
function replaceArgs(
6+
msg: string,
7+
argList: Array<string | undefined>,
8+
replacer = '[missing argument]'
9+
): string {
10+
let argIdx = 0;
11+
return msg.replace(/%s/g, function () {
12+
const arg = argList[argIdx++];
13+
// arg can be an empty string: ?args[0]=&args[1]=count
14+
return arg === undefined || arg === '' ? replacer : arg;
15+
});
16+
}
17+
18+
/**
19+
* Sindre Sorhus <https://sindresorhus.com>
20+
* Released under MIT license
21+
* https://github.com/sindresorhus/linkify-urls/blob/edd75a64a9c36d7025f102f666ddbb6cf0afa7cd/index.js#L4C25-L4C137
22+
*
23+
* The regex is used to extract URL from the string for linkify.
24+
*/
25+
const urlRegex =
26+
/((?<!\+)https?:\/\/(?:www\.)?(?:[-\w.]+?[.@][a-zA-Z\d]{2,}|localhost)(?:[-\w.:%+~#*$!?&/=@]*?(?:,(?!\s))*?)*)/g;
27+
28+
// When the message contains a URL (like https://fb.me/react-refs-must-have-owner),
29+
// make it a clickable link.
30+
function urlify(str: string): React.ReactNode[] {
31+
const segments = str.split(urlRegex);
32+
33+
return segments.map((message, i) => {
34+
if (i % 2 === 1) {
35+
return (
36+
<a
37+
key={i}
38+
target="_blank"
39+
className="underline"
40+
rel="noopener noreferrer"
41+
href={message}>
42+
{message}
43+
</a>
44+
);
45+
}
46+
return message;
47+
});
48+
}
49+
50+
// `?args[]=foo&args[]=bar`
51+
// or `// ?args[0]=foo&args[1]=bar`
52+
function parseQueryString(search: string): Array<string | undefined> {
53+
const rawQueryString = search.substring(1);
54+
if (!rawQueryString) {
55+
return [];
56+
}
57+
58+
const args: Array<string | undefined> = [];
59+
60+
const queries = rawQueryString.split('&');
61+
for (let i = 0; i < queries.length; i++) {
62+
const query = decodeURIComponent(queries[i]);
63+
if (query.startsWith('args[')) {
64+
args.push(query.slice(query.indexOf(']=') + 2));
65+
}
66+
}
67+
68+
return args;
69+
}
70+
71+
export default function ErrorDecoder() {
72+
const {errorMessage} = useErrorDecoderParams();
73+
/** error messages that contain %s require reading location.search */
74+
const hasParams = errorMessage?.includes('%s');
75+
const [message, setMessage] = useState<React.ReactNode | null>(() =>
76+
errorMessage ? urlify(errorMessage) : null
77+
);
78+
79+
const [isReady, setIsReady] = useState(errorMessage == null || !hasParams);
80+
81+
useEffect(() => {
82+
if (errorMessage == null || !hasParams) {
83+
return;
84+
}
85+
86+
setMessage(
87+
urlify(
88+
replaceArgs(
89+
errorMessage,
90+
parseQueryString(window.location.search),
91+
'[missing argument]'
92+
)
93+
)
94+
);
95+
setIsReady(true);
96+
}, [hasParams, errorMessage]);
97+
98+
return (
99+
<code
100+
className={cn(
101+
'block bg-red-100 text-red-600 py-4 px-6 mt-5 rounded-lg',
102+
isReady ? 'opacity-100' : 'opacity-0'
103+
)}>
104+
<b>{message}</b>
105+
</code>
106+
);
107+
}

src/components/MDX/MDXComponents.tsx

Lines changed: 3 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
@@ -441,6 +443,7 @@ export const MDXComponents = {
441443
Solution,
442444
CodeStep,
443445
YouTubeIframe,
446+
ErrorDecoder,
444447
};
445448

446449
for (let key in MDXComponents) {

src/content/errors/377.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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 over the wire.
4+
5+
</Intro>
6+
7+
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.
8+
9+
The full text of the error you just encountered is:
10+
11+
<ErrorDecoder />
12+
13+
This error occurs when you pass a BigInt value from a Server Component to a Client Component.

src/content/errors/generic.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 over the wire.
4+
5+
</Intro>
6+
7+
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.
8+
9+
The full text of the error you just encountered is:
10+
11+
<ErrorDecoder />

src/content/errors/index.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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 over the 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, the error message will include just a link to the docs for the error.
9+
10+
For an example, see: [https://react.dev/errors/149](/errors/421).

src/pages/[[...markdownPath]].js

Lines changed: 13 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44

55
import {Fragment, useMemo} from 'react';
66
import {useRouter} from 'next/router';
7-
import {MDXComponents} from 'components/MDX/MDXComponents';
87
import {Page} from 'components/Layout/Page';
98
import sidebarHome from '../sidebarHome.json';
109
import sidebarLearn from '../sidebarLearn.json';
1110
import sidebarReference from '../sidebarReference.json';
1211
import sidebarCommunity from '../sidebarCommunity.json';
1312
import sidebarBlog from '../sidebarBlog.json';
14-
13+
import {MDXComponents} from 'components/MDX/MDXComponents';
14+
import compileMDX from 'utils/compileMDX';
1515
export default function Layout({content, toc, meta}) {
1616
const parsedContent = useMemo(
1717
() => JSON.parse(content, reviveNodeOnClient),
@@ -94,20 +94,10 @@ function reviveNodeOnClient(key, val) {
9494
}
9595
}
9696

97-
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
98-
// ~~~~ IMPORTANT: BUMP THIS IF YOU CHANGE ANY CODE BELOW ~~~
99-
const DISK_CACHE_BREAKER = 7;
100-
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
101-
10297
// Put MDX output into JSON for client.
10398
export async function getStaticProps(context) {
10499
const fs = require('fs');
105-
const {
106-
prepareMDX,
107-
PREPARE_MDX_CACHE_BREAKER,
108-
} = require('../utils/prepareMDX');
109100
const rootDir = process.cwd() + '/src/content/';
110-
const mdxComponentNames = Object.keys(MDXComponents);
111101

112102
// Read MDX from the file.
113103
let path = (context.params.markdownPath || []).join('/') || 'index';
@@ -118,132 +108,14 @@ export async function getStaticProps(context) {
118108
mdx = fs.readFileSync(rootDir + path + '/index.md', 'utf8');
119109
}
120110

121-
// See if we have a cached output first.
122-
const {FileStore, stableHash} = require('metro-cache');
123-
const store = new FileStore({
124-
root: process.cwd() + '/node_modules/.cache/react-docs-mdx/',
125-
});
126-
const hash = Buffer.from(
127-
stableHash({
128-
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
129-
// ~~~~ IMPORTANT: Everything that the code below may rely on.
130-
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
131-
mdx,
132-
mdxComponentNames,
133-
DISK_CACHE_BREAKER,
134-
PREPARE_MDX_CACHE_BREAKER,
135-
lockfile: fs.readFileSync(process.cwd() + '/yarn.lock', 'utf8'),
136-
})
137-
);
138-
const cached = await store.get(hash);
139-
if (cached) {
140-
console.log(
141-
'Reading compiled MDX for /' + path + ' from ./node_modules/.cache/'
142-
);
143-
return cached;
144-
}
145-
if (process.env.NODE_ENV === 'production') {
146-
console.log(
147-
'Cache miss for MDX for /' + path + ' from ./node_modules/.cache/'
148-
);
149-
}
150-
151-
// If we don't add these fake imports, the MDX compiler
152-
// will insert a bunch of opaque components we can't introspect.
153-
// This will break the prepareMDX() call below.
154-
let mdxWithFakeImports =
155-
mdx +
156-
'\n\n' +
157-
mdxComponentNames
158-
.map((key) => 'import ' + key + ' from "' + key + '";\n')
159-
.join('\n');
160-
161-
// Turn the MDX we just read into some JS we can execute.
162-
const {remarkPlugins} = require('../../plugins/markdownToHtml');
163-
const {compile: compileMdx} = await import('@mdx-js/mdx');
164-
const visit = (await import('unist-util-visit')).default;
165-
const jsxCode = await compileMdx(mdxWithFakeImports, {
166-
remarkPlugins: [
167-
...remarkPlugins,
168-
(await import('remark-gfm')).default,
169-
(await import('remark-frontmatter')).default,
170-
],
171-
rehypePlugins: [
172-
// Support stuff like ```js App.js {1-5} active by passing it through.
173-
function rehypeMetaAsAttributes() {
174-
return (tree) => {
175-
visit(tree, 'element', (node) => {
176-
if (node.tagName === 'code' && node.data && node.data.meta) {
177-
node.properties.meta = node.data.meta;
178-
}
179-
});
180-
};
181-
},
182-
],
183-
});
184-
const {transform} = require('@babel/core');
185-
const jsCode = await transform(jsxCode, {
186-
plugins: ['@babel/plugin-transform-modules-commonjs'],
187-
presets: ['@babel/preset-react'],
188-
}).code;
189-
190-
// Prepare environment for MDX.
191-
let fakeExports = {};
192-
const fakeRequire = (name) => {
193-
if (name === 'react/jsx-runtime') {
194-
return require('react/jsx-runtime');
195-
} else {
196-
// For each fake MDX import, give back the string component name.
197-
// It will get serialized later.
198-
return name;
199-
}
200-
};
201-
const evalJSCode = new Function('require', 'exports', jsCode);
202-
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
203-
// THIS IS A BUILD-TIME EVAL. NEVER DO THIS WITH UNTRUSTED MDX (LIKE FROM CMS)!!!
204-
// In this case it's okay because anyone who can edit our MDX can also edit this file.
205-
evalJSCode(fakeRequire, fakeExports);
206-
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
207-
const reactTree = fakeExports.default({});
208-
209-
// Pre-process MDX output and serialize it.
210-
let {toc, children} = prepareMDX(reactTree.props.children);
211-
if (path === 'index') {
212-
toc = [];
213-
}
214-
215-
// Parse Frontmatter headers from MDX.
216-
const fm = require('gray-matter');
217-
const meta = fm(mdx).data;
218-
219-
const output = {
111+
const {toc, content, meta} = await compileMDX(mdx, path, {});
112+
return {
220113
props: {
221-
content: JSON.stringify(children, stringifyNodeOnServer),
222-
toc: JSON.stringify(toc, stringifyNodeOnServer),
114+
toc,
115+
content,
223116
meta,
224117
},
225118
};
226-
227-
// Serialize a server React tree node to JSON.
228-
function stringifyNodeOnServer(key, val) {
229-
if (val != null && val.$$typeof === Symbol.for('react.element')) {
230-
// Remove fake MDX props.
231-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
232-
const {mdxType, originalType, parentName, ...cleanProps} = val.props;
233-
return [
234-
'$r',
235-
typeof val.type === 'string' ? val.type : mdxType,
236-
val.key,
237-
cleanProps,
238-
];
239-
} else {
240-
return val;
241-
}
242-
}
243-
244-
// Cache it on the disk.
245-
await store.set(hash, output);
246-
return output;
247119
}
248120

249121
// Collect all MDX files for static generation.
@@ -266,7 +138,12 @@ export async function getStaticPaths() {
266138
: res.slice(rootDir.length + 1);
267139
})
268140
);
269-
return files.flat().filter((file) => file.endsWith('.md'));
141+
return (
142+
files
143+
.flat()
144+
// ignores `errors/*.md`, they will be handled by `pages/errors/[errorCode].tsx`
145+
.filter((file) => file.endsWith('.md') && !file.startsWith('errors/'))
146+
);
270147
}
271148

272149
// 'foo/bar/baz.md' -> ['foo', 'bar', 'baz']
@@ -280,6 +157,7 @@ export async function getStaticPaths() {
280157
}
281158

282159
const files = await getFiles(rootDir);
160+
283161
const paths = files.map((file) => ({
284162
params: {
285163
markdownPath: getSegments(file),

0 commit comments

Comments
 (0)