Skip to content

Commit

Permalink
frontend: rework React/SolidHead into Head
Browse files Browse the repository at this point in the history
  • Loading branch information
terrablue committed Sep 23, 2023
1 parent 2766a77 commit 6b986fc
Show file tree
Hide file tree
Showing 14 changed files with 189 additions and 44 deletions.
5 changes: 5 additions & 0 deletions docs/blog/introducing-the-head-element.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"title": "Introducing the Head element",
"epoch": 1695159400000,
"author": "terrablue"
}
144 changes: 144 additions & 0 deletions docs/blog/introducing-the-head-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
Today we're introducing `Head` component for React and Solid that mimics the
behavior of `<svelte:head>` for Svelte.

Primate aims for feature parity across its supported frontend frameworks.
Specifically, Svelte has a feature that React and Solid lack, the ability to
manage the `<head>` part of the HTML document individually from components.
This includes supporting client rendering, SSR, and extracting head tags from
several components embedded in each other across the component hierarchy of a
page, including layouts and imported components.

!!!
If you're new to Primate, we recommend reading the [Getting started] page to
get an idea of the framework.
!!!

## Install

To use `Head`, update `@primate/frontend` to version `0.5.0` or later.

## Use

In a component of your choice, import `Head` from `@primate/frontend/react` and
use it anywhere within the component.

```js caption=components/PostIndex.jsx
import {Head} from "@primate/frontend/react";

export default function (props) {
return <>
<Head>
<title>All posts ({props.posts.length})</title>
</Head>
<h1>All posts</h1>
<For each={props.posts}>
{(post) => <h2><a href={`/post/view/${post.id}`}>{post.title}</a></h2>}
</For>
<h3><a href="/post/edit/">add post</a></h3>
</>;
}
```

!!!
For Solid, replace `@primate/frontend/react` with `@primate/frontend/solid`.
!!!

You can also use `Head` in any layout. During SSR, a combined list of head
tags will be generated and sent along with the page. Later during hydration,
the client components will take over management of their head tags.

If you use `@primate/liveview` to navigate between pages without a full reload,
`Head` will manage its head tags between page changes, automatically removing
the tags used by the previous page's components and inserting new ones. Tags in
`pages/app.html` won't be managed by `Head` and will be left intact.

## Use outside of Primate

As `@primate/frontend` exports `react/Head` and `solid/Head` and has virtually
no dependencies, you can use it even if you don't use Primate itself.

### Without SSR

If you don't care for SSR, simply import `Head` and use it within your React
or Solid components.

### With SSR

Unlike Svelte, both React and Solid compile a component entirely into a string.
That makes it difficult to extract any head parts that have been used in an
individual component down the hierarchy.

To extract the head part, we need to pass a function prop to `Head` that it can
then call with its children. This function prop then mutates a closure
variable.

To do so, we use contexts in both React and Solid. Contexts are a way for a
parent component to create props that are accessible to all its children
components, and *their* children, down the tree. Our implementation, which
you would need to replicate if you want to support SSR, looks roughly as
follows.

This function mimics the signature of a Svelte component's `render` function.

```js caption=server-render-react.js
import {renderToString} from "react-dom/server";
import {createElement} from "react";

const render = (component, props) => {
const heads = [];
const push_heads = sub_heads => {
heads.push(...sub_heads);
};
const body = renderToString(createElement(component, {...props, push_heads}));
const head = heads.join("\n");

return {body, head};
};
```

And the same for Solid.

```js caption=server-render-solid.js
import {renderToString} from "solid-js/web";

export const render = (component, props) => {
const heads = [];
const push_heads = sub_heads => {
heads.push(...sub_heads);
};
const body = renderToString(() => component({...props, push_heads}));
const head = heads.join("\n");

return {body, head};
};
```

The only thing left to do is wrap your root component with a context provider.
It is assumed that `body` here contains your component hierarchy.

```js caption=root-component-react.jsx
import {HeadContext, is} from "@primate/frontend/react";
const Provider = HeadContext.Provider;

export default ({components, data, push_heads: value}) =>
is.client ? body : <Provider value={value}>{body}</Provider>;
```

For Solid, use `@primate/frontend/solid` instead for the import.

We use here the `is` export to check if we're on the client or the server. You
don't have to do it, but using the provider on the client doesn't make a lot of
sense.

## Fin

Warm thanks to [ralyodio] for the idea and his incessant support for Primate.

If you like Primate, consider [joining our channel #primate][irc] on
irc.libera.chat.

Otherwise, have a blast with `Head`!

[Getting started]: /guide/getting-started
[irc]: https://web.libera.chat/gamja#primate
[ralyodio]: https://github.com/ralyodio
1 change: 0 additions & 1 deletion packages/frontend/src/client/exports.js

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const render = (maybe_children, id) => {
if (!all_good) {
const bad = `<${children.find(({type}) => !allowed.includes(type)).type}>`;
const alloweds = `${allowed.map(tag => `<${tag}>`).join(", ")}`;
const error = `ReactHead may only contain ${alloweds}, found ${bad}`;
const error = `Head may only contain ${alloweds}, found ${bad}`;
throw new Error(error);
}

Expand Down Expand Up @@ -61,7 +61,7 @@ const clear = (data_value) => {
});
};

