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

Support async components #2242

Closed
4 tasks done
remcohaszing opened this issue Feb 1, 2023 · 22 comments
Closed
4 tasks done

Support async components #2242

remcohaszing opened this issue Feb 1, 2023 · 22 comments
Labels
💪 phase/solved Post is done

Comments

@remcohaszing
Copy link
Member

Initial checklist

Problem

React server components can be asynchronous. It would be nice if MDX content supports this too.

# Hello visitor

This page has been visited {await Promise.resolve(42)} times

Solution

If an await expression is detected, mark _createMdxContent as async.

Alternatives

We could not support it 🤷

@wooorm
Copy link
Member

wooorm commented Feb 1, 2023

  • As I understand it, that’s not RSC. That’s specifically the server part of RSC (in Next.js?)
  • As I understand it, they’re not stable yet: RFC: First class support for promises and async/await reactjs/rfcs#229
  • I’m quite wary about changing return types like this, magically, based on content. Should probably be an experimental flag for people to opt in and try this if they want. Similar to useDynamicImport, allowDangerousRemoteMdx

@remcohaszing
Copy link
Member Author

Right, those are all good points.

It makes sense to make this opt-in, I imagine an option named allowAsyncComponents.

This will also break editor support, not because of React or MDX, but because of the same reason async components aren’t supported in TypeScript (microsoft/TypeScript#21699).

I personally don’t need this right now, it’s just an idea I had. We can wait until someone has a real use case.

@wooorm
Copy link
Member

wooorm commented Oct 18, 2023

@remcohaszing I think there are too many ifs currently, this isn’t actionable until React (and others!) solve their promise story. We can discuss again in the future. Can I close this?

@remcohaszing
Copy link
Member Author

Maybe I mixed up some terminology and supporting async components isn't the same as supporting RSC. But from the perspective of MDX this is about supporting async components.

Next.js supports already async components in app/page.tsx, so why support not await in app/page.mdx?

Also the related TypeScript issue has been resolved.

I'd say we can wait for user feedback to see if users actually want to use this. Based on the fact there are already some 👍 reactions, I think they do.

@wooorm
Copy link
Member

wooorm commented Oct 18, 2023

We don’t just support Next, or React, but any arbitrary runtime.
Supporting await is one thing, we can detect that syntacticly (although we’d need to first parse as if that could happen). But components returning promises is something that can only be done at runtime?

@remcohaszing
Copy link
Member Author

We don’t just support Next, or React, but any arbitrary runtime.

I think this proves my point. We support any arbritrary runtime. This runtime may support async function components. Why should MDX not support that?

Also to clarify, my proposal is to only make the component async if the body contains await expressions. It’s about supporting await, not making everything async. This is statically analyzable and doesn’t require any detection at runtime.

@wooorm
Copy link
Member

wooorm commented Oct 19, 2023

But nobody supports it yet? It doesn’t seem like a great idea to design things that can’t be tested.

It’s about supporting await, not making everything async.

How are they not related? We already parse await. It would make _createMdxContent and MDXContent async and it would mean users need to await it?

@remcohaszing
Copy link
Member Author

But nobody supports it yet?

Next.js does. For example see https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#fetching-data-on-the-server-with-fetch

It would make _createdMdxContent async if and only if an await expression is used.

MDXContent would return a promise if _createdMdxContent is async and no MDXLayout is used.

MDXContent is currently typed as a function that returns a JSX element. That would no longer hold true. It would need to be changed to a FunctionComponent.

- export type MDXContent = (props: MDXProps) => JSX.Element;
+ export type MDXContent = FunctionComponent<MDXProps>;

@wooorm
Copy link
Member

wooorm commented Oct 19, 2023

Next.js does

I don’t classify that as the “yet” part. They support RSC. The RFC isn’t merged, see first comment here: #2242 (comment).

and no MDXLayout is used.

And what if a layout is used?

It would need to be changed to a FunctionComponent.

And what if someone is using Preact or Vue or anything other than Next? The types wouldn’t match reality. They could get Promise<JSX.Element> as a return type even though their types say they can’t.

@remcohaszing
Copy link
Member Author

And what if a layout is used?

Then _createMdxContent is passed as a JSX component, and MDXContent returns synchronously.

And what if someone is using Preact or Vue or anything other than Next? The types wouldn’t match reality. They could get Promise<JSX.Element> as a return type even though their types say they can’t.

The return type of MDX’s FunctionComponent is adjusted to the framework via the global JSX namespace. If someone uses Preact, they get the Preact types. If someone uses Vue, they get the Vue types.

@wooorm
Copy link
Member

wooorm commented Oct 19, 2023

Checking this out locally. I think we’d only need to detect for AwaitExpressions? I do see one more occurance of await:

export interface ForOfStatement extends BaseForXStatement {
    type: "ForOfStatement";
    await: boolean;
}

Would we also have to use async fn for that? 🤔

@wooorm
Copy link
Member

wooorm commented Oct 19, 2023

+++ b/packages/mdx/lib/plugin/recma-document.js
@@ -602,9 +602,23 @@ export function recmaDocument(options) {
       argument = argument.children[0]
     }
 
+    let awaitExpression = false
+
+    walk(argument, {
+      enter(node) {
+        if (
+          node.type === 'AwaitExpression' ||
+          (node.type === 'ForOfStatement' && node.await)
+        ) {
+          awaitExpression = true
+        }
+      }
+    })
+
     return [
       {
         type: 'FunctionDeclaration',
+        async: awaitExpression,
         id: {type: 'Identifier', name: '_createMdxContent'},
         params: [{type: 'Identifier', name: 'props'}],
         body: {

example.js

import fs from 'node:fs/promises'
import {compile} from '@mdx-js/mdx'
import {createElement} from 'react'
import {renderToString} from 'react-dom/server'

const document = `{await Promise.resolve(42)}`

const result = String(await compile(document))

await fs.writeFile('output.js', result)

console.log('code:', result)

const id = './output.js#' + Date.now()
/** @type {import('mdx/types.js').MDXModule} */
const mod = await import(id)
const Content = mod.default

console.log('Content:', Content)

const output = renderToString(createElement(Content))

console.log('output:', output)

Yields:

$ node example.js 
code: /*@jsxRuntime automatic @jsxImportSource react*/
import {Fragment as _Fragment, jsx as _jsx} from "react/jsx-runtime";
async function _createMdxContent(props) {
  return _jsx(_Fragment, {
    children: await Promise.resolve(42)
  });
}
function MDXContent(props = {}) {
  const {wrapper: MDXLayout} = props.components || ({});
  return MDXLayout ? _jsx(MDXLayout, {
    ...props,
    children: _jsx(_createMdxContent, {
      ...props
    })
  }) : _createMdxContent(props);
}
export default MDXContent;

Content: [Function: MDXContent]
/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6183
    throw new Error("Objects are not valid as a React child (found: " + (childString === '[object Object]' ? 'object with keys {' + Object.keys(node).join(', ') + '}' : childString) + "). " + 'If you meant to render a collection of children, use an array ' + 'instead.');
          ^

Error: Objects are not valid as a React child (found: [object Promise]). If you meant to render a collection of children, use an array instead.
    at renderNodeDestructiveImpl (/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6183:11)
    at renderNodeDestructive (/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6080:14)
    at renderIndeterminateComponent (/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:5789:7)
    at renderElement (/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:5950:7)
    at renderNodeDestructiveImpl (/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6108:11)
    at renderNodeDestructive (/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6080:14)
    at retryTask (/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6532:5)
    at performWork (/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6580:7)
    at /Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:6904:12
    at scheduleWork (/Users/tilde/Projects/oss/mdx/node_modules/react-dom/cjs/react-dom-server-legacy.node.development.js:78:3)

Node.js v20.8.1

Interesting. All tests pass too. But eh. Useless? And no type errors?

@remcohaszing
Copy link
Member Author

Yes, we’d need to check for AwaitExpressions within the body of _createMdxContent.

function _createMdxContent() {
  await 1 // ← top-level

  if (await 1) { // if-statement
    await 1
  }

  while (await 1) { // loop
    await 1
  }

  const foo = {
    bar: await 1 // nested property
  }

  foo(await 1) // as argument

  async function foo() {
    await 1 // Inside an embedded function doesn’t count.
  }
}

This is doable, but it does look a bit more involved than I initially anticipated.

@wooorm
Copy link
Member

wooorm commented Oct 19, 2023

Why? I posted working code above!

@remcohaszing
Copy link
Member Author

Why? I posted working code above!

While I was fiddling.

I think your code works, but needs to stop traversion on a function expression, function declaration, or arrow function.

@wooorm
Copy link
Member

wooorm commented Oct 19, 2023

Ah, it should also check async functions!

@wooorm
Copy link
Member

wooorm commented Oct 19, 2023

wait no, it should indeed skip, and not use async on functions as if an await was found. Lol

@remcohaszing
Copy link
Member Author

I thought maybe async components only work with streams, since you can’t go from an async function to a synchronous renderToString. But that doesn’t seem to work either.

import fs from 'node:fs/promises'
import {compile} from '@mdx-js/mdx'
import {createElement} from 'react'
import server from 'react-dom/server'

const document = `{await Promise.resolve(42)}`

const result = String(await compile(document))

await fs.writeFile('output.js', result)

console.log('code:', result)

const id = './output.js#' + Date.now()
/** @type {import('mdx/types.js').MDXModule} */
const mod = await import(id)
const Content = mod.default

console.log('Content:', Content)

console.dir(server)

server.renderToPipeableStream(createElement(Content)).pipe(process.stdout)

@remcohaszing
Copy link
Member Author

Note that the change turns what would now emit a syntax error to support for a new feature without breaking changes. This could be done later in a semver minor.

@wooorm wooorm closed this as completed in 44fd9ca Oct 19, 2023
@wooorm wooorm added 💪 phase/solved Post is done and removed 💬 type/discussion This is a request for comments labels Oct 19, 2023
@wooorm
Copy link
Member

wooorm commented Oct 24, 2023

Not sure about the types in mdx/types.js btw!

@remcohaszing
Copy link
Member Author

We could either leave it as-is, or make the changes I proposed in #2242 (comment).

For now I think it's best to keep the types as-is for compatibility with TypeScript 5.0 and lower.

@wooorm
Copy link
Member

wooorm commented Oct 24, 2023

fine!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
💪 phase/solved Post is done
Development

No branches or pull requests

2 participants