Slot and Fill

Description

There’s a powerful pattern in React called Slot and Fill which allows the insertion of any React component in different places of the app. It is perfect when the application is extensible, like Frontity.

In the case of Frontity, this will allow the theme developer to insert <Slot> components among the theme, and other package developers to add <Fill> components hooked to a particular Slot.

User Stories

As a Frontity theme consumer
I want to add additional components to the theme
so that I can customize to my needs

As a Frontity theme consumer
I want to substitute certain components of the theme
so that I can completely override parts of the theme

As a Frontity theme consumer
I want to specify some data assertions for my fills
so that I can fill the slots only under certain conditions

Moved to: Data assertions for Fills

As a Frontity theme creator
I want to use a hook instead of a component for Slots
so that I can have more control of the insides

Moved to: useFills hooks

Examples

  • Add an ad above the first post of any archive.
  • Add a newsletter subscription widget after the eighth post of the home.
  • Add more elements to a menu, for example, a link to the GDPR or a notifications switch.
  • Substitute the theme footer for a new custom one.

Possible solution

Initial proposed solution (click to open)

Slots in the theme

The theme developers can add Slots wherever they want.

import { Slot } from "frontity";

const Menu = () => {
  return (
    <>
      <Slot name="before-menu" />
      <MenuContainer>
        <MenuItem1 />
        <MenuItem2 />
        <MenuItem3 />
        <Slot name="menu-items" />
      </MenuContainer>
      <Slot name="after-menu" />
    </>
  )
};

Themes need to export a list of Slots to be used in the upcoming Frontity Admin UI.

Slots in the content

Slots in the content don’t need to be added by the theme because any package can add processors.

Possible Solutions:

  1. Add common Slot processors to html2react for things like:
    • "after-third-paragraph"
    • "before-first-image"
    • "after 500 characters"

This would need the ability to add components instead of substituting them in html2react. I don’t know if this is possible.

import { afterThirdParagraph } from "@frontity/html2react/processors/slots";

{
  ...
  libraries: {
    html2react: {
      processors: [image, afterThirdParagraph]
    }
  }
}
  1. Add multiple Slot processors at once:
import slots from "@frontity/html2react/processors/slots";

{
  ...
  libraries: {
    html2react: {
      processors: [image, slots]
    }
  }
}
  1. Let “Fill package” deal with this themselves:
  packages: [
  ...
  {
    name: "some-ads-package",
    state: {
      fills: [
        {
          name: "ad-1",
          id: "123456",
          slot: "content-after-third-paragraph"
        }
      ]
    }
  }
  ]

Passing props to Fills

Decisions:

  1. How to pass props to Fills?
  2. Are there some standard props that need to be passed or is it up to the theme and Slot to decide?

How to pass props to Fills?

  • By including the data in the Slot.
    • Pro: It is simpler.
const SomeSlot = ({ state }) => {
  return (
    <Slot name="footer" data={state.source.get("/some-page")} />
  )
};
  • By using a render prop.
    • Pro: It is more hackable.