const Head = class ReactHead extends Component {
const Head = class Head extends Component {
// clearing after SSR and before root hydration
static clear(data_value = data_ssr) {
clear(data_value);
Expand Down Expand Up @@ -99,4 +99,9 @@ const Head = class ReactHead extends Component {

Head.contextType = HeadContext;

export {Head, HeadContext};
const is = {
client: is_client,
server: !is_client,
};

export {Head, HeadContext, is};
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,9 @@ const render = (maybe_children, id) => {
const tags = children.map(({t: tag}) => tag);
const all_good = tags.every(tag => allowed.includes(get_tag(tag)));
if (!all_good) {
const name = "SolidHead";
const bad = get_tag(tags.find(tag => !allowed.includes(get_tag(tag))));
const alloweds = `${allowed.map(tag => `<${tag}>`).join(", ")}`;
const error = `${name} may only contain ${alloweds}, found <${bad}>`;
const error = `Head may only contain ${alloweds}, found <${bad}>`;
throw new Error(error);
}

Expand All @@ -63,7 +62,7 @@ const clear = (data_value) => {
});
};

const Head = function SolidHead(props) {
const Head = function Head(props) {
let id;
onMount(() => {
if (is_client) {
Expand All @@ -89,4 +88,9 @@ const Head = function SolidHead(props) {

Head.clear = (data_value = data_ssr) => clear(data_value);

export {Head, HeadContext};
const is = {
client: is_client,
server: !is_client,
};

export {Head, HeadContext, is};
9 changes: 8 additions & 1 deletion packages/frontend/src/exports.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
export * from "./frontends/exports.js";
export * from "./shared/exports.js";

const is_client = globalThis.document?.createElement !== undefined;
const is = {
client: is_client,
server: !is_client,
};

export {is};
4 changes: 2 additions & 2 deletions packages/frontend/src/frontends/react/client/create-root.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export default length => {

return `
import {createElement} from "react";
import {ReactHeadContext, is} from "@primate/frontend";
const {Provider} = ReactHeadContext;
import {HeadContext, is} from "@primate/frontend/react";
const {Provider} = HeadContext;
export default ({components, data, push_heads: value}) =>
is.client ? ${body} : createElement(Provider, {value}, ${body});
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/frontends/react/client/expose.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ export default `
const {createElement} = React;
export {createElement};
export {hydrateRoot} from "react-dom/client";
export {ReactHead} from "@primate/frontend";
export {Head as ReactHead} from "@primate/frontend/react";
`;
4 changes: 1 addition & 3 deletions packages/frontend/src/frontends/react/imports.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ export const prepare = async app => {
...valmap(imports, value => `${new Path("/", library, module, value)}`),
};

if (app.importmaps["@primate/frontend"] === undefined) {
await app.import("@primate/frontend");
}
await app.import("@primate/frontend", "react");
// expose code through "app", for bundlers
await app.export({type: "script", code: expose});
};
4 changes: 2 additions & 2 deletions packages/frontend/src/frontends/solid/client/create-root.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ export default length => {

return `
import {createComponent} from "solid-js/web";
import {SolidHeadContext, is} from "@primate/frontend";
import {HeadContext, is} from "@primate/frontend/solid";
const Provider = SolidHeadContext.Provider;
const Provider = HeadContext.Provider;
export default ({components, data, push_heads: value}) =>
is.client ? ${body} : <Provider value={value}>{${body}}</Provider>;
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/frontends/solid/client/expose.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export default `
export {hydrate as hydrate_solid, render as render_solid} from "solid-js/web";
export {SolidHead} from "@primate/frontend";
export {Head as SolidHead} from "@primate/frontend/solid";
`;
4 changes: 1 addition & 3 deletions packages/frontend/src/frontends/solid/imports.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ export const prepare = async app => {
await depend("solid-js", app, true);
await depend("solid-js/web", app);

if (app.importmaps["@primate/frontend"] === undefined) {
await app.import("@primate/frontend");
}
await app.import("@primate/frontend", "solid");
// expose code through "app", for bundlers
await app.export({type: "script", code: expose});
};
Expand Down
19 changes: 0 additions & 19 deletions packages/frontend/src/shared/exports.js

This file was deleted.

12 changes: 8 additions & 4 deletions packages/primate/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export default async (log, root, config) => {
const prefix = algorithm.replace("-", _ => "");
return `${prefix}-${btoa(String.fromCharCode(...new Uint8Array(bytes)))}`;
},
async import(module) {
async import(module, deep_import) {
const {http: {static: {root}}, location: {client}} = this.config;

const parts = module.split("/");
Expand All @@ -188,12 +188,16 @@ export default async (log, root, config) => {
const exports = pkg.exports === undefined
? {[module]: `/${module}/${pkg.main}`}
: transform(pkg.exports, entry => entry
.filter(([, export$]) => export$.browser?.default !== undefined
.filter(([, export$]) =>
export$.browser?.[deep_import] !== undefined
|| export$.browser?.default !== undefined
|| export$.import !== undefined
|| export$.default !== undefined)
.map(([key, value]) => [
key.replace(".", module),
value.browser?.default.replace(".", `./${module}`)
key.replace(".", deep_import === undefined
? module : `${module}/${deep_import}`),
value.browser?.[deep_import]?.replace(".", `./${module}`)
?? value.browser?.default.replace(".", `./${module}`)
?? value.default?.replace(".", `./${module}`)
?? value.import?.replace(".", `./${module}`),
]));
Expand Down

0 comments on commit 6b986fc

Please sign in to comment.