Improving the Prismic Preview developer experience
An RFC presenting a higher-level abstraction over usePrismicPreview and mergePrismicPreviewData primarily through the use of higher-order components.
The following request for comments (RFC) was posted to
angeloashmore/gatsby-source-prismic
to gather feedback before development. The proposed features have been finalized and are included inv3.1
.
gatsby-source-prismic
v3 introduced a client-side preview system that
integrates with Prismic’s REST API via prismic-javascript
.
The core APIs powering previews is the new usePrismicPreview
hook and
mergePrismicPreviewData
utility function. Under the hood, usePrismicPreview
fetches preview data and runs it through the same data transformers used in the
source plugin at build-time.
This system provides developers low-level functions to create custom preview systems that work with their existing custom setup. Although powerful, such a system may be more low-level than necessary for most users.
The following RFC presents a higher-level abstraction over usePrismicPreview
and mergePrismicPreviewData
primarily through the use of higher-order
components.
- Provide a
withPreview
HOC that orchestrates merging preview data fromusePrismicPreview
transparently usingmergePrismicPreviewData
. - Provide a
withUnpublishedPreview
HOC that orchestrates previewing unpublished documents using the 404 page. - Provide a
withPreviewResolver
HOC that orchestrates fetching preview data, saving that data to a global store, and redirecting to an appropriate page. - Provide a global store to store and manage preview data with direct access if needed.
- A clear focus on facilitating data flow and nothing more.
This sets up developers to easily integrate Prismic Previews with
gatsby-source-prismic
. It also opens the door to external integrations by
standardizing the data store.
HOC: withPreview
withPreview
simplifies the process of using static page data alongside dynamic
preview data by automatically performing the merging.
The withPreview
HOC performs the following:
- Checks the global preview store for data associated with the page’s path.
- If preview data is available, use
mergePrismicPreviewData
to merge the static and preview data objects.
Templates would not need to include usePrismicPreview
or
mergePrismicPreviewData
and instead can be written like standard pages. The
data
prop received by Gatsby page components will include the merged preview
data automatically if needed.
Considerations
- Should there be a way to opt out of a preview even if preview data is saved? Think: compare published and previewed versions quickly via a toggle.
- What happens if a site uses multiple Prismic repositories? Should
withPreview
default to the first repo defined but allow options to pass tousePrismicPreview
such as the repository name?
Example usage
Note that the default export is wrapped with the withPreview
HOC.
// src/templates/page.js import * as React from 'react' import { graphql } from 'gatsby' import { withPreview } from 'gatsby-source-prismic' import { Layout } from '../components/Layout' const PageTemplate = ({ data }) => { const page = data.prismicPage return ( <Layout> <h1>{page?.data?.title?.text}</h1> </Layout> ) } // Note the use of `withPreview` here. export default withPreview(PageTemplate) export const query = graphql` query PageTemplate($uid: String!) { prismicPage(uid: { eq: $uid }) { data { title { text } } } } `
Example implementation
Note that usePreviewStore
is a new hook defined in this RFC.
import * as React from 'react' import { PageProps, Node } from 'gatsby' import { usePreviewStore, mergePrismicPreviewData } from 'gatsby-source-prismic' const getDisplayName = (WrappedComponent: React.ComponentType<any>) => WrappedComponent.displayName || WrappedComponent.name || 'Component' export const withPreview = <TProps extends PageProps>( WrappedComponent: React.ComponentType<TProps>, ): React.ComponentType<TProps> => { const WithPreview = (props: TProps) => { const [state] = usePreviewStore() const path = props.location.pathname const staticData = props.data const previewData = state.pages[path] const data = React.useMemo( () => state.enabled ? mergePrismicPreviewData({ staticData, previewData: previewData as { [key: string]: Node }, }) : staticData, [state.enabled, staticData, previewData], ) return <WrappedComponent {...props} data={data} /> } WithPreview.displayName = `withPreview(${getDisplayName(WrappedComponent)})` return WithPreview }
HOC: withUnpublishedPreview
withUnpublishedPreview
simplifies the process of hooking up preview data to a
page that does not yet exist in the Gatsby site. This can be accomplished by
hooking into the site’s 404 page for a seamless integration.
The withUnpublishedPreview
HOC performs the following:
- Checks the global preview store for data associated with the page’s path.
- If preview data is available, find an appropriate component to render (i.e. a Gatsby page template) using a user-provided function or map.
- If a component is found, return the component.
- If a component is not found, or preview data is not available for the path (i.e. it is a real 404 request), yield to the wrapped component.
Considerations
- We cannot assume a specific method in which users create pages. A document’s type may not be a one-to-one mapping to a template. Think: a document’s field determines which template to use and thus requires a function to determine its template.
- Is recommending the 404 page as a means of showing unpublished previews okay? How much does this rely on the server to correctly point to the 404 page? Any implications in returning a 404 response code?
Example usage
Note that the default export is wrapped with the withUnpublishedPreview
HOC
along with its options.
Also note that the templates are imported using their default exports as they
are wrapped with the withPreview
HOC.
// src/pages/404.js import * as React from 'react' import { withUnpublishedPreview } from 'gatsby-source-prismic' import { Layout } from '../components/Layout' import PageTemplate from '../templates/page' import BlogPostTemplate from '../templates/blogPost' const NotFoundPage = () => ( <Layout> <h1>Not found!</h1> </Layout> ) // Note the use of `withUnpublishedPreview` here. export default withUnpublishedPreview(NotFoundPage, { templateMap: { page: PageTemplate, blogPost: BlogPostTemplate, }, })
Example implementation
Note that usePreviewStore
is a new hook defined in this RFC.
import * as React from 'react' import { PageProps, Node } from 'gatsby' import { usePreviewStore, mergePrismicPreviewData } from 'gatsby-source-prismic' const getDisplayName = (WrappedComponent: React.ComponentType<any>) => WrappedComponent.displayName || WrappedComponent.name || 'Component' type WithUnpublishedPreviewArgs = { templateMap: Record<string, React.ComponentType<any>> } export const withUnpublishedPreview = <TProps extends PageProps>( WrappedComponent: React.ComponentType<TProps>, options: WithUnpublishedPreviewArgs, ): React.ComponentType<TProps> => { const WithUnpublishedPreview = (props: TProps) => { const [state] = usePreviewStore() const isPreview = state.pages.hasOwnProperty(props.location.pathname) if (isPreview) { const key = Object.keys(props.data)[0] const TemplateComp = options.templateMap[ (props.data as Record<string, { type?: string }>)[key] .type as keyof typeof customTypeToTemplate ] if (TemplateComp) return <TemplateComp {...props} /> } return <WrappedComponent {...props} /> } WithUnpublishedPreview.displayName = `withUnpublishedPreview(${getDisplayName( WrappedComponent, )})` return WithUnpublishedPreview }
HOC: withPreviewResolver
withPreviewResolver
simplifies the process of creating a /preview
page that
editors land on when clicking the Preview button in Prismic.
The withPreviewResolver
HOC performs the following:
- Calls
usePrismicPreview
to detect the Prismic preview. - If the request is a preview, save the data to the global store using
usePreviewStore
. - Navigate to the previewed document’s path.
- While this is happening, provide
usePrismicPreview
’s state to the wrapped component as props to show an appropriate UI (e.g. “Loading”, “Oops, this isn’t a preview”).
Considerations
- This HOC should do the least amount of data management necessary to provide previews. This HOC should not have a UI, for example.
Example use
// src/pages/preview.js import * as React from 'react' import { withPreviewResolver } from 'gatsby-source-prismic' import { linkResolver } from '../linkResolver' import { Layout } from '../components/Layout' const PreviewPage = ({ isLoading, isPreview }) => { if (isLoading) return <Layout>Loading…</Layout> if (isPreview === false) return <Layout>Not a preview</Layout> return null } // Note the use of `withPreviewResolver` here. export default withPreviewResolver(PreviewPage, { repositoryName: process.env.GATSBY_PRISMIC_REPOSITORY_NAME, linkResolver, })
Example implementation
import * as React from 'react' import { PageProps, Node } from 'gatsby' import { usePreviewStore, mergePrismicPreviewData, LinkResolver, } from 'gatsby-source-prismic' const getDisplayName = (WrappedComponent: React.ComponentType<any>) => WrappedComponent.displayName || WrappedComponent.name || 'Component' type WithPreviewResolverArgs = { repositoryName: string linkResolver?: LinkResolver } export const withPreviewResolver = <TProps extends PageProps>( WrappedComponent: React.ComponentType<TProps>, options: WithPreviewResolverArgs, ): React.ComponentType<TProps> => { const WithPreviewResolver = (props: TProps) => { const [, dispatch] = usePreviewStore() const { isLoading, isPreview, previewData, path } = usePrismicPreview({ repositoryName: options.repositoryName, linkResolver: options.linkResolver, }) React.useEffect(() => { if (isPreview && previewData && path) { dispatch({ type: ActionType.AddPage, payload: { path, data: previewData }, }) navigate(path) } }, [isPreview, previewData, path, dispatch]) return ( <WrappedComponent {...props} isPreview={isPreview} isLoading={isLoading} /> ) } WithPreviewResolver.displayName = `withPreviewResolver(${getDisplayName( WrappedComponent, )})` return WithPreviewResolver }
Context: usePreviewStore
usePreviewStore
is a specialized useReducer
made global via React context.
It holds all preview data in an object and any related state such as toggling
the enabled state.
A dedicated /preview
page would use usePreviewStore
to save the preview data
before redirecting to the document’s page. On the document’s page, or a site’s
404 page if unpublished, usePreviewStore
would be used to access the
document’s previewed data.
Use of usePreviewStore
would be abstracted behind withPreview
and
withUnpublishedPreview
, but could still be made available for user-land
functionality. Direct access could allow site-specific features like toggling
preview data and listing which pages are previewed.
Considerations
- This requires wrapping the app with a context provider. This could be done
automatically with
gatsby-browser.js
andgatsby-ssr.js
.
Example implementation
import * as React from 'react' export enum ActionType { AddPage, EnablePreviews, DisablePreviews, } type Action = | { type: ActionType.AddPage payload: { path: string; data: object } } | { type: Exclude<ActionType, ActionType.AddPage> } interface State { pages: Record<string, object> enabled: boolean } const initialState: State = { pages: {}, enabled: false, } const reducer = (state: State, action: Action): State => { switch (action.type) { case ActionType.AddPage: { return { ...state, pages: { ...state.pages, [action.payload.path]: action.payload.data, }, enabled: true, } } case ActionType.EnablePreviews: { return { ...state, enabled: true } } case ActionType.DisablePreviews: { return { ...state, enabled: false } } } } const PreviewStoreContext = React.createContext([initialState, () => {}] as [ State, React.Dispatch<Action>, ]) export type PreviewStoreProviderProps = { children?: React.ReactNode } export const PreviewStoreProvider = ({ children, }: PreviewStoreProviderProps) => { const reducerTuple = React.useReducer(reducer, initialState) return ( <PreviewStoreContext.Provider value={reducerTuple}> {children} </PreviewStoreContext.Provider> ) } export const usePreviewStore = () => React.useContext(PreviewStoreContext)