Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: error-decoder #6214

Merged
merged 19 commits into from
Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/components/ErrorDecoder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {useMemo} from 'react';
import {useRouter} from 'next/router';

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: ?invariant=377&args[0]=&args[1]=count
rickhanlonii marked this conversation as resolved.
Show resolved Hide resolved
return arg === undefined || arg === '' ? replacer : arg;
});
}

// When the message contains a URL (like https://fb.me/react-refs-must-have-owner),
// make it a clickable link.
rickhanlonii marked this conversation as resolved.
Show resolved Hide resolved
function urlify(str: string): React.ReactNode {
const urlRegex = /(https:\/\/fb\.me\/[a-z\-]+)/g;

const segments = str.split(urlRegex);

return segments.map((message, i) => {
if (i % 2 === 1) {
return (
<a key={i} target="_blank" rel="noopener noreferrer" href={message}>
{message}
</a>
);
}
return message;
});
}

// `?invariant=123&args[]=foo&args[]=bar`
// or `// ?invariant=123&args[0]=foo&args[1]=bar`
rickhanlonii marked this conversation as resolved.
Show resolved Hide resolved
function parseQueryString(
query: ReturnType<typeof useRouter>['query']
): Array<string | undefined> {
const args: Array<string | undefined> = [];

Object.entries(query).forEach(([key, value]) => {
if (key.startsWith('args[')) {
args.push(Array.isArray(value) ? value[0] : value);
}
});

return args;
}

interface ErrorDecoderProps {
errorMessages: string | null;
}