const SomeSlot = ({ state }) => {
  return (
    <Slot name="footer">
     {fills =>
       fills.map(Fill => <Fill data={state.source.get("/some-page")} />
     }
    </Slot>
  )
};

In both cases, the props are passed down to any direct children of Fill, like Page here:

const SomeFill = ({ state }) => {
  return (
    <Fill name="footer">
      <Page>
    </Fill>
  )
};

Post now has access to data.

const Page = ({ state, data }) => {
  const page = state.source.page[data.id];
  return <div>{page.title.rendered}</div>;
};

Although this is a bit too magical for the developer creating the Fill. Maybe a render prop could be used instead:

const SomeFill = ({ state }) => {
  return (
    <Fill name="footer">
      {({ data }) => <Page data={data}>
    </Fill>
  )
};

We need to take a good look at how TypeScript can work here.

Slot substitution

  • Fallback to a component by passing a default component as children.
    • Pro: It is simpler.
const SomeSlot = () => {
  return (
    <Slot name="footer">
       <DefaultFooter />
    </Slot>
  )
};
  • Fallback to a component by checking the length of fills.
    • Pro: It is more hackable.
const SomeSlot = () => {
  return (
    <Slot name="footer">
     {({ fills }) => {
       if (fills.length === 0) return <DefaultFooter />;
       return fills.map(Fill => <Fill />);
     }}
    </Slot>
  )
};
  • This could be the default children if no render prop is passed:
const SomeSlot = () => {
  return (
    <Slot name="footer">
     {fills => fills.map(Fill => <Fill />)}
    </Slot>
  )
};

Which is actually equivalent to this, but somehow seems simpler to understand what’s going on:

const SomeSlot = () => {
  return (
    <Slot name="footer">
     {fills => fills}
    </Slot>
  )
};

References

  1. This is the library that started the pattern: https://github.com/camwest/react-slot-fill
    As far as I know, it is still based on the old context API.
  2. Gutenberg has its own implementation, based on camwest library: https://github.com/WordPress/gutenberg/tree/master/packages/components/src/slot-fill
    It’s a modern implementation of the same, so it’s probably great to take a look.
  3. Javi Velasco made a very similar one: https://github.com/javivelasco/react-tunnels
  4. There’s another library made by a guy named Jonatan Salas: https://github.com/BlackBoxVision/react-slot-fill
  5. Jason Miller did a SlotFill library for Preact as well: https://github.com/developit/preact-slots

Implementation proposal

I would like to propose a new implementation which is slightly different than what I proposed in the OP but it’s more native to Frontity.

My idea is that, instead of using an internal React context, the fills are saved in the state. The actual match between slots and fills is done by the Slot component, but accessing the state, insead of a React context.
Moving configuration to the state is going to be a common pattern in Frontity.

The slots are added by the theme:

const Header = () => (
  <Slot name="Above header" />
  <Menu />
  <MobileMenu />
  { /* ... */ }
  <Slot name="Below header" />
);

And the fills are added to the state, to a common namespace called fills.

// frontity.settings.js
const settings = {
  packages: [
    {
      name: "smartads",
      state: {
        fills: {
          smartads1: {
            slot: "Below Header",
            library: "SmartAd",
            props: {
              networkId: "123",
              id: "456"
            }
          },
          smartads2: {
            slot: "Below Header",
            library: "SmartAd",
            priority: 5,
            props: {
              networkId: "123",
              id: "789"
            }
          }
        }
      }
    }
  ]
};

The Fill component is specificed by the property library and stored in libraries:

// /packages/smartads/src/index.ts
import HeadScripts from "./components/head-scripts";
import SmartAd from "./components/ad";

export default {
  roots: {
    smartads: HeadScripts
  },
  libraries: {
    fills: {
      SmartAd
    }
  }
};

With this technique, maybe we can also get rid of the weird name Roots because we won’t need fills anymore, and rename it to something easier to understand. Also, fills can have priorities.

If fills need to access the state they can do so themselves.

const SmartAd = ({ state, networkId, id }) => {
  // ...
  return <div smartads-id={id} />;
};

export default connect(SmartAd);

The Slot component will get the fills it needs from the state.

const Slot = ({ state, actions, libraries, name, ...slotProps }) => {
  const fills = Object.values(state.fills)
    .find(fill => fill.name === name)
    .sort((a, b) => a.priority > b.priority);

  return fills.map(fill => {
    const Fill = libraries.fills[fill.library];
    return <Fill {...slotProps} {...fill.props} />;
  });
};

As you can see, it passes down the props it receives along with the props of fill itself. That means that people can document their slots to instruct people about the props that their fills will receive.

For example, the slots of a Home Carrousel package can pass info about each post.

const HomeCarrousel = ({ state }) => {
  // Get first 5 items from the home.
  const homePosts = state.source.get("/").items.slice(0, 4);

  return (
    <>
      <Slot name="Before all posts in home carrousel" />

      {homePosts.map((item, index) => {
        const data = state.source.get(item.link);
        return (
          <>
            <Slot name={`Before post ${index} in home carrousel`} data={data} />
            <Post data={data} />
            <Slot name={`After post ${index} in home carrousel`} data={data} />
          </>
        );
      })}

      <Slot name="After all posts in home carrousel" />
    </>
  );
};

The Slot component can support an optional children that is rendered if no fills are present.

const Post = () => (
  <>
    {/* ... */}
    <PostTitle />
    <Slot name="Between post title and post meta">
      <Seperator />
    </Slot>
    <PostMeta />
    {/* ... */}
  </>
);

In the future, once we implement the useConnect hook, we can expose a useFills hook to make Slots even more hackable.

const useFills = name =>
  useConnect(({ state }) =>
    Object.values(state.fills)
      // Match only the fills for this name.
      .find(fill => fill.name === name)
      // Sort fills by priority.
      .sort((a, b) => a.priority > b.priority)
      // Add real component to the array.
      .map(fill => {
        const Fill = libraries.fills[fill.library];
        return {
          ...fill,
          Fill
        };
      })
  );

And then use that hook instead of the Slot component.

For example, a theme can use useFills to render the fills in-between <li> tags:

const Menu = ({ state }) => {
  const menuItems = state.theme.menu;
  const menuFills = useFills("Menu items");

  return (
    <>
      <Slot name="Before menu" />
      <ul>
        {menuItems.map(item => (
          <li>
            <Link href={item[1]}>{item[0]}</Link>
          </li>
        ))}
        {menuFills.map(({ Fill, props }) => (
          <li>
            <Fill {...props} />
          </li>
        ))}
      </ul>
      <Slot name="After menu" />
    </>
  );
};

While talking with @David about this we thought that we could add data assertions to the fills.

They will look like this:

const state = {
  fills: {
    homeAd: {
      slot: "Below header",
      library: "Adsense",
      props: {
        adId: 123
      },
      assertions: {
        data: {
          isHome: true
        }
      }
    },
    postsAd: {
      slot: "Below header",
      library: "Adsense",
      props: {
        adId: 456
      },
      assertions: {
        data: {
          isPostType: true
        }
      }
    },
    categoriesAd: {
      slot: "Below header",
      library: "Adsense",
      props: {
        adId: 789
      },
      assertions: {
        data: {
          isTaxonomy: true,
          taxonomy: category
        }
      }
    }
  }
};

The Slot component will run those assertions against data:

const Slot = ({
  state,
  actions,
  libraries,
  data: dataProp,
  name,
  ...slotProps
}) => {
  const data = dataProp || state.source.get(state.router.link);
  const fills = Object.values(state.fills)
    .find(fill => fill.name === name)
    .sort((a, b) => a.priority > b.priority);

  return fills.map(fill => {
    const Fill = libraries.fills[fill.library];
    const show = assert(data, fill.assertions);
    return show ? <Fill {...slotProps} {...fill.props} /> : null;
  });
};

As you can see, Slot can receive a custom data object. If it doesn’t, the assertions run against the current data object.

To support more than “equal” assertions, they can be a value or an object. We can start with "equal" and "notEqual" and add more in the future.

The default will be “equal” so these are equivalent:

const assertions = {
  data: {
    isHome: true,
    isHome: {
      type: "equal",
      value: true
    }
  }
};

Using an object opens new possibilities:

const state = {
  fills: {
    allPostTypesButProducts: {
      assertions: {
        data: {
          isPostType: true,
          type: {
            type: "notEqual",
            value: "products"
          }
        }
      }
    }
  }
};

ORs could be done with arrays:

const state = {
  fills: {
    homeAndCategories: {
      assertions: {
        data: [
          {
            isHome: true
          },
          {
            isTaxonomy: true,
            taxonomy: "category"
          }
        ]
      }
    }
  }
};

This system can be used in other parts of Frontity, like the handlers of Source v2.

2 Likes

Implementation Proposal

1. Fill configuration

Fills are defined in the state, under the namespace state.fills.

That can be done in either the frontity.settings.js or a package state.

// frontity.settings.js
const settings = {
  packages: [
    {
      name: "smartads",
      state: {
        fills: {
          smartAdserver: {
            ad1: {
              slot: "Below Header",
              library: "SmartAdserver.Ad",
              priority: 10,
              props: {
                networkId: "123",
                id: "456",
              },
            },
            ad1: {
              slot: "Below Header",
              library: "SmartAdserver.Ad",
              priority: 5,
              props: {
                networkId: "123",
                id: "789",
              },
            },
          },
        },
      },
    },
  ],
};

Fills configuration objects have:

const state = {
  fills: {
    namespace: {
      nameOfTheFill: {
        slot: "Name of the slot they want to feel",
        library: "namespace.NameOfTheFillInLibraries",
        priority: 5,
        props: {
          // Object with props that will be passed to the component.
        },
      },
    },
  },
};
  • object key: Name of your fill, must be unique.
  • slot: Name of the slot they want to feel. Required.
  • library: Name of the component they want to use. Required.
  • priority: Priority of the fill. Default is 10.
  • props: Object with props that will be passed to the component. Optional.

Fills configuration objects can have a false value.

const state = {
  fills: {
    namespace: {
      nameOfTheFill: false,
    },
  },
};

This is useful if a package creates a fill by default and a user (or another package) wants to turn it off.

There’s nothing to do about this part in our code, except for docs and tests.

2. Fill components

The Fill components are going to be exposed in libraries.fills by Frontity packages. Like this:

import { MyFill1, MyFill2 } from "./fills";

export default {
  state: {
    //...
  },
  actions: {
    //...
  },
  libraries: {
    fills: {
      namespace: {
        MyFill1,
        MyFill2,
      },
    },
  },
};

Packages should document the fills in their own docs. Which props the accept and so on. A good example are the fills of the ads packages, like:

There’s nothing to do about this part in our code, except for docs and tests.

3. Slot component

The Slot component will be exposed in frontity package.

It receives these props:

  • name: The name that describes the placement of this Slot inside the theme. For example “Above the footer” or “After post 7”.
  • children: The default component rendered if not fills for that Slot are present.
  • data: The data object relevant for this Slot. If no data object is provided, the Slot will use the data object of the current URL.
  • any other prop: The theme can specify other props and they will be passed down to the fills.

Slots without data prop are like this:

import { Slot } from "frontity";

const Theme = ({ state }) => (
  <>
    <Slot name="Above Header" />
    <Header />
    <Slot name="Below Header" />
    {/* ... */}
  </>
);

Slots with data prop are like this:

import { Slot } from "frontity";

const Carrousel = ({ state }) => {
  // Get latest posts.
  const homeData = state.source.get("/");

  return homeData.items.map((post, index) => {
    const data = state.source.get(post.link);
    return (
      <>
        <Slot data={data} name={`Before post ${index}`} />
        <PostCard />
        <Slot data={data} name={`After post ${index}`} />
      </>
    );
  });
};

The Slot component will support an optional children that is rendered if no fills are present.

const Post = () => (
  <>
    {/* ... */}
    <PostTitle />
    <Slot name="Between post title and post meta">
      <Seperator />
    </Slot>
    <PostMeta />
    {/* ... */}
  </>
);

The Slot component implementation could be something like this:

const Slot = ({ state, libraries, name, children, ...slotProps }) => {
  // Get the data, either from props or the current link.
  const data = slotProps.data || state.source.get(state.router.link);
  // Get the fills for this name and sort them by priority.
  const fills = Object.values(state.fills)
    .find((fill) => fill.slot === name)
    .map((fill) => ({ ...fill, priority: fill.priority || 10 }))
    .sort((a, b) => a.priority > b.priority);

  return fills.length > 0
    ? fills.map((fill) => {
        const Fill = libraries.fills[fill.library];
        return <Fill {...slotProps} {...fill.props} />;
      })
    : children;
};

Possible issues

  • Create the Slot component.

Implementation Proposal Update

Now that we are about to merge the useConnect hook, I don’t think it make sense anymore to create the Slot component and the useFills hook separately. We should create the useFills hook and then add use that logic inside the Slot component.

The useFills hook could be something like this:

const useFills = (name) => {
  const { state, libraries } = useConnect();

  return (
    Object.values(state.fills)
      // Match only the fills for this name.
      .filter((fill) => fill.slot === name)
      // Add the real component and default priority to the fill object.
      .map((fill) => ({
        ...fill,
        Fill: libraries.fills[fill.library],
        priority: fill.priority || 10,
      }))
      // Sort fills by priority.
      .sort((a, b) => a.priority > b.priority)
  );
};

And can be used like this:

const Menu = ({ state }) => {
  const menuItems = state.theme.menu;
  const menuFills = useFills("Menu items");

  return (
    <>
      <Slot name="Before menu" />
      <ul>
        {menuItems.map((item) => (
          <li>
            <Link href={item[1]}>{item[0]}</Link>
          </li>
        ))}
        {menuFills.map(({ Fill, props }) => (
          <li>
            <Fill {...props} />
          </li>
        ))}
      </ul>
      <Slot name="After menu" />
    </>
  );
};

And the Slot component will use the useFills hook internally. Something like this:

const Slot = ({ name, children, ...slotProps }) => {
  // Get the data, either from props or the current link.
  const data = slotProps.data || state.source.get(state.router.link);
  // Get the fills for this name.
  const fills = useFills(name);

  return fills.length > 0
    ? fills.map(({ Fill, props }) => <Fill {...slotProps} {...props} />)
    : children;
};

This adds a little bit of work to the sprint, but it’s less work in the long run because we will reuse the logic of useFills internally in Slot.

Possible issues

  • Create the useFills hook.
  • Create the Slot component.

@santosguillamot, if you consider this interesting, add the new task to the current sprint and divide the points between both. My take is that from the 8 points, we could assign 5 to the hook and 3 to the component.

2 Likes

I feel it’s great, thanks Luis :slightly_smiling_face: I’ve already updated the GitHub board with both issues and I’ve seen that @mmczaplinski started a Pull Request for the hook - https://github.com/frontity/frontity/pull/430

1 Like

Fills are going to need the key and the fill name is a perfect candidate, because it’s guaranteed to be unique (is the key of an object), so let’s add that to the final array as well.

As fills are going to be a framework feature, it would be nice to add typings for state.fills and libraries.fills in Settings and Package.

As far as I understand this would have to be more like:

const state = {
	fills: {
    	nameOfTheFill: {
			slot: "Name of the slot they want to fill",
			library: null,
			priority: 1
		}
	}
}

because the nameOfTheFill is just a key that is assigned by the user of a theme. They still have to specify which slot and from which library they want to “turn off”.

But there can be many fills for a single slot and all them will have different names. For example, a package can expose:

const state = {
  fills: {
    adPackage: {
      ads1: {
        slot: "Slot 1",
        library: "adPackage.AdComponent",
        props: {
          slotId: 123,
        },
      },
      ads2: {
        slot: "Slot 1",
        library: "adPackage.AdComponent",
        props: {
          slotId: 456,
        },
      },
    },
  },
};

And users can choose to disable only one on their frontity.settings.js file:

const settings = {
  state: {
    fills: {
      adPackage: {
        ads2: false,
      },
    },
  },
};

Do you think that is a problem?

Hmmm, I think I misunderstood how the “key” ('nameOfTheFill`) is going to be used. I’ve realized that that it’s not going to be a problem, in fact.

The useFills hook is implemented in https://github.com/frontity/gitbook-docs/pull/69
according to Slot and Fill

The <Slot/> component is going to be implemented in another PR (to be completed)

Totally my fault that I didn’t mention this, but this APIs should go on the main "frontity" package.

After working on other parts of Frontity that are going to follow the same library: "code" pattern in a configuration object and realizing that we are going to need namespaces for some of them to avoid name collisions, I have decided to use namespaces for all of them, including fills. I think that if we don’t do it it’s going to be confusing which pattern has namespaces and which not.

I have edited the Implementation Proposal to reflect this change:

export default {
  state: {
    fills: {
      namespace: {
        fillName: {
          library: "namespace.ComponentName",
          /* other properties */
        },
      },
    },
  },
  libraries: {
    fills: {
      namespace: {
        ComponentName,
      },
    },
  },
};