Error when switching between files in react WebViewer: Annotations from previous doc stay in the viewer

PDF.js Express Version

Development environment
Frontend: React@^17.0.2
Backend NodeJs

Detailed description of issue
Hi there,
Here is the problem I’ve been facing: I followed the official documentation and the advices that I’ve found in the forum in order to load a document as a Blob in the viewer.
I’m using react router as my router gateway.
When you land on this route /sessions/:session_id/editor/folder/:folder_id/doc/:doc_id, the WebViewer parent’s component grabs the session_id, folder_id and doc_id, make a call to our server and return the doc infos (mainly the xfdf string and the doc as a base64 string).
Then, with the received data, I pass two props:

  • doc_data: contains my file data as a Blob
  • xfdf: contains the xfdf string from whom I import annotations

In the WebViewer component, I’m using a useEffect that checks if instance is defined and if doc_data is defined then I load the doc_data into the viewer using instance.loadDocument(doc_data)
There is also another useEffect that attach the documentLoaded eventListenner to the docViewer (I use the .one() instead of .on() else the eventListenner is called multiple times on the same doc)
The last useEffect that I use is for setting the annotationChanged eventListenner, in there I simply call an endpoint on our server in order to save the xfdf string.
To be noted that the more I switch files the more onAnnotationChanged is called.

The problem is that whenever I switch between files, the annotation from the previous doc are re-rendered in the newly loaded document. To be noted that it also imports the new document annotations.
Below I’ll attach a video demonstrating my problem and I’ll add some code snippets regarding the two components that I’ve talked about.

ezgif.com-gif-maker

Expected behaviour
I expect the viewer to render only the annotations that belong to the currently loaded document

Does your issue happen with every document, or just one?
All documents

Link to document
Not possible, files are loaded as Blob into the viewer

Code snippet
WebViewer component (child component)

import React, { useRef, useEffect, useMemo, useLayoutEffect } from 'react';
import WebViewer from '@pdftron/pdfjs-express';
import Fab from '@material-ui/core/Fab';
import GetAppIcon from '@material-ui/icons/GetApp';
import { useDispatch, useSelector } from 'react-redux';
import { stopIsLoading, setIsLoading } from '../../features/loader/loadingSlice';
import { makeStyles } from '@material-ui/core/styles';
import BackEnd from '../../api';

const useStyles = makeStyles((theme) => ({
  fab: {
    position: 'fixed',
    bottom: theme.spacing(2),
    right: theme.spacing(2),
  }
}));

