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

RFC: Next.js App Router support in @faust/blocks package #1619

Closed
theodesp opened this issue Oct 19, 2023 · 16 comments
Closed

RFC: Next.js App Router support in @faust/blocks package #1619

theodesp opened this issue Oct 19, 2023 · 16 comments
Assignees
Labels

Comments

@theodesp
Copy link
Member

theodesp commented Oct 19, 2023

We have recently done some research into seeing how we can support the Next.js App Router in @faust/blocks package and for Gutenberg.

Problem Statement

Next.js recently introduced a new feature called "App Router" in which a new directory, called "app", is used to create and route pages. These pages work differently than the file based pages we see in the "pages" directory. Instead of a single file containing the React presentation component and the SSR/SSG counterpart, like getServerSideProps or getStaticProps, the App Router makes use of several files to create one presentation. Since Faust uses these SSR/SSG counterparts to fetch data, authenticate, etc. this poses an issue for supporting the App Router with the current implementations in Faust. Additionally, with Next.js shifting to React Server Components, we will need to come up with solutions for fetching data, authenticating, etc, all on the server within RSCs (React Server Components).

Proposal

Since supporting React Server Components requires to avoid using React specific hooks and providers we can propose the following additions to the @faust/blocks packages to accommodate rendering blocks as RSC:

  1. Remove all usages of hooks inside the CoreBlocks

For example we use the useBlocksTheme hook in the CoreBlocks. Instead we should pass the theme parameter as a property:

Before

export function CoreParagraph(props: CoreParagraphFragmentProps) {
  const theme = useBlocksTheme();
  const style = getStyles(theme, { ...props });
  const { attributes } = props;
  return (
    <p
      style={style}
      className={attributes?.cssClassName}
      // eslint-disable-next-line react/no-danger
      dangerouslySetInnerHTML={{ __html: attributes?.content ?? '' }}
    />
  );
}

After

export function CoreParagraph(props: CoreParagraphFragmentProps) {
  const { attributes, theme } = props;
  const style = getStyles(theme, { ...props });

  return (
    <p
      style={style}
      className={attributes?.cssClassName}
      // eslint-disable-next-line react/no-danger
      dangerouslySetInnerHTML={{ __html: attributes?.content ?? '' }}
    />
  );
}

This makes the component an RSC so it can be rendered on the server.

  1. BlocksViewerRSC
    Provide a new component to render RSC components without using WordPressBlocksProvider and WordPressBlocksViewer. The new component called BlocksViewerRSC will accept all the required properties it self , eliminating the need for a React Provider which marks the components as Client Only:
import { BlocksViewerRSC } from '@faustwp/blocks';
import blocks from '../wp-blocks';

const blockList = flatListToHierarchical(editorBlocks, { childrenKey: 'innerBlocks' });
<BlocksViewerRSC content={blockList} blocks={blocks} theme={null}/>

BlocksViewerRSC takes the following properties:

  • content: The query data of the block list. Required.
  • blocks: And object with the exported block components: Required
  • theme: The theme object that is generated when using the fromThemeJson helper function. Optional.

To facilitate the smooth usage of the Core blocks as RSC component we re-export the Core blocks under a new name CoreBlocksRSC:

Before

// wp-blocks/index.js
import { CoreBlocks } from '@faustwp/blocks';

export default {
  ...CoreBlocks,
};

After

// wp-blocks/index.js
import { CoreBlocksRSC } from '@faustwp/blocks';

export default {
  ...CoreBlocksRSC,
};

The CoreBlocksRSC contain all the original blocks but they are specifically used with the BlocksViewerRSC component.

  1. Make Block Component Exports suitable for both Client and Server Rendering.
    Since Client Side component exports are not visible in the Server Side then we cannot access the config or any of the component metadata using the dot (.) operator. SEE: Dot notation client component breaks consuming RSC. vercel/next.js#51593.

To avoid those issues we need to separate those options both from the client side and the server side. This is how we propose the Block Component Exports to be.

Here is the CoreParagraph Block written in the new format as a client component:

// CoreParagraph.js

'use client';

export function CoreParagraph(props: CoreParagraphFragmentProps) {
  const { attributes, theme } = props;
  const style = getStyles(theme, { ...props });

  return (
    <p
      style={style}
      className={attributes?.cssClassName}
      // eslint-disable-next-line react/no-danger
      dangerouslySetInnerHTML={{ __html: attributes?.content ?? '' }}
    />
  );
}

export default CoreParagraph;

Now we separate the config and fragments into a separate exported file which is available on the server:

// CoreParagraphMeta.js

import { gql } from '@apollo/client';

