expressive-code / expressive-code

A text marking & annotation engine for presenting source code on the web.

Home Page:https://expressive-code.com/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Next.js: Copy button works on inital page load, but it doesn't work after the route changes

ekmas opened this issue · comments

In this video, you can see that after I changed a couple of routes and tried to copy the code snippet, it didn't work. After I refreshed the page it worked normally.

demo

next.config.mjs:

import fs from 'fs'
import createMDX from '@next/mdx'
import rehypeExpressiveCode, {
  ExpressiveCodeTheme,
} from 'rehype-expressive-code'
import codeImport from 'remark-code-import'

const jsoncString = fs.readFileSync(
  new URL(`./src/data/theme.jsonc`, import.meta.url),
  'utf-8',
)
const myTheme = ExpressiveCodeTheme.fromJSONString(jsoncString)

/** @type {import('rehype-expressive-code').RehypeExpressiveCodeOptions} */
const rehypeExpressiveCodeOptions = {
  themes: [myTheme],
  styleOverrides: {
    borderWidth: '2px',
    borderColor: '#000',
    borderRadius: '0px',
    scrollbarThumbColor: '#000',
    scrollbarThumbHoverColor: '#000',
    frames: {
      frameBoxShadowCssValue:
        'var(--horizontal-box-shadow) var(--vertical-box-shadow) 0 #000',
      tooltipSuccessBackground: '#fff',
      tooltipSuccessForeground: '#000',
      inlineButtonBorder: '#000',
      inlineButtonBackground: '#fff',
      inlineButtonBorderOpacity: '1',
      inlineButtonBackgroundIdleOpacity: '1',
      inlineButtonBackgroundActiveOpacity: '1',
      inlineButtonBackgroundHoverOrFocusOpacity: '1',
    },
    codeFontWeight: '700',
    uiFontFamily: 'inherit',
    uiFontWeight: '700',
    gutterBorderColor: '#000',
    focusBorder: '#000',
  },
}

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

const withMDX = createMDX({
  extension: /\.mdx?$/,
  options: {
    remarkPlugins: [[codeImport]],
    rehypePlugins: [[rehypeExpressiveCode, rehypeExpressiveCodeOptions]],
  },
})

export default withMDX(nextConfig)

package.json:

{
  "name": "neobrutalism-components",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@hookform/resolvers": "^3.3.4",
    "@mdx-js/loader": "^3.0.1",
    "@mdx-js/react": "^3.0.1",
    "@next/mdx": "^14.1.4",
    "@radix-ui/react-accordion": "^1.1.2",
    "@radix-ui/react-alert-dialog": "^1.0.5",
    "@radix-ui/react-avatar": "^1.0.4",
    "@radix-ui/react-checkbox": "^1.0.4",
    "@radix-ui/react-collapsible": "^1.0.3",
    "@radix-ui/react-context-menu": "^2.1.5",
    "@radix-ui/react-dialog": "^1.0.5",
    "@radix-ui/react-dropdown-menu": "^2.0.6",
    "@radix-ui/react-hover-card": "^1.0.7",
    "@radix-ui/react-label": "^2.0.2",
    "@radix-ui/react-menubar": "^1.0.4",
    "@radix-ui/react-navigation-menu": "^1.1.4",
    "@radix-ui/react-popover": "^1.0.7",
    "@radix-ui/react-progress": "^1.0.3",
    "@radix-ui/react-radio-group": "^1.1.3",
    "@radix-ui/react-scroll-area": "^1.0.5",
    "@radix-ui/react-select": "^2.0.0",
    "@radix-ui/react-slider": "^1.1.2",
    "@radix-ui/react-slot": "^1.0.2",
    "@radix-ui/react-switch": "^1.0.3",
    "@radix-ui/react-tabs": "^1.0.4",
    "@radix-ui/react-toast": "^1.1.5",
    "@radix-ui/react-tooltip": "^1.0.7",
    "@tanstack/react-table": "^8.15.3",
    "@types/mdx": "^2.0.12",
    "@types/node": "20.4.5",
    "@types/react": "18.2.17",
    "@types/react-dom": "18.2.7",
    "autoprefixer": "10.4.14",
    "class-variance-authority": "^0.7.0",
    "clsx": "^2.1.0",
    "cmdk": "^1.0.0",
    "date-fns": "^3.6.0",
    "embla-carousel-react": "^8.0.0",
    "eslint": "8.45.0",
    "eslint-config-next": "13.4.12",
    "fs": "^0.0.1-security",
    "highlight.js": "^11.9.0",
    "input-otp": "^1.2.4",
    "lucide-react": "^0.364.0",
    "next": "^13.5.4",
    "path": "^0.12.7",
    "postcss": "8.4.27",
    "react": "18.2.0",
    "react-day-picker": "^8.10.0",
    "react-dom": "18.2.0",
    "react-fast-marquee": "^1.6.3",
    "react-hook-form": "^7.51.2",
    "react-icons": "^5.0.1",
    "react-resizable-panels": "^2.0.16",
    "rehype-expressive-code": "^0.35.2",
    "tailwind-merge": "^2.2.2",
    "tailwindcss": "3.3.3",
    "tailwindcss-animate": "^1.0.7",
    "typescript": "5.1.6",
    "vaul": "^0.9.0",
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "@ianvs/prettier-plugin-sort-imports": "^4.2.1",
    "@tailwindcss/typography": "^0.5.11",
    "eslint-config-prettier": "^8.8.0",
    "prettier": "^3.0.0",
    "prettier-plugin-tailwindcss": "^0.4.1",
    "remark-code-import": "^1.2.0"
  }
}

