next-sanity icon indicating copy to clipboard operation
next-sanity copied to clipboard

Feature suggestion: update-events-only subscription hook

Open lunelson opened this issue 3 years ago • 4 comments

Since calls to Next.js' getStaticProps and getServerSideProps must often do further processing on fetched data, it's potentially a false assumption that the user actually wants the data to come through the subscription: rather, it may be more useful to listen for the update event, and then re-trigger the feeding of data from getStaticProps and/or getServerSideProps. A classic example of this is when the CMS data contains raw MDX code, and this needs to be compiled.

It turns out this can be done my using the Next router, and simply calling router.replace(router.asPath), which will cause all those prop-getting functions to re-run.

Since I just ran in to this issue with datocms' implementation of the same functionality, I offer this simplified hook based on their internal datocms-listen library, which is capable of returning the data from the query but for which I just use it to get the events:

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { subscribeToQuery } from 'datocms-listen';

export function useDatoCmsUpdate({
  query,
  variables = {},
  preview = false,
  updateRef = false, // pass data from gSP in here, to track `isUpdating` state
} = {}) {
  const [error, setError] = useState(null);
  const [status, setStatus] = useState(query ? 'connecting' : 'closed');
  const [isUpdating, setIsUpdating] = useState(false);
  const router = useRouter();
  useEffect(() => {
    if (!updateRef) return;
    setIsUpdating(false);
  }, [updateRef]);
  useEffect(() => {
    if (!query) return () => false; // return early if there's no query, preview mode is inactive
    let unsubscribe;
    async function subscribe() {
      unsubscribe = await subscribeToQuery({
        query,
        variables,
        preview,
        token: process.env.DATOCMS_TOKEN,
        onStatusChange: (s) => {
          setStatus(s);
        },
        onChannelError: (e) => {
          setError(e);
        },
        onUpdate: (updateData) => {
          setError(null);
          if (status !== 'closed') {
            // hat tip: https://www.joshwcomeau.com/nextjs/refreshing-server-side-props/
            router.replace(router.asPath);
            if (updateRef) setIsUpdating(true);
          }
        },
      });
    }
    subscribe();
    return () => {
      unsubscribe && unsubscribe();
    };
  }, []);
  return { error, status, isUpdating };
}

lunelson avatar Feb 18 '21 16:02 lunelson

This is a great idea, thanks for shedding light on the router.replace(router.asPath) trick! I've asked the next.js team for clarification on the "stability" of this just to avoid implementing something that might break/change in the near future.

rexxars avatar Feb 19 '21 04:02 rexxars

@rexxars your instinct about the stability was right, and I spoke too soon: there seems to be a bug that prevents it from working in production builds right now... 🤷🏻 https://github.com/vercel/next.js/issues/11559#issuecomment-609956467

lunelson avatar Feb 19 '21 15:02 lunelson

While waiting for Next.js to fix that bug, the following works too, though less elegantly:

// top of page component
let allowReload = false;
useEffect(() => {
  setTimeout(() => (allowReload = true), 5000);
}, []);

// later on...
if (allowReload) window.location.reload(true);

lunelson avatar Feb 19 '21 17:02 lunelson

@rexxars the router.replace trick works again, in my testing, latest as of Next.js v10.2.x.

This is a part of my current solution with DatoCMS—I'm guessing it would be similarly applicable in Sanity, the question just being how to get the update event—if you've got further ideas I'd love to hear them ;)

import { useEffect, useRef, useState } from 'react';
import { subscribeToQuery } from 'datocms-listen';
import { useRouter } from 'next/router';

import { reporter } from '../utils';
/*
  references for this technique of using router.replace

    - https://www.joshwcomeau.com/nextjs/refreshing-server-side-props/
      - https://twitter.com/flybayer/status/1333081016995622914
    - https://medium.com/wearewebera/updating-the-properties-of-the-getstaticprops-method-in-next-js-79a17b7801e1

  questions

    - how can I return something useful from this, so I know if we are in a "loading" state?
    - how can I prevent an additional run when the page first mounts?
*/
export function useDatoListeners(...queries) {
  const router = useRouter();
  const unsubsRef = useRef(queries);
  const [isUpdating, setIsUpdating] = useState(false);
  // const [isLoading, setIsLoading] = useState(true);
  useEffect(() => {
    const { info, table } = reporter();
    if (router.isPreview) {
      // setTimeout(() => setIsLoading(false), 1000);
      unsubsRef.current = unsubsRef.current.map((query) =>
        subscribeToQuery({
          ...query,
          // preview: router.isPreview,
          // token: process.env.DATOCMS_TOKEN,
          onChannelError({ code, message, response }) {
            table({ code, message, response });
            window.location.reload(true); // retry the connection !
          },
          onUpdate() {
            if (isUpdating) return;
            info('datocms update received');
            /* NOTES:
                1. `shallow: false` means getStaticProps getServerProps will run
                2. `scroll: false` maintains position, avoiding a scroll back to top
            */
            router.replace(router.asPath, router.asPath, { shallow: false, scroll: false });
            setTimeout(() => setIsUpdating(false), 1000);
          },
        }),
      );
      return () => Promise.all(unsubsRef.current).then((unsubs) => unsubs.forEach((unsub) => unsub()));
    }
  }, []);
  return isUpdating;
}

lunelson avatar May 15 '21 13:05 lunelson

In v2.0.0 you're encouraged in the docs to implement patterns where you're not data fetching on the server when you're in preview mode. As you still have to fetch in @sanity/groq-store so it doesn't make sense to dual fetch 😄

stipsan avatar Nov 16 '22 16:11 stipsan