question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

CSS module styling is removed too early on route changes

See original GitHub issue

Bug report

Describe the bug

CSS module styling is removed immediately after clicking a next/link, instead of after the DOM is removed on production builds. This causes the components to have no styling at all during a page transition. This issue does not happen on dev mode.

I believe this is a bug with CSS modules specifically because components styled with styled-jsx don’t have this problem.

Really would love to be able to use Sass via CSS modules here instead of re-writing the entire app I’m working on using styled-jsx. If Sass modules can’t work in this scenario, I think I would be forced to use styled-jsx, which is not my preferred method of styling my components for this project.

To Reproduce

I have created repos, and deployed these repos to demonstrate the problem using framer-motion for page transitions. If you were to pull these repos and run them locally using npm run dev, you will see that the flash of unstyled content does not happen on any one of them in dev mode. However, on their deployed sites, you can see the flash of unstyled content with CSS modules and Sass modules.

styled-jsx

Behavior: correct, no flash of unstyled content Deployed site on Vercel Repo

CSS modules

Behavior: buggy, there is a flash of unstyled content immediately after clicking the link Deployed site on Vercel Repo

Sass via CSS modules (additional)

Behavior: buggy, there is a flash of unstyled content immediately after clicking the link (same as CSS modules) Deployed site on Vercel Repo

Expected behavior

Styling for components that come from CSS modules should not be removed immediately on route changes, and instead, are removed when the markup is removed (the component unmounts?). The expected behavior is the behavior we can see on the styled-jsx deployment above.

System information

  • OS: macOS
  • Browser (if applies): N/A
  • Version of Next.js: 9.5.3
  • Version of Node.js: 12.14.1

Issue Analytics

  • State:open
  • Created 3 years ago
  • Reactions:221
  • Comments:92 (7 by maintainers)

github_iconTop GitHub Comments

35reactions
stefanmariccommented, Dec 25, 2020

I’ve encounter this issue as well and tried @scriptify 's suggested woraround but—as other have mentioned—it didn’t seem to really help with the first navigation.

After looking deeper, I realized this is a hard problem to solve for the framework. I don’t really know how pre-v10 versions of Next.js worked in this regard since the project I’m working on started with the v10, but I can assume any page transition solution relied on a bug or an unoptimized behavior.

When the page changes you want to remove styles (and maybe other kind of resources) from the DOM to 1) prevent style clashes (in the case of global CSS) and to 2) prevent memory leaks as the CSSOM would otherwise increase on every page navigation.

Next.js handles this well.

The problem with page transitions arises from the fact that any solution (being it with Framer motion, ReactTransitionGroup, ReactFlipToolkit, etc) relies on holding to the previous render’s element tree (its children) until the animation has finished.

Next.js cannot know when such animation finishes, instead, it has to unlink styles from the DOM as soon as the current (old) page Component is replaced by the next (new) page Component.

I don’t think here’s any “fix” Next.js can apply for this. The only solution I can think of is for Next.js to provide a new set of APIs to hook to and manipulate its resource management system on the client-side. Think of providing callbacks on the _app interface or making the Route events an async middleware that allows one to delay the next step in the route change process.

tl;dr: a workaround

I’ve come up with a workaround that 1) seems to be working even for the first navigation and 2) cleans up the DOM after the page transition has finished.

There’s 2 variations depending on the animation system you use. Note you would use one or the other depending on your case, not both.

Spring-based

The first one is for spring-based animations which provide a completion callback. Place this hook anywhere you prefer:

// utils/useTransitionFix.ts

import Router from 'next/router'
import { useCallback, useEffect, useRef } from 'react'

type Cleanup = () => void

export const useTransitionFix = (): Cleanup => {
  const cleanupRef = useRef<Cleanup>(() => {})

  useEffect(() => {
    const changeListener = () => {
      // Create a clone of every <style> and <link> that currently affects the page. It doesn't
      // matter if Next.js is going to remove them or not since we are going to remove the copies
      // ourselves later on when the transition finishes.
      const nodes = document.querySelectorAll('link[rel=stylesheet], style:not([media=x])')
      const copies = [...nodes].map((el) => el.cloneNode(true) as HTMLElement)

      for (let copy of copies) {
        // Remove Next.js' data attributes so the copies are not removed from the DOM in the route
        // change process.
        copy.removeAttribute('data-n-p')
        copy.removeAttribute('data-n-href')

        // Add duplicated nodes to the DOM.
        document.head.appendChild(copy)
      }

      cleanupRef.current = () => {
        for (let copy of copies) {
          // Remove previous page's styles after the transition has finalized.
          document.head.removeChild(copy)
        }
      }
    }

    Router.events.on('beforeHistoryChange', changeListener)

    return () => {
      Router.events.off('beforeHistoryChange', changeListener)
      cleanupRef.current()
    }
  }, [])

  // Return an fixed reference function that calls the internal cleanup reference.
  return useCallback(() => {
    cleanupRef.current()
  }, [])
}

