diegomura / react-pdf

📄 Create PDF files using React
https://react-pdf.org
MIT License
14.78k stars 1.17k forks source link

Broken backwards compatibility of dynamic rendering a `View` using the `render` prop #1210

Open gabrielvincent opened 3 years ago

gabrielvincent commented 3 years ago

Describe the bug

  1. A View whose content was rendered using the render prop function used to be rendered correctly on react-pdf v1 even if one of the sub-Views returned as its children was a Text that uses the render prop to render its content as well.
  2. On react-pdf v1 the return of the render prop function of a View could be a React.Fragment with as many Views as wanted. Now an error (TypeError: element.type is not a function) is thrown if the return of the render function is not a single View. This can be seen happening here on the REPL.

In this issue I'll use the Footer component of my project for code examples. This is how this footer looks like. Note that the layout of the footer changes depending on the page number: it's aligned to the right on even pages and to the left on even pages. That's why I need to use the render prop function of a View and return in it a Text that is also rendered using its own render prop function. The pageNumber in the View is used to determine the layout of the footer.

image

The reason why I need to also render the content of Text using the render prop function is because only Text can give me totalPages, which I use as a flag to differentiate between the first render from the last one, when all the pages are already at their final position. I can then properly instruct my PageTracker class about keeping a count of page numbers and at what page each chapter starts. This is crucial in order to enable a Table of Contents.

Here's the Footer component's code which works on react-pdf v1.6.14:

import React, { Fragment } from 'react'
import { Text, View } from '@react-pdf/renderer'
import { isEmpty, isNil } from 'lodash'

import { useReportContext } from 'core/hooks/useReportContext'
import { getFullName } from 'core/utils/BirthInfo'

import type ReactPDF from '@react-pdf/renderer'
import type { FC, ReactElement } from 'react'

import {
  absolute,
  bottomPt,
  exoLight,
  exoSemiBold,
  f,
  flex,
  flexColumn,
  flexRow,
  flexRowReverse,
  hPt,
  itemsEnd,
  itemsStart,
  justifyBetween,
  mt,
  pb,
  phPercent,
  w100,
} from '../../style'

interface FooterProps {
  title: string
  chapterName?: string
  forcePageNumber?: number
}

export const Footer: FC<FooterProps> = ({
  title,
  chapterName,
  forcePageNumber,
}) => {
  const { birthInfo, pageTracker, theme } = useReportContext()
  const name = getFullName(birthInfo)
  const textColor: ReactPDF.Style = {
    color: theme.footer.textColor,
  }
  const dividerColor: ReactPDF.Style = {
    backgroundColor: theme.footer.dividerColor,
  }

  return (
    <View
      fixed
      style={[w100, phPercent(10), absolute, bottomPt(0), pb(5)]}
      render={({ pageNumber }): ReactElement => {
        pageNumber = forcePageNumber ?? pageNumber

        const rowDirection = (pageNumber + pageTracker.totalPages).isEven()
          ? flexRow
          : flexRowReverse
        const alignment = (pageNumber + pageTracker.totalPages).isEven()
          ? itemsStart
          : itemsEnd

        return (
          <Fragment>
            <View style={[w100, hPt(3), dividerColor]} />
            <View
              style={[
                flex,
                rowDirection,
                justifyBetween,
                itemsStart,
                mt(2),
                textColor,
              ]}
            >
              <View style={[flex, flexColumn, alignment, f(6)]}>
                <Text style={[exoSemiBold]}>{name}</Text>
                <Text style={[exoLight]}>{title}</Text>
              </View>
              <Text
                style={[f(7), exoSemiBold]}
                render={({ pageNumber, totalPages }): Optional<string> => {
                  if (isNil(totalPages)) {
                    return
                  }

                  pageNumber = forcePageNumber ?? pageTracker.totalPages
                  pageTracker.registerPage()
                  pageTracker.registerChapterPage(chapterName)

                  if (!isEmpty(chapterName)) {
                    {/* Registers beginning of chapter. This enables the Table of Contents */}
                    pageTracker.registerChapterStartPage(
                      pageNumber,
                      chapterName
                    )
                  }

                  return pageNumber.asDoubleDigitsString()
                }}
              />
            </View>
          </Fragment>
        )
      }}
    />
  )
}

On react-pdf v2.0.4 this code produces the TypeError: element.type is not a function.

I can stop that error from happening if I replace the Fragment with react-pdf's View primitive:

export const Footer: FC<FooterProps> = ({
  title,
  chapterName,
  forcePageNumber,
}) => {
  const { birthInfo, pageTracker, theme } = useReportContext()
  const name = getFullName(birthInfo)
  const textColor: ReactPDF.Style = {
    color: theme.footer.textColor,
  }
  const dividerColor: ReactPDF.Style = {
    backgroundColor: theme.footer.dividerColor,
  }

  return (
    <View
      fixed
      style={[w100, phPercent(10), absolute, bottomPt(0), pb(5)]}
      render={({ pageNumber }): ReactElement => {
        pageNumber = forcePageNumber ?? pageNumber

        const rowDirection = (pageNumber + pageTracker.totalPages).isEven()
          ? flexRow
          : flexRowReverse
        const alignment = (pageNumber + pageTracker.totalPages).isEven()
          ? itemsStart
          : itemsEnd

        return (
          // <Fragment> Replaced this with the View below
          <View>
            <View style={[w100, hPt(3), dividerColor]} />
            <View
              style={[
                flex,
                rowDirection,
                justifyBetween,
                itemsStart,
                mt(2),
                textColor,
              ]}
            >
              <View style={[flex, flexColumn, alignment, f(6)]}>
                <Text style={[exoSemiBold]}>{name}</Text>
                <Text style={[exoLight]}>{title}</Text>
              </View>
              <Text
                style={[f(7), exoSemiBold]}
                render={({ pageNumber, totalPages }): Optional<string> => {
                  if (isNil(totalPages)) {
                    return
                  }

                  pageNumber = forcePageNumber ?? pageTracker.totalPages
                  pageTracker.registerPage()
                  pageTracker.registerChapterPage(chapterName)

                  if (!isEmpty(chapterName)) {
                    {
                      /* Registers beginning of chapter. This enables the Table of Contents */
                    }
                    pageTracker.registerChapterStartPage(
                      pageNumber,
                      chapterName
                    )
                  }

                  return pageNumber.asDoubleDigitsString()
                }}
              />
            </View>
          </View>
          // <Fragment> Replaced this with the View above
        )
      }}
    />
  )
}

However, this is the result I get from the code above:

image

A much simpler example of this can be seen here on the REPL. After some tests, it became evident that even a simple dynamic render is not working. In this example on the REPL I could not render a simple View as the return of the render prop function. I expected to see a Text with a green background inside a blue View inside a red View. Only the red, outermost View is rendered.

I couldn't find a way to run the REPL using other versions of react-pd, which could be useful to test these scenarios.

Environment:

diegomura commented 3 years ago

Thanks @gabrielvincent ! and sorry :( I'll try to see this asap

skdavies commented 3 years ago

I'm experiencing a similar issue with dynamic rendering in the newest version (2.0.8). Rendering dynamic content of a Text component causes the PDF to be re-rendered over and over again nonstop. This did not occur in v1.x

anthares-dev commented 3 years ago

Same issue by my side. In order to stop the infinite re-rendering, needed to unmount the footer component which manage the dynamic page number. This happened upgrading to v 2.x.

Here is the component who is causing the issue:

import React from "react";
import {Text, StyleSheet } from "@react-pdf/renderer";

const styles = StyleSheet.create({
  pageNumber: {
    position: "absolute",
    fontSize: 12,
    bottom: 30,
    left: 0,
    right: 0,
    textAlign: "center",
    color: "grey",
    fontFamily: "Nunito",
  },
});

export default () => (
  <Text
    style={styles.pageNumber}
    render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`}
    fixed
  />
);
ewalti commented 3 years ago

I'm also seeing this issue with dynamic rendering in a WebvView (via BlobProvider). I get into an infinite loop with:

<Document>
    <Page size="A4" orientation="landscape">
        <Text
            render={({ pageNumber, totalPages }) => {
            console.log('render', pageNumber, totalPages);
            return `${pageNumber} / ${totalPages}`;
            }}
            fixed
        />
    </Page>
</Document>

Screen Shot 2021-06-01 at 5 57 43 PM

I'll see if I can dig through the code a bit and find a cause

DevTGhosh commented 3 years ago

@diegomura any update on this issue this is breaking all our pdfs right now.

hoppula commented 3 years ago

I noticed that you can wrap render function with useCallback as a workaround to prevent infinite re-renders, e.g.

const pageNumbers = useCallback(({ pageNumber, totalPages }) => {
  return `${pageNumber} / ${totalPages}`;
}, []);

<Text render={pageNumbers} />
terrytjw commented 2 years ago

This is a brilliant solution. Do you mind explaining a little more about how you came to this solution and more elaborate more on how this actually worked?

joznox commented 1 year ago

Either I'm missing something or this was never fixed, but I cannot get any dynamic content to render using using the render prop or calling jsx expressions within Text or View components. I keep getting the same TypeError: element.type is not a function as mentioned in the OP. Was there any resolution on this?