export default function ErrorDecoder({errorMessages}: ErrorDecoderProps) {
const {isReady, query} = useRouter();

const msg = useMemo(() => {
if (!errorMessages?.includes('%s')) {
return errorMessages;
}

if (typeof window !== 'undefined' && isReady) {
return replaceArgs(
errorMessages,
parseQueryString(query),
'[missing argument]'
);
}

return replaceArgs(errorMessages, [], '[parsing argument]');
}, [errorMessages, isReady, query]);
Copy link
Contributor Author

@SukkaW SukkaW Aug 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rickhanlonii

Note that although the page will be SSG-ed at the build time, the router.query still requires client-side rendering and will only be populated after the initial hydration, thus isReady can not be avoided.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the query changes, won't that trigger an update? Why is the isReady needed?

Copy link
Contributor Author

@SukkaW SukkaW Aug 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the query changes, won't that trigger an update? Why is the isReady needed?

Only when isReady is true cab

Per Next.js documentation,: https://nextjs.org/docs/pages/building-your-application/rendering/automatic-static-optimization#how-it-works

To be able to distinguish if the query is fully updated and ready for use, you can leverage the isReady field on next/router.

This also prevents SSR hydration mismatch errors. On the server, Next.js SSR the page without the query, and Next.js will also use the empty query object during the initial hydration.

The isReady: true will only be set after the initial hydration has finished and the query object has been populated (the isReady here acts similarly with React's two-pass rendering approach).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok that's a bummer. I also see that there are multiple updates that happen before query and isReady are set, delaying the time it takes to render the final message.

Instead, why don't we read the query params ourselves instead of from the router. That will allow us to set the message in an effect, which fires immediately after hydration, and that will set it at the first moment possible.

Finally, we can add a fade in animation to avoid the flash of unparsed args. Something like this:

function parseQueryString(search: string): Array<string> {
  const rawQueryString = search.substring(1);
  if (!rawQueryString) {
    return [];
  }

  let args = [];

  const queries = rawQueryString.split('&');
  for (let i = 0; i < queries.length; i++) {
    const query = decodeURIComponent(queries[i]);
    if (query.indexOf('args[') === 0) {
      args.push(query.slice(query.indexOf(']=') + 2));
    }
  }

  return args;
}

export default function ErrorDecoder({errorMessages}: ErrorDecoderProps) {
  const [message, setMessage] = useState<string | null>(errorMessages);

  useEffect(() => {
    if (errorMessages == null) {
      return;
    }
    setMessage(
      replaceArgs(
        errorMessages,
        parseQueryString(window.location.search || ''),
        '[missing argument]'
      )
    );
  }, [errorMessages]);

  const displayMessage = useMemo(() => {
    if (!message) {
      return null;
    }
    return urlify(
      replaceArgs(message, parseQueryString(''), '[missing argument]')
    );
  }, [message]);

  if (!displayMessage) {
    return (
      <p>
        When you encounter an error, you{"'"}ll receive a link to this page for
        that specific error and we{"'"}ll show you the full error text.
      </p>
    );
  }

  return (
    <div>
      <p>
        <b>The full text of the error you just encountered is:</b>
      </p>
      <code className="block bg-red-100 text-red-600 py-4 px-6 mt-5 rounded-lg animate-fade-up">
        <b>{displayMessage}</b>
      </code>
    </div>
  );
}
// add to tailwind.config.js
{
  theme: {
    extend: {
      animation: {
        'fade-up': 'fade-up 1s both',
        // ...
      } ,
      keyframes: {
        'fade-up': {
          '0%': {
            opacity: '0',
            transform: 'translateY(2rem)',
          },
          '100%': {
            opacity: '1',
            transform: 'translateY(0)',
          },
        },
        // ...
      }
      // ...
    }
    // ...
  }

Here's what the animation looks like:

Screen.Recording.2023-08-18.at.2.58.40.PM.mov

Copy link
Contributor Author

@SukkaW SukkaW Aug 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rickhanlonii Done in 4383ac9 (#6214)

Note that, inside useEffect I add an early return if the errorMessages doesn't contain %s. This should avoid extra re-renders for most of the errors.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rickhanlonii Would you like to review the change in 4383ac9 (#6214)?

In the meantime, I am going to start addressing <ErrorDecoder> for MDX you have mentioned.


if (!msg) {
return (
<p>
When you encounter an error, you{"'"}ll receive a link to this page for
that specific error and we{"'"}ll show you the full error text.
</p>
);
}

return (
<div>
<p>
<b>The full text of the error you just encountered is:</b>
</p>
<code className="block bg-red-100 text-red-600 py-4 px-6 mt-5 rounded-lg">
<b>{urlify(msg)}</b>
rickhanlonii marked this conversation as resolved.
Show resolved Hide resolved
</code>
</div>
);
}
2 changes: 1 addition & 1 deletion src/content/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ title: React
permalink: index.html
---

{/* See HomeContent.js */}
{/* See HomeContent.js */}
9 changes: 7 additions & 2 deletions src/pages/[[...markdownPath]].js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export async function getStaticProps(context) {

// Read MDX from the file.
let path = (context.params.markdownPath || []).join('/') || 'index';

let mdx;
try {
mdx = fs.readFileSync(rootDir + path + '.md', 'utf8');
Expand Down Expand Up @@ -216,10 +217,14 @@ export async function getStaticProps(context) {
const fm = require('gray-matter');
const meta = fm(mdx).data;

// Serialize MDX into JSON.
rickhanlonii marked this conversation as resolved.
Show resolved Hide resolved
const content = JSON.stringify(children, stringifyNodeOnServer);
toc = JSON.stringify(toc, stringifyNodeOnServer);

const output = {
props: {
content: JSON.stringify(children, stringifyNodeOnServer),
toc: JSON.stringify(toc, stringifyNodeOnServer),
content,
toc,
meta,
},
};
Expand Down
85 changes: 85 additions & 0 deletions src/pages/errors/[error_code].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {Page} from 'components/Layout/Page';
import {MDXComponents} from 'components/MDX/MDXComponents';
import ErrorDecoder from 'components/ErrorDecoder';
import sidebarLearn from 'sidebarLearn.json';
import type {RouteItem} from 'components/Layout/getRouteMeta';
import {GetStaticPaths, GetStaticProps, InferGetStaticPropsType} from 'next';

const {MaxWidth, p: P} = MDXComponents;

interface ErrorDecoderProps {
errorCode: string;
errorMessages: string;
}

export default function ErrorDecoderPage({
errorMessages,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<Page
toc={[]}
meta={{title: 'Error Decoder'}}
routeTree={sidebarLearn as RouteItem}
section="unknown">
<MaxWidth>
<P>
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.
</P>
<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 errorMessages={errorMessages} />
</MaxWidth>
</Page>
);
}

export const getStaticProps: GetStaticProps<ErrorDecoderProps> = async ({
params,
}) => {
rickhanlonii marked this conversation as resolved.
Show resolved Hide resolved
const errorCodes = await (
await fetch(
'https://raw.githubusercontent.com/facebook/react/main/scripts/error-codes/codes.json'
)
).json();

const code =
typeof params?.error_code === 'string' ? params?.error_code : null;
if (!code || !errorCodes[code]) {
return {
notFound: true,
};
}

return {
props: {
errorCode: code,
errorMessages: errorCodes[code],
},
};
};

export const getStaticPaths: GetStaticPaths = async () => {
const errorCodes = await (
await fetch(
'https://raw.githubusercontent.com/facebook/react/main/scripts/error-codes/codes.json'
)
).json();

const paths = Object.keys(errorCodes).map((code) => ({
params: {
error_code: code,
},
}));

return {
paths,
fallback: 'blocking',
};
};
2 changes: 2 additions & 0 deletions src/pages/errors/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import ErrorDecoderPage from './[error_code]';
export default ErrorDecoderPage;