export default function WebViewerComponent(props) {
  const { instance, download_file, doc_data, xfdf, setInstance, session_id, user_id, setSnackBarMessage, t } = props;
  const currentDoc = useSelector((state) => state.editor.currentDoc);
  const dispatch = useDispatch();
  const memoInstance = useMemo(() => instance, [instance]);
  const viewerDiv = useRef();

  useLayoutEffect(() => {
    WebViewer({
      licenseKey: 'My_license_Key',
      path: '/pdfjsexpress',
      disableFlattenedAnnotations: true,
    }, viewerDiv.current).then(inst => {
      setInstance(inst);
    });
  }, []);

  useEffect(() => {
    if (memoInstance && doc_data) {
      memoInstance.loadDocument(doc_data);
    }
  }, [memoInstance, doc_data]);

  useEffect(() => {
    if (memoInstance && user_id && session_id && currentDoc && currentDoc.id) {
      const annotManager = memoInstance.docViewer.getAnnotationManager();
      annotManager.on('annotationChanged', async (annotations, action, { imported }) => {
        // If the event is triggered by importing then it can be ignored
        // This will happen when importing the initial annotations from the server or individual changes from other users
        if (imported) {
          return;
        } else {
          console.log('annotationChanged IN');
          annotManager.exportAnnotations().then((xfdfString) => {
            if (xfdfString !== xfdf) {
              dispatch(setIsLoading());
              console.log('called merge');
              setAnnotation(session_id, user_id, currentDoc.id, xfdfString);
            }
          });
        }
      });
    }
  }, [memoInstance, currentDoc]);

  useEffect(() => {
    if (memoInstance && xfdf) {
      const annotManager = memoInstance.docViewer.getAnnotationManager();
      memoInstance.docViewer.one('documentLoaded', async () => {
        console.log('imported annotations');
        if (xfdf !== '') {
          // Get rid of the <xml></xml>
          const xfdf_ = xfdf.split('<?xml version=\"1.0\" encoding=\"UTF-8\" ?>')[1];
          annotManager.importAnnotations(xfdf_).then(importedAnnotations => { });
        }
        dispatch(stopIsLoading());
      });
    } else {
      dispatch(stopIsLoading());
    }
  }, [memoInstance, xfdf, doc_data]);

  useEffect(() => {
    return () => {
      if (memoInstance) {
        memoInstance.annotManager.off();
      }
    }
  }, [memoInstance]);

  const setAnnotation = async (sessionId, userId, docId, xfdf) => {
    if (sessionId && userId && docId) {
      try {
        const response = await BackEnd.post(`/medias/set_annotations`, {
          payload: {
            local_user_id: userId,
            session_id: sessionId,
            xfdf_string: xfdf,
            doc_id: docId
          }
        });
        if (response.status === 200) {
          console.log('success saving');
        } else {
          setSnackBarMessage(t('Generic.oops'));
        }
        dispatch(stopIsLoading());
      } catch (e) {
        const { response } = e;
        const { data } = response;
        if (response.status === 403) {
          if (data && data.message) {
            setSnackBarMessage(t('Generic.oops', { err: data.message }));
          } else {
            setSnackBarMessage(t('Generic.oops', { err: 'Access forbidden' }));
          }
        } else if (response.status === 404) {
          setSnackBarMessage(t('Generic.oops', { err: data.message }));
        } else if (response.status === 401) {
          setSnackBarMessage(t('Generic.oops', { err: e.message }));
        } else {
          setSnackBarMessage(t('Generic.oops', { err: e.message }));
        }
        dispatch(stopIsLoading());
      }
    }
  }

  const classes = useStyles();
  return (
    <div className='component' style={{ marginTop: 12 }}>
      <div className="webviewer" ref={viewerDiv} style={{ height: "100vh" }}></div>
      <Fab aria-label={'download'} className={classes.fab} disabled={!memoInstance} color={'primary'} onClick={download_file}>
        <GetAppIcon />
      </Fab>
    </div>
  )
}

SessionEditor component (parent component)

import React, { useEffect, useState } from 'react';
import FileSaver from 'file-saver';
import ExpressUtils from '@pdftron/pdfjs-express-utils';
import GenBackDrop from './Gen/GeneralBackDrop';
import SnackbarComponent from './Gen/SnackBar';
import socketIOClient from "socket.io-client";
import { useDispatch, useSelector } from 'react-redux';
import { setIsLoading, stopIsLoading } from '../features/loader/loadingSlice';
import WebViewerComponent from './WebViewer';
import { setShowFoldersSelect, setCurrentDoc } from '../features/editor/editorSlice';
import BackEnd from '../api';
import ScrollableTabsEditorDocs from './Gen/EditorFilesTabs'

const ENDPOINT = 'our_endpoint';

const utils = new ExpressUtils();