Then you can use it in your app, e.g.:

// pages/_app.ts

import { AnimatePresence, motion } from 'framer-motion'
import type { AppProps } from 'next/app'

import { useTransitionFix } from '../utils/useTransitionFix'

const PAGE_VARIANTS = {
  initial: {
    opacity: 0,
  },
  animate: {
    opacity: 1,
  },
  exit: {
    opacity: 0,
  },
}

function App({ Component, pageProps, router }: AppProps): React.ReactElement {
  const transitionCallback = useTransitionFix()

  return (
      <AnimatePresence exitBeforeEnter onExitComplete={transitionCallback}>
        <motion.div
          key={router.route}
          initial="initial"
          animate="animate"
          exit="exit"
          variants={PAGE_VARIANTS}
        >
          <Component {...pageProps} />
        </motion.div>
      </AnimatePresence>
  )
}

export default App

Timeout-based

The second one is for solutions that use fixed-duration transitions:

// utils/fixTimeoutTransition.ts

import Router from 'next/router'

export const fixTimeoutTransition = (timeout: number): void => {
  Router.events.on('beforeHistoryChange', () => {
    // Create a clone of every <style> and <link> that currently affects the page. It doesn't matter
    // if Next.js is going to remove them or not since we are going to remove the copies ourselves
    // later on when the transition finishes.
    const nodes = document.querySelectorAll('link[rel=stylesheet], style:not([media=x])')
    const copies = [...nodes].map((el) => el.cloneNode(true) as HTMLElement)

    for (let copy of copies) {
      // Remove Next.js' data attributes so the copies are not removed from the DOM in the route
      // change process.
      copy.removeAttribute('data-n-p')
      copy.removeAttribute('data-n-href')

      // Add duplicated nodes to the DOM.
      document.head.appendChild(copy)
    }

    const handler = () => {
      // Emulate a `.once` method using `.on` and `.off`
      Router.events.off('routeChangeComplete', handler)

      window.setTimeout(() => {
        for (let copy of copies) {
          // Remove previous page's styles after the transition has finalized.
          document.head.removeChild(copy)
        }
      }, timeout)
    }

    Router.events.on('routeChangeComplete', handler)
  })
}

Which you would use outside of of your app component:

// pages/_app.ts
import { CSSTransition, TransitionGroup } from "react-transition-group"
import type { AppProps } from 'next/app'

import { fixTimeoutTransition } from '../utils/useTransitionFix'

import '../styles/globals.css'

const TRANSITION_DURATION = 500

fixTimeoutTransition(TRANSITION_DURATION)

function App({ Component, pageProps, router }: AppProps): React.ReactElement {
  return (
    <TransitionGroup>
      <CSSTransition
        classNames="app-transition-wrapper"
        enter
        exit
        key={router.asPath}
        timeout={TRANSITION_DURATION}
        unmountOnExit
      >
        <Component {...pageProps} />
      </CSSTransition>
    </TransitionGroup>
  )
}

export default App

So far I haven’t found issues with these. Hope it helps you all.

31reactions
ccambournaccommented, Oct 12, 2020

Hi, experiencing the very same issue as reported.

Read more comments on GitHub >

github_iconTop Results From Across the Web

CSS module being removed on path change before Framer ...
As a workaround (fortunately, i have a small project), I removed all files [name].module.css and moved all styles to global stylesheet.
Read more >
Solution: CSS Styles are Removed Too Early on Page ...
First, this issue only seems to pop up when we are importing a second CSS module into a child component, separate from the...
Read more >
Advanced Features: Next.js Compiler
Learn about the Next.js Compiler, written in Rust, which transforms and minifies your Next.js application.
Read more >
Styling - Remix
This seems like a reasonable request of a CSS framework--to generate a CSS file. Remix isn't against the frameworks that can't do this,...
Read more >
What are CSS Modules and why do we need them?
import styles from "./styles.css"; element.innerHTML = `<h1 class="${styles.
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Hashnode Post

No results found