As you can see in next.config I use a remark plugin for code importing, I tried removing it, and it didn't solve the problem.

Thank you for the report!

Do you have a branch that I could look at? I found your repo, but didn't see EC in there, neither in branches nor in the most recent PRs.

I assume the issue is caused by the SPA navigation between pages not triggering the button initialization code.

Sorry, here is code: https://github.com/ekmas/neobrutalism-components/tree/improve-docs.

I've sent you preview link on discord

I assume the issue is caused by the SPA navigation between pages not triggering the button initialization code.

yes, that's the issue, but I'm not sure how to solve it

I forgot to tell you, code blocks on /styling and /react/installation routes are not expressive-code code blocks but custom made ones

Ok, so I debugged this and it unfortunately seems to be yet another strange limitation imposed by Next.js. Countless other people appear to have the same issue since years, with only workarounds being available: vercel/next.js#17919

I already had to implement what feels like a full-blown hack using dangerouslySetInnerHTML to stop Next.js from messing up the inline script modules emitted by the rehype plugin, and now while researching your issue, I have to discover that even that won't work because it breaks when using the Next.js Router. This is not fun.

Unless someone knows a reliable way how rehype plugins can properly emit script modules alongside their rendered HTML output that work with Next.js, I'm now facing the decision whether I have to drop Next.js support entirely. :(

Just for some added context: The Next.js issue only occurs when the very first page you visit does NOT include an Expressive Code block, and you use the Next.js Router to navigate to other pages which do include Expressive Code blocks.

The Next.js Router does not seem to support actually loading any script modules which are included in the new page content. When you look at the inspector view, the pages look right with the scripts in place, but they don't get executed by the browser at all.

When the first page you visit (or do a full page reload on) does include an Expressive Code block, the Next.js Router doesn't interfere with the scripts, as they are already contained in the server-side rendered HTML, so the browser will properly execute them. Then you can do router-based SPA nagivations as much as you like, and the script will still be loaded and all copy buttons will work.

Thank you for explaining all of this.

I guess this is the only solution:

  1. Import any markdown file into root layout
import ECInit from '@/markdown/expressive-code-init.mdx'
// just put any content inside that markdown
  1. Add it to your layout, but put it inside a div with display: none styling. I'm using tailwind so it looks like this:
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={dmSans.className}>
        {children}
        <div className="hidden">
          <ECInit />
        </div>
      </body>
    </html>
  )
}

Yes, that would work as long as the imported file contains an EC code block. Good idea!

I wonder if I can find a way to allow manually importing the script modules globally somehow. That should have the same effect without feeling so "hacky". :)

I'll let you know if I find a better solution! Good to hear that you're at least unblocked for now.

just discovered this issue on my nextjs project. It can be bypassed via Script component

'use client'
import Script from 'next/script'

export const CodeCopyScript = () => (
    <Script id="code-copy">
      {`try ...
        `}
    </Script>
}

ref: https://nextjs.org/docs/app/building-your-application/optimizing/scripts

the script could be found from the inserted DOM node

I'm kind of in favor of a global script as it could reduce duplicate contents in rehype outputs, and maybe also avoid the same script being run multiple times. but since the classname could be customized, the script content itself could not be static for now, unless we use a static property

another chance for MDX users is to replace script tag with Script component imported from next/script, but I looked at the generated mdx content and discovered that the injected code pins to "script" tag and could not be replaced