rehype-pretty-code icon indicating copy to clipboard operation
rehype-pretty-code copied to clipboard

Error: Expected onClick listener to be a function, instead got a value of string type.

Open Vallerian opened this issue 1 year ago • 8 comments

I am encountering an error while using the rehype-pretty-code package in a Next.js project, specifically when utilizing the transformerCopyButton feature. The error message I receive is:

Error: Expected `onClick` listener to be a function, instead got a value of `string` type.

This error seems to occur when the transformerCopyButton attempts to render the copy button for code blocks. It appears that the onClick event listener is being assigned a string value instead of a function.

Next.js version: 14.2.5 rehype-pretty version: 0.13.2

next.config.mjs

import createMDX from '@next/mdx'
import remarkSlug from 'remark-slug'
import rehypePrettyCode from "rehype-pretty-code";
import remarkGfm from 'remark-gfm'
import remarkFrontmatter from 'remark-frontmatter'
import {transformerCopyButton} from "@rehype-pretty/transformers";


/** @type {import('next').NextConfig} */
const nextConfig = {
    pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
    reactStrictMode: false,
    compiler: {styledComponents: true}
}

const withMDX = createMDX({
    extension: /\.mdx?$/,
    options: {
        remarkPlugins: [
            [remarkFrontmatter],
            [remarkGfm],
            [remarkSlug],
        ],
        rehypePlugins: [
            [rehypePrettyCode, {
                theme: "github-dark-default",
                transformers: [
                    transformerCopyButton({
                        visibility: 'hover',
                        feedbackDuration: 2_000,
                    }),
                ]
            }],
        ],
    },
});


export default withMDX(nextConfig)

Vallerian avatar Aug 06 '24 06:08 Vallerian

Hi Vallerian, Getting the same error. Any luck?

haris989 avatar Aug 11 '24 10:08 haris989

Unfortunately, no, but if you find a way to extract the content of code blocks within React components using Rehype or Remark plugins, like remark-code-blocks, I'd be happy if you let me know.

Vallerian avatar Aug 11 '24 16:08 Vallerian

unfortunately the copy button transformer currently doesn't work with Next.js unless you use a Server Component

Example implementation: https://github.com/rehype-pretty/rehype-pretty-code/blob/master/examples/next/src/app/code.tsx

demo: https://rehype-pretty-example-next.pages.dev/rsc

o-az avatar Aug 27 '24 05:08 o-az

You can easily add a custom pre component with a copy button.

  • custom pre component to be used in MDX files
'use client';

import { Check, Clipboard } from 'lucide-react';
import { DetailedHTMLProps, HTMLAttributes, useRef, useState } from 'react';

export default function Pre({
  children,
  ...props
}: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) {
  const [isCopied, setIsCopied] = useState(false);
  const preRef = useRef<HTMLPreElement>(null);

  const handleClickCopy = async () => {
    const code = preRef.current?.textContent;

    if (code) {
      await navigator.clipboard.writeText(code);
      setIsCopied(true);

      setTimeout(() => {
        setIsCopied(false);
      }, 3000);
    }
  };

  return (
    <pre ref={preRef} {...props} className='relative'>
      <button
        disabled={isCopied}
        onClick={handleClickCopy}
        className='absolute right-4 size-6'
      >
        {isCopied ? <Check /> : <Clipboard />}
      </button>
      {children}
    </pre>
  );
}
  • mdx-component.tsx (if you are using @next/mdx)
import type { MDXComponents } from 'mdx/types';
import Pre from './components/mdx/Pre';

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    pre: (props) => <Pre {...props} />,
    ...components,
  };
  • Result image

2wndrhs avatar Sep 16 '24 02:09 2wndrhs

I encountered the same issue when rendering the MDX file and am still seeking a better solution.

shiny avatar Nov 15 '24 06:11 shiny

Expanding on @2wndrhs's implementation, the icon would not stick to the top right when scrolling horizontally for long lines of code. I have made the following modifications in the returned JSX to account for that.

