faustjs
faustjs copied to clipboard
RFC: Next.js App Router support in @faust/blocks package
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:
-
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.
-
BlocksViewerRSC
Provide a new component to render RSC components without using
WordPressBlocksProvider
andWordPressBlocksViewer
. The new component calledBlocksViewerRSC
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.
- 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: https://github.com/vercel/next.js/issues/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 theBlocksViewerRSC
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.
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?
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.
Basically we avoid using any of React.Context
and hooks
when working with app-router RSC.
@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?
@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
@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?)
@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
https://github.com/vercel/next.js/issues/51593
We will have to provide an alternative solution to tackle this.
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.
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.
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 🤞
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.
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 🤞
@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' }
@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 🙌
As it has been a while. I'm going to close this issue until we can revisit in roadmap discussions.
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?