const fragments = {
  key: `CoreParagraphBlockFragment`,
  entry: gql`
    fragment CoreParagraphBlockFragment on CoreParagraph {
      attributes {
        cssClassName
        backgroundColor
        content
        style
        textColor
        fontSize
        fontFamily
        direction
        dropCap
        gradient
        align
      }
    }
  `,
};

const config = {
  name: 'CoreParagraph',
};

export { config, fragments };

And in the index.js we export both modules under the following convention:

export { default as Component } from './CoreParagraph.js';
export * from './CoreParagraphMeta.js';

Now when you importing the component you need to import the whole module (Component and Meta properties):

export * as CoreParagraph from './CoreParagraph/index.js';

Notice the convention here:

Component: is the React component that we want to render. It could be either client or a server component.
The next export export * from './CoreParagraphMeta.js'; is an object with the usual Block metadata like fragments and config. This should be a server component only since we need to read this information on the server.

The final exported module will be the following object:

CoreParagraph: Object [Module] {
    Component: [Getter], // Client Export
    config: [Getter], // Server Export
    fragments: [Getter] // Server Export
  },

With this approach we are able to use both client side and server components from within BlocksViewerRSC without any issues.

Fallback block

Instead of passing the Fallback block as a parameter to BlocksViewerRSC you can add this to the blocklist under the special property name: fallBackBlock. For example:

export default {
  ...CoreBlocksRSC,
 fallBackBlock: MyFallBackBlock
};

The BlocksViewerRSC will try to detect if this block exists and try to use it when rendering a default block.

User Experience

When you use the provided BlocksViewerRSC you should be able to see the original blocks rendered as RSC component so they are not shipped to the client.

Caveats

  • Faust Hook filters would not work when using BlocksViewerRSC since the BlocksViewerRSC is rendered on the server and not client side code (useEffect) can run during that time.
  • We will have to accommodate the new exports change when registering blocks using the registerFaustBlock function to convert a React Component to Block. https://faustjs.org/tutorial/react-components-to-gutenberg-blocks

Compatibility Matrix

The following Matrix captures the compatibility of Blocks when using client vs server components between BlocksViewerRSC and WordPressBlocksViewer

Blocks BlocksViewerRSC WordPressBlocksViewer use in client use in server Comment
CoreBlocks Use WordPressBlocksViewer for any original CoreBlocks since they work only on the client side. Not compatible with app-router package.
CoreBlocksRSC Use BlocksViewerRSC for any CoreBlocksRSC when using app-router.
Client Side Block Use BlocksViewerRSC for any client side component as long as you provide the correct export.
Server Side Block Use BlocksViewerRSC for any server side component as long as you provide the correct export.

In other words if you are using the next.js 13 app-router package use CoreBlocksRSC otherwise use WordPressBlocksViewer

POC

This branch encompasses a POC of the above proposal.

@theodesp theodesp added the RFC label Oct 19, 2023
@jordanmaslyn
Copy link
Contributor

Just to confirm - in this case, specific blocks could still leverage client React functionality and thus be included in the bundle, but in doing so, the remainder of the blocks in the tree would still remain RSC with the relevant benefits. Am I understanding that correctly?

@theodesp
Copy link
Member Author

theodesp commented Oct 19, 2023

Hey @jordanmaslyn thank you for asking. If you are using the CoreBlocks they would work with the WordPressBlocksProvider and WordPressBlocksViewer but because the blocks have hooks they are classified as Client Side components in Next.js. This is is not ideal since it increases the bundle size in app-router and are shipped to the client:

What instead you need to use for app-router is to avoid using any of the WordPressBlocksProvider and WordPressBlocksViewer and use the BlocksViewerRSC/CoreBlocksRSC combination instead.

What you get is the same experience but without sending the components to the client.

@theodesp
Copy link
Member Author

theodesp commented Oct 19, 2023

Basically we avoid using any of React.Context and hooks when working with app-router RSC.

@jordanmaslyn
Copy link
Contributor

jordanmaslyn commented Oct 19, 2023

@theodesp I totally understand that! What I am asking is if I had a block that needed interactivity (e.g. an Accordion block) but wanted RSC for all of the others, would that be possible in this new paradigm using BlocksViewerRSC?

So the Accordion would be included in the bundle but all others would remain RSC and thus not be included in the bundle.

Is that accurate?

@theodesp
Copy link
Member Author

@jordanmaslyn thats right. You can use BlocksViewerRSC to render client side components since this component is RSC it can accept Client components. See https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#interleaving-server-and-client-components

@justlevine
Copy link
Contributor

@theodesp how would this affect the generated js bundle?

