portabletext/react-portabletext

Certain custom components cause a 500 internal server error

Closed this issue · 1 comments

I have ran into custom components inside PortableText causing a 500 internal server error twice now. The first time was when I was caching tweets with @upstash/redis and I thought it was a setup issue. However, this second time, I was using the next-sanity/image component which has a custom loader to use next/image but utilize the Sanity CDN for image optimizations.

For simplicity, I will just focus on the next-sanity/image issue.

Here is my custom portable text:

import Link from 'next/link'
import dynamic from 'next/dynamic'
import {
  PortableText,
  PortableTextComponents,
  PortableTextMarkComponentProps,
} from '@portabletext/react'
import { PortableTextBlock } from 'sanity'
import { CameraIcon } from 'lucide-react'

import { Image as SanityImage } from '@/components/image'
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table'

const Tweet = dynamic(() => import('react-tweet').then((module) => module.Tweet), {
  ssr: false,
})

const InternalLink = ({ children, value }: PortableTextMarkComponentProps) => {
  let linkHref = `/${value?.reference.slug}`

  if (value?.reference._type === 'division') {
    linkHref = `/news/${value?.reference.slug}`
  }

  if (value?.reference._type === 'conference') {
    linkHref = `/news/${value?.reference.divisionSlug}/${value?.reference.slug}`
  }

  return (
    <Link href={linkHref} className="underline hover:text-muted-foreground">
      {children}
    </Link>
  )
}

const ImageEmbed = ({ value }: { value: any }) => {
  return (
    <figure className="my-2 flex flex-col items-center self-center rounded-lg shadow-md">
      <SanityImage
        src={value}
        width={720}
        height={379}
        priority={false}
        className="rounded-lg"
        alt={value.caption}
      />
      {value.attribution && (
        <figcaption className="flex items-center gap-2 text-sm text-muted-foreground">
          <CameraIcon className="h-4 w-4" />
          <span>Source: {value.attribution}</span>
        </figcaption>
      )}
    </figure>
  )
}

export function CustomPortableText({
  paragraphClasses,
  value,
}: {
  paragraphClasses?: string
  value: PortableTextBlock[]
}) {
  const components: PortableTextComponents = {
    block: {
      normal: ({ children }) => {
        return <p className={paragraphClasses}>{children}</p>
      },
    },
    marks: {
      internalLink: InternalLink,
      link: ({ value, children }: PortableTextMarkComponentProps) => {
        return value?.blank ? (
          <a
            className="underline hover:text-muted-foreground"
            href={value?.href}
            rel="noreferrer noopener"
            target="_blank"
            aria-label={`Opens ${value?.href} in a new tab`}
            title={`Opens ${value?.href} in a new tab`}
          >
            {children}
          </a>
        ) : (
          <a className="underline hover:text-muted-foreground" href={value?.href}>
            {children}
          </a>
        )
      },
    },
    types: {
      twitter: ({ value }) => {
        return (
          <div className="not-prose flex items-center justify-center">
            <Tweet id={value.id} />
          </div>
        )
      },
      image: ImageEmbed,
      top25Table: ({ value }) => {
        return (
          <div className="not-prose">
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>Voter</TableHead>
                  {Array.from(Array(25).keys()).map((num) => (
                    <TableHead key={num} className="w-8">
                      {num + 1}
                    </TableHead>
                  ))}
                </TableRow>
              </TableHeader>
              <TableBody>
                {value.votes.map((vote: any) => {
                  return (
                    <TableRow key={vote._key}>
                      <TableCell className="whitespace-nowrap">
                        <div className="flex items-center">
                          <div>
                            <div className="font-medium">{vote.voterName}</div>
                            <div className="mt-1 text-sm italic text-muted-foreground">
                              {vote.voterAffiliation}
                            </div>
                          </div>
                        </div>
                      </TableCell>
                      {vote.teams &&
                        vote.teams.map((team: any) => (
                          <TableCell key={team._id}>
                            <div className="w-8">
                              <SanityImage
                                className="w-full"
                                src={team.image}
                                width={32}
                                height={32}
                                alt={team.name}
                                priority={false}
                              />
                            </div>
                          </TableCell>
                        ))}
                    </TableRow>
                  )
                })}
              </TableBody>
            </Table>
          </div>
        )
      },
    },
  }

  return <PortableText components={components} value={value} />
}

and here is what the import { Image as SanityImage } from '@/components/image' looks like:

import createImageUrlBuilder from '@sanity/image-url'
import { Image as SanityImage, type ImageProps } from 'next-sanity/image'

import { projectId, dataset } from '@/lib/sanity.api'

const imageBuilder = createImageUrlBuilder({
  projectId,
  dataset,
})

export const urlForImage = (source: Parameters<(typeof imageBuilder)['image']>[0]) =>
  imageBuilder.image(source)

export function Image(
  props: Omit<ImageProps, 'src' | 'alt'> & {
    src: {
      _key?: string | null
      _type?: 'image' | string
      asset: {
        _type: 'reference'
        _ref: string
      }
      crop: {
        top: number
        bottom: number
        left: number
        right: number
      } | null
      hotspot: {
        x: number
        y: number
        height: number
        width: number
      } | null
      caption?: string | undefined
    }
    alt?: string
  },
) {
  const { src, ...rest } = props
  const imageBuilder = urlForImage(props.src)
  if (props.width) {
    imageBuilder.width(typeof props.width === 'string' ? parseInt(props.width, 10) : props.width)
  }
  if (props.height) {
    imageBuilder.height(
      typeof props.height === 'string' ? parseInt(props.height, 10) : props.height,
    )
  }

  return (
    <SanityImage
      alt={typeof src.caption === 'string' ? src.caption : ''}
      {...rest}
      src={imageBuilder.url()}
    />
  )
}

Everything works and loads properly locally (whether there are images in the block content or not). However, when I open a PR and go to view an article on a preview environment (whether there are images in the block content or not), I get a 500 internal error.

Here is what an article looks like locally:

Screenshot 2024-08-11 at 3 52 46 PM

and here is what it looks like when going to that same article on my preview environment

Screenshot 2024-08-11 at 3 53 36 PM

Looking at the Vercel logs, this is all that shows up:

Screenshot 2024-08-11 at 4 05 52 PM

This fortunately ended up not being an issue with the Portable Text. Apparently returning [] in my generateStaticParams for preview env caused this.