export default function SessionEditor(props) {
  const { t, match, history } = props;
  const foldersDocs = useSelector((state) => state.editor.foldersDocs);
  const [instance, setInstance] = useState(null)
  const [loading, setLoading] = useState(false);
  const [loadingMessage, setloadingMessage] = useState(null);
  const [currentDocData, setCurrentDocData] = useState(null);
  const [currentDoc, setCurrentDoc_] = useState({});
  const [currentUser, setCurrentUser] = useState(null);
  const [sessionId, setSessionId] = useState(null);
  const [userId, setUserId] = useState(null);
  const [docId, setDocId] = useState(null);
  const [userRole, setUserRole] = useState(null);
  const [snackBarMessage, setSnackBarMessage] = useState(null);
  const [currentSocket, setCurrentSocket] = useState(null);
  const dispatch = useDispatch();

  /**
   * @main
   * Create a socket connection to our server 
   */
  useEffect(() => {
    const socket = socketIOClient(ENDPOINT, { transports: ["websocket"] });
    setCurrentSocket(socket);
  }, [])

  useEffect(() => {
    setLoading(true);
    dispatch(setIsLoading());
    const local_user_id = localStorage.getItem('local_user_id') || null;
    if (!match.params.session_id || !match.params.folder_id || !match.params.doc_id || !local_user_id) {
      history.push('/');
    } else if (match.params.session_id && match.params.folder_id && match.params.doc_id && local_user_id) {
      setSessionId(match.params.session_id);
      setUserId(local_user_id);
      setDocId(match.params.doc_id);
    }
  }, [match.params.session_id, match.params.doc_id, match.params.folder_id]);

  useEffect(() => {
    if (sessionId && userId && docId) {
      fetchDocData(sessionId, userId, docId);
    }
  }, [sessionId, userId, docId]);

  useEffect(() => {
    if (sessionId && docId && userId && currentSocket) {
      currentSocket.emit('createSessionSocket', { sessionId, docId, userId });
    }
  }, [sessionId, docId, userId, currentSocket]);

  useEffect(() => {
    if (instance && currentUser && userRole) {
      set_config(instance);
      set_current_user_and_authorization(instance, currentUser, userRole);
    }
  }, [instance, currentUser, userRole]);

  const set_config = (instance) => {
    const locale_not_sterialized = window.localStorage.i18nextLng || 'en-US';
    const sterialized_locale = locale_not_sterialized.split('-')[0];
    // Set language to the browser language. fallback to english if no language or if not currently supported
    instance.setLanguage(sterialized_locale);
    instance.enableMeasurement();
    instance.enableFeatures([instance.Feature.Search]);
  }

  const set_current_user_and_authorization = (instance, currUser, userRole) => {
    const { annotManager } = instance;
    annotManager.setCurrentUser(currUser.f_name);
    annotManager.setIsAdminUser(userRole === 'Admin' ? true : false);
    annotManager.setReadOnly(userRole === 'Viewer' ? true : false)
  }

  function base64ToBlob(base64) {
    const binaryString = window.atob(base64);
    const len = binaryString.length;
    const bytes = new Uint8Array(len);
    for (let i = 0; i < len; ++i) {
      bytes[i] = binaryString.charCodeAt(i);
    }

    return new Blob([bytes], { type: 'application/pdf' });
  };

  const download_file = async () => {
    setLoading(true);
    setloadingMessage(t('Session_Editor.wait_for_download'));
    const xfdf = await instance.annotManager.exportAnnotations();
    const fileData = await instance.docViewer.getDocument().getFileData({});

    // Set the annotations and document into the Utility SDK, then merge them together
    const resp = await utils
      .setFile(fileData)
      .setXFDF(xfdf)
      .set();

    // Get the resulting blob from the merge operation
    const mergedBlob = await resp.getBlob();
    // currentSocket.emit('edits', mergedBlob);
    const file_name = `${currentDoc.file_name.split('.')[0]}.pdf`;
    // trigger a download for the user!
    FileSaver.saveAs(mergedBlob, file_name);
    setLoading(false);
    setloadingMessage(null);
    setSnackBarMessage(t('Session_Editor.file_downloaded', { file_name: file_name }));
  }

  async function fetchDocData(session_id, local_user_id, doc_id) {
    try {
      const response = await BackEnd.get(`/medias/retrieve_media?session_id=${session_id}&local_user_id=${local_user_id}&doc_id=${doc_id}`);
      const { data } = response;
      if (response.status === 200) {
        setCurrentDocData(base64ToBlob(data.buffer));
        dispatch(setCurrentDoc(data.doc));
        setCurrentDoc_(data.doc);
        setCurrentUser(data.user);
        setUserRole(data.user_role);
        dispatch(setShowFoldersSelect());
      } else {
        setSnackBarMessage(t('Generic.oops'));
      }
    } catch (e) {
      dispatch(stopIsLoading());
      const { response } = e;
      const { data } = response;
      if (response.status === 403) {
        if (data && data.message) {
          setSnackBarMessage(t('Generic.oops', { err: data.message }));
        } else {
          setSnackBarMessage(t('Generic.oops', { err: 'Access forbidden' }));
        }
      } else if (response.status === 404) {
        setSnackBarMessage(t('Generic.oops', { err: data.message }));
      } else if (response.status === 400) {
        setSnackBarMessage(t('Generic.oops', { err: data.message }));
      } else {
        setSnackBarMessage(t('Generic.oops', { err: e.message }));
      }
    }
    setLoading(false);
  }

  const dismiss_snackar = () => {
    setSnackBarMessage(null);
  }

  return (
    <div>
      <ScrollableTabsEditorDocs session_id={sessionId} h={history} docs={foldersDocs} match={match}/>
      <WebViewerComponent setInstance={setInstance} session_id={sessionId} setSnackBarMessage={setSnackBarMessage} t={t} user_id={userId} doc_id={docId} doc={currentDoc} doc_data={currentDocData} instance={instance} download_file={download_file} xfdf={currentDoc && currentDoc.xfdf_string} />
      <GenBackDrop message={loadingMessage} isOpen={loading} />
      <SnackbarComponent error_and_message={snackBarMessage} dismiss={dismiss_snackar} />
    </div>
  )
}

