React and NextJS provide a quick user experience by executing page navigations within the browser, also known as client-side navigation. However, at times, certain third party packages we want to use require us to inject scripts manually into the DOM of these React components. These scripts will execute well on the first navigation but will not execute on subsequent client-side navigations to this component or page, which is a major blocker for the usage of such packages.

This issue has been reported by many NextJS users on GitHub (discussion #17919, issue #4477). The proposed solutions are:

  1. Use <a> tags instead of <Link> tags to direct to this page. This fixes the issue by removing client-side navigation (less performant) and requires wide-ranging changes to the entire project, which can also be hard to verify for any future changes.
  2. Use <Head> or <Script> components provided for NextJS. In my case, this doesn't suit my need because I need the script of my package to be in the body in order for the script to make changes to the DOM relative to its own positioning in the DOM.
  3. Use useEffect to add and remove the script on mount and unmount. This solution seemed to help some folks, but doesn't in my case as it doesn't retrigger the execution of my script.

The root cause

I ran into this problem while building out an example usage of Penmark, a third party package that I'm currently developing. From my investigations, this seems to be caused by the fact that the script executes upon load (when it is injected into the DOM). When doing so, the browser loads the source code of the package into the source memory of the browser. Therefore, when we leave the page (remove the script ) and return to the page (re-inject the script), the script is not automatically executed because it has already been loaded by the browser. This is corroborated by dshuthwal in the thread.

Workaround #1

One option would be to make use of the eval() function. By doing so, we can fetch the package code, and evaluate it manually when the component mounts. This allows us to ensure that the script is executed again when the component is remounted. The code would look like this:

export default Component(){
  [...]

  const [code, setCode] = useState('');
  const penmarkRef = useRef(null)

  //fetch code from Penmark
  useEffect(() => {
    const fetchCode = async () => {
      try{
        const res = await fetch('https://penmark.appsinprogress.com/dist/DraftsClient.js');
        setCode(await res.text());
      }
      catch(err){
        console.log(err);
      }
    }
    fetchCode();
  }, []);

  //evaluate code from Penmark manually. usually, the below useEffect would suffice to manually inject the script
  //but it does not get re-executed on client side page navigation, so we manually execute the Penmark code
  useEffect(() => {
    eval(code);
  }, [code]);

  //inject Penmark script
  useEffect(() => { 
    const script = document.createElement('script');
    script.setAttribute('type', 'module');
    script.setAttribute('src', 'https://penmark.appsinprogress.com/dist/DraftsClient.js');
    
    [...]

    if (penmarkRef.current) {
      penmarkRef.current.appendChild(script);
    }
  }, [penmarkRef]);

  [...]

}

This works. However, using eval() has can pose security vulnerabilities, especially when it is used to execute code from untrusted sources. So while this workaround can work for prototyping and uses when the code is trusted, I've opted for a more permanent solution.

Workaround #2

Since I have access to the third party package causing the issue, I have opted to bind the script to a function on the window to resolve the issue. By doing this, any developer using NextJS or React will be able manually trigger the scripts' execution by calling one of  window.penmarkDraftsInit(), window.penmarkPostInit(), or window.penmarkLoginInit() functions. In my package code, I made the following changes:

Each of the scripts previously executed the code directly to initialize the package. Now, they are wrapped with a function, and this function is binded to a window function as such:

window.penmarkDraftsInit = async function penmarkDraftsInit(){
    console.log("running drafts init")

    try{
        await getAccessToken();

        var script = document.querySelector(`script[src="${__JS_PACKAGE_HOST__}/DraftsClient.js"]`);
        
        [...]
    }

[...]
}

penmarkDraftsInit();

This is replicated for each of the scripts I provide for my package.

As for the usage of these window functions, a developer could now re-trigger the script manually within a within a React/NextJS component as such:

export default Component(){
  [...]

//inject penmark script

 useEffect(() => {
    const script = document.createElement('script');
    script.setAttribute('type', 'module');
    script.setAttribute('src', 'http://localhost:9000/DraftsClient.js');
    script.setAttribute('draftsFolder', 'drafts');
    script.setAttribute('postsFolder', '_posts');
    script.setAttribute('imagesFolder', 'public/assets/blog');
    script.setAttribute('githubUsername', 'penmark-cms');
    script.setAttribute('githubRepoName', 'penmark-nextjs-example');
    script.async = true;

    if (penmarkRef.current) {
        penmarkRef.current.appendChild(script);
    }

    if(window.penmarkDraftsInit) {
        window.penmarkDraftsInit();
     }
 }, [penmarkRef]);

[...]

}

Workaround 2 is a cleaner solution, and since I have access to the underlying package, I can make the needed changes in order to provide these trigger hooks to any developer using the package. It also removes the need to use eval(), which some can deem as unsecure due to not self-hosting the package.

Conclusion

Scripts not re-executing on client-side page navigation is a common occurrence as can be seen from the GitHub discussions on the topic. These 2 workarounds complement the existing alternatives proposed in the thread, providing a solution for folks who want to keep client-side navigation and need to inject scripts at specific locations in the body.