( Currently, because blocks are loaded into the provider in app.js, theyre loaded on every route - even if that route doesn't use any blocks. Does RSC here get those blocks out of the shared bundle to be streamed on demand, or will they still be loaded onto every page?)

@theodesp
Copy link
Member Author

@justlevine thank you for asking.

None of the CoreBlocksRSC will use 'use client' directive so are not loaded as client side components so the bundle size stays flat.

Since theBlocksViewerRSC and CoreBlocksRSC are Server Side components, then they will not be included in the bundle. If you are using a client side block component (marked as 'use client") then it will be included in the bundle.

We may have to create a new npm export under the /rsc folder to avoid any side-effects

import { BlocksViewerRSC } from '@faustwp/blocks/rsc';

The only problem that I see now is when using the dot (.) notation with blocks will be problematic

vercel/next.js#51593

We will have to provide an alternative solution to tackle this.

@theodesp
Copy link
Member Author

We may have to introduce a different way to export block components something like that:

function CoreCode(props: CoreCodeFragmentProps) {...}
const fragments = {..}
const config = {
  name: 'CoreCode',
};
const displayName = 'CoreCode';

export default { Component: CoreCode, config, displayName, fragments };

This is annoying but the spec is very fussy.

@theodesp
Copy link
Member Author

theodesp commented Oct 23, 2023

Regarding the above issue with the dot (.) notation with blocks I will have to make some experiments since it may cause issues when mixing client and server components. I will post my updates here.

@justlevine
Copy link
Contributor

Regarding the above issue with the dot (.) notation with blocks I will have to make some experiments since it may cause issues when mixing client and server components. I will post my updates here.

While it's probably outside of the scope to actually fix in this PR, I'm hoping whatever solution you land on don't make it harder to support dynamically-imported client side blocks in the future 🤞

@theodesp
Copy link
Member Author

theodesp commented Oct 24, 2023

Regarding the above issue with the dot (.) notation with blocks I will have to make some experiments since it may cause issues when mixing client and server components. I will post my updates here.

While it's probably outside of the scope to actually fix in this PR, I'm hoping whatever solution you land on don't make it harder to support dynamically-imported client side blocks in the future 🤞

I'm not sure how this is feasible at the moment. So far I've encountered the above issue when using Dynamic components:

Cannot access CoreParagraph.then.then on the server. You cannot dot into a client module from a server component. You can only pass the imported name through.

I also tried using this approach:

import dynamic from 'next/dynamic.js';

const DynamicComponent = dynamic(() => import('./CoreParagraph.js'), {
  ssr: false,
});

export { DynamicComponent as Component };

But it looks like that WebPack did not create a separate chuck for it but included it in the page.js assets. Not sure if it's possible to have dynamically-imported client blocks this way.

@justlevine
Copy link
Contributor

justlevine commented Oct 24, 2023

But it looks like that WebPack did not create a separate chuck for it but included it in the page.js assets. Not sure if it's possible to have dynamically-imported client blocks this way.

Correct, because the current client-based provider loads the entire component in the _app.tsx file in order to determine whether it should be used.

Perhaps the effort fix/work around the dot-notation will open a path to solve it, but I doubt it. However, we can hopefully avoid making it worse 🤞

@theodesp
Copy link
Member Author

theodesp commented Oct 24, 2023

@justlevine I found a workaround with the dot-notation and I've updated the spec.

Let me know what you think.

export { default as Component } from './CoreParagraph.js';
export * from './CoreParagraphMeta.js'; // contains config and fragments available in the server side

Exported module signature:

Before

CoreParagraph: Function() { // Client Export + React Component
    config: Object,
    fragments: Object
  },

Here dot notation fails to work on the server:
CoreParagraph.config:

Cannot access CoreParagraph.config on the server. You cannot dot into a client module from a server component. You can only pass the imported name through.

After

CoreParagraph: Object [Module] {
    Component: [Getter], // Client Export + React Component
    config: [Getter], // Server Export
    fragments: [Getter] // Server Export
  },

Here dot notation works fine on the server:

CoreParagraph.config
{ name: 'CoreParagraph' }

@justlevine
Copy link
Contributor

justlevine commented Oct 24, 2023

@theodesp looks good to me. As far as I can tell there's no real additional coupling in the signature, which means it would be trivial to update Component to support a dynamic component if/when the underlying client provider supports them 🙌

@ChrisWiegman
Copy link
Contributor

ChrisWiegman commented Apr 3, 2024

As it has been a while. I'm going to close this issue until we can revisit in roadmap discussions.

@justlevine
Copy link
Contributor

As it has been a while. I'm going to close this issue so we can revisit in roadmap discussions.

@ChrisWiegman can you clarify? Other than #1624 what work towards making blocks work server-side still needs to be done/ reevaluated for inclusion?

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

No branches or pull requests

5 participants