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 asynchronous rendering for MDC components as a path to reusable "MDC snippet" components #263

Open
adamdehaven opened this issue Oct 8, 2024 · 11 comments

Comments

@adamdehaven
Copy link
Contributor

adamdehaven commented Oct 8, 2024

Supporting reusable "snippet" components

It would be great to allow users to define a subset of markdown documents (MDC) that can be utilized in a project as reusable "snippets" that can be utilized in a parent document by means of a custom block component that handles fetching the snippet markdown content, and rendering in a nested/child MDCRenderer component.

For my use case, the snippet markdown will be fetched from an external API; however, I believe the Nuxt Content module, along with Nuxt Studio could easily be adapted to support reusable "snippets" that can be embedded in parent documents as block components.

My goal is to get this working for my own project, but I 100% would like to port this functionality to Nuxt Content and Nuxt Studio for use by the wider ecosystem.

Questions

Is it possible to defer the resolution of a parent MDCRenderer component so that it waits for all child nodes to be resolved?

I realize there could be a performance hit here if the page were to contain ~100 separate calls for other documents. Any suggestions on optimization or alternative solutions are welcome.

For the first iteration in the reproduction outlined below, I was thinking of limiting the number of "levels" of components allowed (see the snippetNestingLevel variable in /components/global/PageSnippet.vue).

Reproduction

https://stackblitz.com/github/adamdehaven/mdc-repros/tree/chore/snippets?file=README.md

This reproduction renders nested markdown content (e.g. reusable "snippets") from nested MDCRenderer components.

The initial markdown content fetched by a page would contain additional MDC page-snippet block components that internally fetch additional raw markdown to be rendered in their own MDCRenderer component. Since the child components need to fetch their content before the top-level parent should resolve, there needs to be a way to "defer rendering" of the parent until all of the child nodes have been processed and had a chance to fetch and render their content.

If there are too many page-snippet components (e.g. if the data in all components takes a bit to load), the parent component in index.vue resolves before all of the internal page-snippet components have finished fetching their data.

I have also added a /utils/serveCachedData.ts helper that caches the data after the first load so that navigating to the second page at /second and coming back to the home route / then renders the data instantly from cache. This is not relevant to the actual issue seen, but shows that once the snippet data is fetched and cached, there's no issue in rendering the nested components. You can even go multiple nesting levels deep.

When running the project, you'll observe:

  1. On the homepage, if you refresh the page to load it from the server, you'll see the Nested Content list items only load 2-3 on the server, and then reload and fill in the other items in the client, causing a hydration error in the console.
  2. Wrapping the PageSnippet.vue in a ClientOnly tag doesn't resolve the actual loading/timing issue (and the point here is to render on the server on initial load)
  3. Once the data is loaded you can navigate to the second page, and back to the homepage, and since the servedCachedData function is providing a transform and getCachedData configuration, the data is immediately available since it has already been fetched.

Other details

  • The /api/markdown endpoint serves a response of { content: string } of raw markdown, and adds a simulated 250ms delay on the endpoint to simulate loading the data from a remote server.
  • The page-snippet component passes a query param to the same /api/markdown endpoint so that it simply returns a single list item.
  • To prevent recursion, (e.g. snippet importing itself over and over) there is a removeInvalidSnippets utility that processes the markdown AST nodes and removes any nested children of the same tag, as well as a regular expression that removes the name prop from the PageSnippet component inline in the raw markdown. (again, none of this impacts the reproduction as-is)

Running the project

  1. Clone the repository and check out the chore/snippets branch.
  2. pnpm install
  3. pnpm dev
@farnabaz
Copy link
Collaborator

farnabaz commented Oct 9, 2024

Hello @adamdehaven
Thanks for the explanation, The solution is actually not that hard to achieve. And this is part of our vision for the next version of MDC and Content module.

In order to support async rendering, MDCRenderer should detect async components (components with async setup function), wrap them within defineAsyncComponent (If they are not wrapped before) and render wrapped components instead of original one. Since Nuxt have a global suspense component, page rendering defer the rendering of the page and there will be no hydration issue of visual glitch.

We need to update this function

/**
* Resolve component if it's a Vue component
*/
const resolveVueComponent = (component: any) => {
if (typeof component === 'string') {
return htmlTags.includes(component) ? component : resolveComponent(pascalCase(component), false)
}
return component
}

Note that if you use MDC module outside Nuxt environment, everything should wrap inside <suspense>