<div className="relative">
      <button
        disabled={isCopied}
        onClick={handleClickCopy}
        className="absolute right-4 top-4 size-6 z-10"
      >
        {isCopied ? <Check className="text-green-400 " /> : <Clipboard />}
      </button>
      <pre ref={preRef} {...otherProps} className={classNames}>
        {children}
      </pre>
</div>

shehroze-1122 avatar Jan 06 '25 19:01 shehroze-1122

It is very very very much imp that this will not work if you are doing development on http instead use https the navigator.clipboard API is not available over http connections.

Solution for vite

you can mannually create certificates and use them in vite.config.ts at server.https OR you can use this

npm i vite-plugin-mkcert -D

and then modify vite.config.ts / vite.config.js accordingly:

import { defineConfig } from 'vite'
import mkcert from 'vite-plugin-mkcert'

export default defineConfig({
  plugins: [ mkcert() ] // and rest of your plugins
})

you should be ready to go by now...

😉 Heads UP I use my-macbook-name-on-localnetwork.local to do mobile development and this gets tricky because I was only able to make my personal macbook trust the certificates what about other local devices ?

for this you can you localhost tunneling.

and if you have any other work around, please do tell.

mohammadazeemwani avatar Apr 06 '25 14:04 mohammadazeemwani

I created a new plugin that overrides the existing onclick. Fixes it for me in Next.js, it broke when I started using rehype-react.

import type { Root } from "hast";
import type { Plugin } from "unified";
import { visit } from "unist-util-visit";

/**
 * Adds onClick handlers to copy buttons.
 *
 * Finds all `button` elements that either:
 * - have a `data` attribute, or
 * - have class `rehype-pretty-copy`
 *
 * It attaches an `onClick` handler that reads the `data` attribute and writes
 * it to the clipboard using `navigator.clipboard.writeText`.
 */
export interface RehypeCopyOnclickOptions {
  feedbackDuration?: number;
}

export const rehypeCopyOnclick: Plugin<[RehypeCopyOnclickOptions?], Root> = (
  options = {},
) => {
  const feedbackDuration = options.feedbackDuration ?? 3_000;

  return function (tree: Root): undefined {
    visit(tree, "element", (node: any) => {
      if (!node || node.tagName !== "button") return;

      const props = (node.properties ??= {});
      const className = props.className ?? props.class;

      const hasCopyClass = Array.isArray(className)
        ? className.includes("rehype-pretty-copy")
        : typeof className === "string"
          ? className.split(/\s+/).includes("rehype-pretty-copy")
          : false;

      const hasData = typeof props["data"] === "string";

      if (!hasCopyClass && !hasData) return;

      // Ensure `type="button"` so it doesn't submit forms accidentally.
      if (!props.type) props.type = "button";

      // Do not override existing handlers.
      if (typeof props.onClick === "function") return;

      // Attach React onClick handler (rehype-react will preserve this).
      props.onClick = (e: any) => {
        try {
          const el = e?.currentTarget as HTMLElement | null;
          if (!el) return;

          // Match the behavior of @rehype-pretty/transformers copy-button
          // Read from the `data` attribute value.
          let text = (el as any)?.attributes?.data?.value || "";

          // Fallback: if not present on the element, use the serialized props value.
          if (!text && typeof props["data"] === "string") {
            text = props["data"] as string;
          }

          if (!text) return;

          // Write to clipboard.
          void navigator.clipboard.writeText(text);

          // Add feedback class to trigger icon animation, then remove it.
          el.classList.add("rehype-pretty-copied");
          window.setTimeout(
            () => el.classList.remove("rehype-pretty-copied"),
            feedbackDuration,
          );
        } catch {
          // no-op
        }
      };

      // Remove any inline `onclick` string injected upstream for safety; React
      // expects a function on `onClick` and may otherwise pass unsafe strings.
      if (typeof (props as any).onclick === "string") {
        delete (props as any).onclick;
      }
    });
  };
};

export default rehypeCopyOnclick;

LodeKennes avatar Sep 05 '25 19:09 LodeKennes