Thanks for your time

Hello, I’m Ron, an automated tech support bot :robot:

While you wait for one of our customer support representatives to get back to you, please check out some of these documentation pages:

Guides:APIs:Forums:

Hi there,

I am 99% sure that the issue is coming from somewhere inside your React hooks. My guess is that you are not cleaning up an event listener when the document changes, or you are missing an item in one of your hooks dependency arrays.

If I had to guess, its coming from this code:

useEffect(() => {
    if (memoInstance && user_id && session_id && currentDoc && currentDoc.id) {
      const annotManager = memoInstance.docViewer.getAnnotationManager();
      annotManager.on('annotationChanged', async (annotations, action, { imported }) => {
        // If the event is triggered by importing then it can be ignored
        // This will happen when importing the initial annotations from the server or individual changes from other users
        if (imported) {
          return;
        } else {
          console.log('annotationChanged IN');
          annotManager.exportAnnotations().then((xfdfString) => {
            if (xfdfString !== xfdf) {
              dispatch(setIsLoading());
              console.log('called merge');
              setAnnotation(session_id, user_id, currentDoc.id, xfdfString);
            }
          });
        }
      });
    }
  }, [memoInstance, currentDoc]);

Here you are calling setAnnotation(session_id, user_id, currentDoc.id, xfdfString); but session_id and user_id are not in your dependency array.

We have heavily tested this use case so I am pretty confident this is an error coming from your code.

Please let me know if you still cannot resolve your issue.

Thanks!
Logan

Hi there Logan, thanks for coming back at me, I checked what you said and added in my useEffect array of dependencies session_id && user_id, it didn’t change a thing.

Could you please provide me some code demonstrating how to switch file regarding the fact that I load the file in the viewer using a Blob, I didn’t find any documentation on my demands.
Also could you please give me some advice/ code on where, when should I clear my events listeners and which one of them should I clear

Tank you very much,
Lenny

Hi there,

To load a blob you simply just pass the blob into loadDocument. This is clearly outlined in the documentation.

You should be cleaning up every event listener. For example, you are subscribing to annotManager.on('annotationChanged') but not cleaning up that listener inside your hook.

Try using this pattern:

useEffect(() => {
  
   const handler = () => {}

   instance.annotManager.on('annotationChanged', handler)

   return () => instance.annotManager.off('annotationChanged', handler)

}, [dep1, dep2])

Thanks!
Logan

Thanks, Logan
I used your idea of separating the event handler from the .on event and now it looks like it’s working.
For information, for other users that may come across this post, I took the const handler = () => {} outside of the useEffect for both the ‘annotationChanged’ and the ‘documentLoaded’

Thanks again Logan for your help,
Have a nice day,
Lenny

1 Like