@adamdehaven
Copy link
Contributor Author

adamdehaven commented Oct 9, 2024

@farnabaz would you be able to show me an example of how you would suggest updating this function to wrap the component?

I've tried a few different variations (in this function, and a few other places) utilizing defineAsyncComponent and then importing a local build into my Nuxt project but I can't seem to get it working properly. The parent MDCRenderer still resolves before the children.

Any guidance/example here would be much appreciated

@adamdehaven
Copy link
Contributor Author

I tried this, but it still doesn't wait to resolve:

const resolveVueComponent = (component: any) => {
  if (typeof component === 'string') {
    return htmlTags.includes(component)
      ? component
      : defineAsyncComponent(() => new Promise((resolve) => {
        resolve(resolveComponent(pascalCase(component), false) as any)
      }))
  }
  return component
}

@farnabaz is this what you had in mind?

@farnabaz
Copy link
Collaborator

farnabaz commented Oct 15, 2024

Did you wrap everything in suspense?
Without suspense, it will not work

@adamdehaven
Copy link
Contributor Author

Did you wrap everything in suspense? Without suspense, it will not work

It’s inside a Nuxt app page, so the page itself is already wrapped I believe?

Would I need to wrap each individual component as well?

I didn’t try using a nested <Suspense suspensible>

@adamdehaven
Copy link
Contributor Author

I attempted to utilize <Suspense> throughout the tree, wrapping all of the MDCRenderer components, similar to this:

<template>
  <Suspense suspensible>
    <MDCRenderer
      v-if="!!snippetName && ast?.body && !snippetError"
      :body="ast.body"
      :data="ast.data"
      :data-testid="!!snippetName ? snippetName : undefined"
    />
  </Suspense>
</template>

(I also attempted adding another Suspense in pages/index.vue as well)

But it still doesn't wait to resolve 😅 - @farnabaz show me what I'm missing?

@farnabaz
Copy link
Collaborator

Interesting, Could you provide a reproduction with suspense?
This approach works fine with Nuxt and Suspense.

@adamdehaven
Copy link
Contributor Author

@farnabaz here you go: https://stackblitz.com/github/adamdehaven/mdc/tree/feat/async-rendering?file=src%2Fruntime%2Fcomponents%2FMDCRenderer.vue

This reproduction is a fork of the repo with the following changes:

  • Replaced the resolveVueComponent implementation in MDCRenderer.vue with the code above
  • Replaced the /playground in the repo with my original example, updated to utilize <Suspense suspensible> in both the /playground/page/index.vue and /playground/components/global/PageSnippet.vue components.

@farnabaz
Copy link
Collaborator

I see, the glitch you are facing is not related to content renderer anymore.

You have two async data in your component, and the second one is immediate: false which means that it does not run immediately. Thus, it will not block rendering.

Merge two async data.

const { data: ast, execute: parseSnippetData } = await useAsyncData(`parsed-${fetchKey.value}`, async (): Promise<MDCParserResult> => {
  const { content } = await $fetch('/api/markdown', {
    query: {
      name: 'snippet'
    }
  })
  
  const parsed = await parseMarkdown(content)

  // Extract the `body` and destructure the rest of the document
  const { body, ...parsedDoc } = parsed

  // Important: Remove invalid snippets from the AST
  const processedBody = removeInvalidSnippets(body)

  // Return the MDCParserResult with the sanitized body
  return {
    ...parsedDoc,
    body: processedBody as MDCRoot
  }
}, {
  transform,
  getCachedData
})

adamdehaven added a commit to adamdehaven/mdc that referenced this issue Oct 18, 2024
@adamdehaven
Copy link
Contributor Author

Ah I see! I can actually just move the conditional logic that fires the execute command in my reproduction to look like this:

immediate: !!snippetName.value && (!!sanitizedData && !!snippetData.value?.content) && !snippetError.value

With my repro, I never want to fetch the data if these conditionals evaluate to false, so then the component can resolve immediately with no issue.


@farnabaz this all works perfectly now (thanks for your help!) and I've put together a pull request, along with adding a playground example and documentation:

#266

Please let me know if I can make any changes to the PR; otherwise, I'd love to see this merged in 🎉

adamdehaven added a commit to adamdehaven/mdc that referenced this issue Oct 24, 2024
@adamdehaven
Copy link
Contributor Author

Update: #266 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants