Yeah, I think that order of action is perfect
Although it seems we’re not going to be able to include any of these in this sprint, this is the initial estimation we did:
- Expose the HTML template, the render function and the App in
libraries
- 5pt Add a context tobeforeCSR
, similar to the context passed tobeforeSSR
- 5pt- Overwrite the HTML template - 5pt
- Move all the CSS to the
<head>
- 5pt - Change the render to be
renderToStaticMarkup
- 3pt.
Here we could release the first beta version, which means 18pts.
- Create minimum amount of AMP processors (the main ones we select) - 13pt
- Remove
/amp
fromlink
inactions.source.fetch
andstate.source.get
- 3pt
We could do this little by little, there would be 16pt more.
- Make the themes amp-aware - 21pt.
- Setting to change the entry point of the packages of a site - 8pt.
- Setting to change which bundles are generated (client and/or server) - 8pt.
- Allow packages to modify the settings of a site. - 13pt.
- Add
frontity.config.js
file for setting the previous steps in the@frontity/amp
package - 3pt.
There would be 53pt more until we make these optimizations. At this point it could even be the v1.
- Support for package priorities (add priority to amp package). - 8pt.
Ok, thanks @santosguillamot!
Some updates:
-
I’ve decided we should go with
libraries.frontity
because:- Adding
ctx
to the client means adding a new concept, while people already know aboutlibraries
. - It is consistent with
state.frontity
where we expose data about the current site.
- Adding
-
I forgot to add that we should also update some packages to make them AMP-aware, like the analytics packages:
@frontity/comscore-analytics
,@frontity/google-analytics
,@frontity/google-tag-manager--analytics
. -
I have fixed the code snippet of the section Moving CSS to the
<head>
.
@nicholasio.oliveira, these is a summary of what needs to be done for the AMP package:
Frontity Core
-
Expose some internal things in
libraries
- The HTML template in
libraries.frontity.template
. - The render function in
libraries.frontity.render
. - The App in
libraries.frontity.App
.
This should be really easy to do. Simply, adding those things to
store.libraries
before running theinit
andbeforeSSR
actions: https://github.com/frontity/frontity/blob/dev/packages/core/src/server/index.tsx#L89-L104 - The HTML template in
AMP package
-
Modify the HTML template
This should also be simple, as it’d be to use the
init
orbeforeSSR
action of the package to modifylibraries.frontity.template
to a function like this one: https://github.com/frontity/frontity/blob/dev/packages/core/src/server/templates/amp.ts -
Change the render to be
renderToStaticMarkup
This is also trivial. Just using the same
init
orbeforeSSR
action to modifylibraries.frontity.reder
to the ReactDOM’srenderToStaticMarkup
. -
Move all the CSS to the
<head>
This is the trickiest part, but we have already done so with success, like I explained in the Move CSS to the section of the Implementation Proposal above. It shouldn’t take long as it’d mean we need to replicate what we already had in the very first version of Frontity.
With those features you should be able to render AMP sucessfully with a @frontity/amp
package.
Extra things
-
Make your theme AMP aware
Of course, you need to make sure your theme is AMP aware. The
@frontity/amp
package can create astate.amp
object that React components can check to see if an amp package is present or not. -
Create processors to make the post
content
AMP awareWe did that in the past. They are not that many to be honest. For example, this was the processor and component for SoundCloud:
- https://github.com/wp-pwa/h2r/blob/dev/src/processors/soundcloud.js
- https://github.com/wp-pwa/h2r/blob/dev/src/components/LazySoundcloud/index.js
More processors here: https://github.com/wp-pwa/h2r/tree/dev/src/processors
-
Remove
/amp
from the internal source actionsIf the URL needs to be
https://domain.com/some-post/amp
, we’d also need to remove the/amp
from link inactions.source.fetch
andstate.source.get
.We decided we are going to hardcode it in the v1 of
source
because it’s not possible to do it through the AMP package, although that will be solved in the v2 ofsource
.This is not required if the URL is a subdomain
https://amp.domain.com/some-post
or a queryhttps://domain.com/some-post?amp=true
.It should not be hard to do, a couple of lines of code.
It’d be interesting to see if it’d be possible to integrate Frontity with styling libraries that require SSR steps like Material-UI (SSR instructions) after exposing these parts.
@cristianbote and I have done a video to go over all the tasks that need to be accomplished for the AMP package:
cc: @dev-team and anyone else that wants to help us out with a PR
In my mind I think this suits all of what we’ve been looking at while satisfying the need for defining an AMP package. I’ve tried to boil down the customisations in this drawing: https://excalidraw.com/#json=5886236229107712,HRh2W3l7ljVzjOzRGgXD4g (let’s be real though, nowhere as cool as Luis’s diagrams ).
Summary
Let’s break down the changes on each of the context:
frontity.App
In order to enable users to define they’re own app shell we should expose a way to do that. Based on the previous IP and FD the way to do that was to expose on libraries.
a special namespace to hold these properties. The namespace is called frontity
.
The App
needs to be a functional component. That functional component will receive a prop named App
. The functional component needs to include App
in it’s tree, because App
is the main root
component that is being rendered.
Let’s look at an example:
export default {
// [...] rest of the .settings.js
actions: {
theme: {
beforeSSR({ libraries: { frontity } }) {
// Custom `<App />`.
// This will result in the `Root` of the theme to be wrapped in a `div`
// with an id of "app"
frontity.App = ({ App }) => (
<div id="app">
<App />
</div>
);
}
},
},
};
Of course, this will result in an hydration warning and a subsequent client side render. Not great. So, in order to achieve the proper hydration the user needs to be able to define the same App
component on the client. So, that’s why we have beforeCSR
. Everything put together this is how it’ll look:
function WrappedApp({ App }) {
return (
<div id="app">
<App />
</div>
);
}
export default {
// [...] rest of the .settings.js
actions: {
theme: {
beforeSSR({ libraries: { frontity } }) {
// Only the reference is needed
frontity.App = WrappedApp;
},
beforeCSR({ libraries: { frontity } }) {
// Same as beforeSSR. Only the reference.
frontity.App = WrappedApp;
}
},
},
};
frontity.render
This refers to the render
function that Frontity should use to render the React app. Same as App
, render
function receives the App
as a functional component and also the defaultRenderer
function, that represent the default serialiser that Frontity uses to convert JSX to html.
Why the need for
defaultRenderer
defaultRenderer
is useful for the case where you don’t need to handle the actual rendering to string but only wrapping with providers the mainApp
component, like for exampleemotion
, and handle the results inside thetemplate
function. More on that later.
Let’s look at an example of how one can handle the render
of their App
.
export default {
// [...] rest of the .settings.js
actions: {
theme: {
beforeSSR({ libraries: { frontity } }) {
// Let's say you want to 'custom' render your app with special tags.
frontity.render = ({ App }) => {
const html = renderToString(<App />);
// You can manipulate or change your html result as you wish.
html += '<my-custom-tag>I have a custom tag</my-custom-tag>';
return html;
};
}
},
},
};
The above examples it’s a pretty simple one, as it only does some minor modifications to the result. But let’s look at a real life scenario with emotion.
import { CacheProvider } from "@emotion/react";
import createEmotionServer from "@emotion/server/create-instance";
import createCache from "@emotion/cache";
export default {
// [...] rest of the .settings.js
actions: {
theme: {
beforeSSR({ libraries: { frontity } }) {
// Let's say you want to 'custom' render your app with special tags.
frontity.render = ({ App, defaultRenderer }) => {
const key = "frontity";
const cache = createCache({ key });
const { extractCritical } = createEmotionServer(cache);
// Call the extractCritical. This will return an object as { html, ids, css }
const { html, ids, css } = extractCritical(
// `defaultRenderer` is the default serialiser that Frontity uses internally.
defaultRenderer(
<CacheProvider value={cache}>
<App />
</CacheProvider>
)
);
return `
<style data-emotion="${key} ${ids.join(' ')}">${css}</style>
${html}
`;
};
}
},
},
};
Even though the above it is more real, it is not ideal as the <style>
tag needs to be rendered in the <head>
section. So we need a way to customise the html
template.
frontity.template
As we’ve seen so far, the .template
function it’s quite vital to accomplish a fully extensible way to define one’s custom html output. The template
is a function that is called with the render
method result
, a defaultTemplate
function and a few lists that hold the head tags, named head
, the body scripts
and the html and body attributes, htmlAttributes
and bodyAttributes
.
Let’s build on the above example with emotion’s moving the generated <style>
tag to the <head>
section.
import { CacheProvider } from "@emotion/react";
import createEmotionServer from "@emotion/server/create-instance";
import createCache from "@emotion/cache";
export default {
// [...] rest of the .settings.js
actions: {
theme: {
beforeSSR({ libraries: { frontity } }) {
// Let's say you want to 'custom' render your app with special tags.
frontity.render = ({ App, defaultRenderer }) => {
const key = "frontity";
const cache = createCache({ key });
const { extractCritical } = createEmotionServer(cache);
// Call the extractCritical. This will return an object as { html, ids, css }
const result = extractCritical(
// `defaultRenderer` is the default serialiser that Frontity uses internally.
defaultRenderer(
<CacheProvider value={cache}>
<App />
</CacheProvider>
)
);
// We can safely return here the emotion critical call result
// since we're gonna handle this result ourselves.
return {
...result,
key
};
};
// Custom `template` function.
frontity.template = ({ result, head, defaultTemplate, ...rest }) => {
// We grab the resulted html, ids, css and the cache key.
const { html, ids, css, key } = result;
// And we push to the `head` the new style tag.
head.push(
`<style data-emotion="${key} ${ids.join(' ')}">${css}</style>`
);
// And we then pass along the rest of the arguments
// with the new `head` and the `html`.
return defaultTemplate({
...rest,
head,
html
});
};
}
},
},
};
And we are done! Now our server side rendering will extract the critical css, move it to head and return the html.
Conclusion
These options are really powerful. I am so in awe with what it can be achieved. For example regarding styling, even though Frontity does come with emotion by default, you can use anything you’d like, because now you can handle the server side extraction.
More importantly though this will allow for an amp
package to be built with ease.
And you also have the init
action which runs before the beforeSSR
and beforeCSR
actions, both in the client and server
Inside that action, if you need to, you can differenciate between server and client using state.frontity.platform
. You can also export different init
functions using src/client.js
and src/server.js
.
I have a question about the defaultRenderer
: How well it would handle multiple packages?
Let’s imagine the case of two packages that add their own logic to the render:
// package-1
export default {
actions: {
package1: {
beforeSSR({ libraries: { frontity } }) {
frontity.render = ({ App, defaultRenderer }) => {
const html = defaultRenderer(<App />);
html += "<tag>Package 1</tag>";
return html;
};
},
},
},
};
// package-2
export default {
actions: {
package2: {
beforeSSR({ libraries: { frontity } }) {
frontity.render = ({ App, defaultRenderer }) => {
const html = defaultRenderer(<App />);
html += "<tag>Package 2</tag>";
return html;
};
},
},
},
};
If I’m not mistaken, the <tag>Package 1</tag>
tag won’t appear, because frontity.render
will be overwritten by the package-2.
Did you think about this?
Also, what do you think about exposing the things that we pass to the template in libraries as well? That way packages won’t have to overwrite the template to add things to the head, for example.
Something like this, inside the libraries.frontity.render
function itself:
libraries.frontity.head.push(
`<style data-emotion="${key} ${ids.join(" ")}">${css}</style>`
);
Oh, indeed! Let me think about it. I’ll get back with a proposal.
That would be really useful indeed. Not particular for emotion in our case, but I can see this easily be the case to push 1st or 3rd party scripts with ease.
Alrighty, so this is achievable but only on the packages side. As it is proposed and implemented right now, Frontity only reads the properties defined on .frontity
namespace. That means effectively one package can override previously defined ones. This is not a drawback in my opinion as these three properties are quite flexible and one needs to be careful about them.
In the case of multiple .render
methods, each package should check of a previous value of frontity.render
and depending on what exactly it’s needed to be achieved should call the previously defined method. Example:
export default {
name: "baz",
actions: {
baz: {
beforeSSR({ libraries: { frontity } }) {
// Hold the previous render in a reference
const previousRender = frontity.render;
// Define the new method
frontity.render = ({ App, defaultRenderer, ...rest }) => {
// In this case we want to wrap the App with a `<baz>` element
// but we could imagine this a provider as well,
// but in that case `.App` should be used instead.
let BazApp = () => (
<baz>
<App />
</baz>
);
// If there's a previousRender defined pass along the new BazApp
if (previousRender) {
return previousRender({
App: BazApp,
defaultRenderer,
...rest,
});
}
// If not use the `defaultRenderer`
return defaultRenderer(<BazApp />);
};
},
},
},
};
Another option is that we could change the .render
API surface to instead be a queue. So that would look something like this.
frontity.setRender(({ App, defaultRenderer }) => {
return defaultRenderer(<App />);
});
But this will not be more effective in handling multiple .render
definitions.
With the .render
method we allow total control of the rendering to a package. And because that means we can not determine how a package will handle it’s own rendering – either using defaultRenderer or it’s own renderer – I don’t believe there’s an API surface that will allow multiple render definitions in a sane way.
But I wanna explore something before settling this.
Ok, I’ve recorded a loom explaining my reasoning
Let me know what you think.
Great explanation Cristian, thanks
Yes, I also think we could go with the wrapper pattern for now and see what happens. We still need to add priorities to the packages, so those who need to go first or last can define it.
I have one question though: What is the benefit of a defaultRender
variable? If the default render is populated in libraries.frontity.render
before we run the middleware/actions, wouldn’t that be the same?
Something like this:
export default {
name: "baz",
actions: {
baz: {
beforeSSR({ libraries: { frontity } }) {
// libraries.frontity.render already points to the default renderer.
const previousRender = frontity.render;
frontity.render = ({ App, ...rest }) => {
let BazApp = () => (
<baz>
<App />
</baz>
);
return previousRender({
App: BazApp,
...rest,
});
};
},
},
},
};
I’ve just realized that differentiating between AMP/not-AMP paths in the code that removes /amp
from the links (this issue) is not needed at all.
The way the @frontity/amp
package is going to work is with a new site:
export default [
{
name: "normal-site",
packages: [
// Normal packages.
],
},
{
name: "amp-site",
match: "\\/amp\\/?($|\\?|#)"
packages: [
"@frontity/amp",
// Normal packages.
],
},
];
It is going to be done that way because an optimized site should not have additional AMP code added to its bundle. With different sites, we can create independent bundles.
The way to load the AMP site is with a match
. The usual configurations are:
- An
amp.domain.com
subdomain. - An
?amp=true
query. - An ending
/amp
path.
The /amp
path will always load the AMP site, so things like /category/amp
will always match.
This means that the /amp
option doesn’t support using amp
for a term or post slug. When people need that, they have to either:
- Choose another option (subdomain or query).
- Add it manually to the
match
regexp.
We should add a warning about this limitation in the documentation when explaining the /amp
option.
You are right, the frontity.render
does hold the default render
method so one can do just that, easily.
The only benefit of defaultRenderer
method is that the package author would not have to ensure the passing of the ...rest
arguments since the serializer
has already been defined. That translates to something in the line of, if the user has no entry points, the defaultRenderer
will call renderToStaticMarkup
instead and not collect the chunks, and so on.
How I see it, render
should be used only when the functionality needs to do something after the App
has been serialized to a string. If the functionality only needs to wrap the App
with providers/custom elements, the App
component should be overwritten instead and let frontity
handle the render.
Do you see the closure capture pattern as a better one? I have no preferences or strong opinions really and my guess is that this will apply to template
as well.
Actually I think the closure capture pattern will enforce a model of always having to call the previous methods, so it’s gonna be an imperative call and the expectancy will be set accordingly.
thanks for the feedback @luisherranz gonna update the implementation and not send the default<method>
at all.
Interesting
To be honest, my plan was to:
- Expose the different parts and let packages overwrite/wrap them, depending on the case.
- Add priorities. Because if a package needs to overwrite, like AMP, it needs to be executed first or it will erase the wrappers.
But maybe we can have a call next week to discuss the different use cases and how each approach would work in each case
Got some more updates and I’ve managed to achieve the closure capture pattern seamlessly! Really comfortable now that the packages are forced now to use this pattern, that will not lead to inconsistencies to the output.
The final package implementation for AMP would looks like this:
import { CacheProvider } from "@emotion/react";
import createEmotionServer from "@emotion/server/create-instance";
import createCache from "@emotion/cache";
export default {
// [...] rest of the .settings.js
actions: {
theme: {
beforeSSR({ libraries: { frontity } }) {
// Get the previous reference
const previousRender = frontity.render;
// Let's say you want to 'custom' render your app with special tags.
frontity.render = ({ App, ...rest }) => {
const key = "frontity";
const cache = createCache({ key });
const { extractCritical } = createEmotionServer(cache);
// Call the extractCritical. This will return an object as { html, ids, css }
const result = extractCritical(
// `previousRender` is the default render method that Frontity uses internally.
previousRender({
App: () => (
<CacheProvider value={cache}>
<App />
</CacheProvider>
)
})
);
// We can safely return here the emotion critical call result
// since we're gonna handle this result ourselves.
return {
...result,
key
};
};
const previousTemplate = frontity.template;
// Custom `template` function.
frontity.template = ({ result, head, ...rest }) => {
// We grab the resulted html, ids, css and the cache key.
const { html, ids, css, key } = result;
// And we push to the `head` the new style tag.
head.push(
`<style data-emotion="${key} ${ids.join(' ')}">${css}</style>`
);
// And we then pass along the rest of the arguments
// with the new `head` and the `html`.
return previousTemplate({
...rest,
head,
html
});
};
}
},
},
};
Awesome
There is one thing I didn’t include in the IP but maybe it makes sense: Expose the inners of the template in libraries
.
libraries.frontity.head.title
libraries.frontity.head.meta
libraries.frontity.head.script
libraries.frontity.head.style
- …
Then, when we get the ones generated in React, we either overwrite them or merge them:
const head = getHeadTags(helmetContext.helmet);
// Maybe overwrite some values.
libraries.frontity.head.title = head.title;
// And merge others.
libraries.frontity.head.meta += head.meta;
libraries.frontity.head.script += head.script;
libraries.frontity.head.style += head.style;
I wonder if we can use this to hook from the AMP into the head.style
and refactor it in a way that all the styles end up inside the single <style amp-custom>
tag.