next.js: [NEXT-1198] Next.js 13.0.1+ breaks radio buttons in both app directory and pages

Verify canary release

  • I verified that the issue exists in the latest Next.js canary release

Provide environment information

Operating System:
      Platform: linux
      Arch: arm64
      Version: #78-Ubuntu SMP Tue Apr 18 09:00:08 UTC 2023
    Binaries:
      Node: 19.8.1
      npm: 9.5.1
      Yarn: 1.22.19
      pnpm: N/A
    Relevant packages:
      next: 13.3.1
      eslint-config-next: N/A
      react: 18.2.0
      react-dom: 18.2.0

Which area(s) of Next.js are affected? (leave empty if unsure)

App directory (appDir: true)

Link to the code that reproduces this issue

https://github.com/christianjuth/next-pages-radio-bug

To Reproduce

Note: this bug appears in 13.0.1+ including 13.4.2-canary.3

This bug appears in both a pages/ and app/. It only appears in the pages/ route when app dir is enabled. In other words, enabling app dir seems to have an effect on the pages dir.

Recreate the bug with pages/

  1. Create a new Next.js app using npx create-next-app@latest (opt out of TS, eslint, and Tailwind for simplicity)
  2. Make sure the app directory is enabled (next.config.js must use experimental.appDir = true prior to Next.js 13.4.0).
  3. Modify either pages/pages.jsx with the following:
    import { useState } from 'react';
    
    export default function Test() {
      const [selectedTopping, setSelectedTopping] = useState('Medium');
    
      return (
        <div className="flex flex-col items-center">
          <input 
            type="radio" 
            name="topping" 
            value="Regular" 
            id="regular" 
            checked={selectedTopping === 'Regular'}
            onChange={e => setSelectedTopping(e.target.value)}
          />
          <label htmlFor="regular">Regular</label>
    
          <input 
            type="radio" 
            name="topping" 
            value="Medium" 
            id="medium" 
            checked={selectedTopping === 'Medium'}
            onChange={e => setSelectedTopping(e.target.value)}
          />
          <label htmlFor="medium">Medium</label>
    
          <input 
            type="radio" 
            name="topping" 
            value="Large" 
            id="large"
            checked={selectedTopping === 'Large'}
            onChange={e => setSelectedTopping(e.target.value)}
          />
          <label htmlFor="large">Large</label>
        </div>
      )
    }
    
  4. Run your app in dev mode using yarn dev. If you visit http://localhost:3000/pages you will notice the controlled radio button is initially selected but then flickers uncollected

Recreate the bug with app/

  1. Create a new Next.js app using npx create-next-app@latest (opt out of TS, eslint, and Tailwind for simplicity)
  2. Make sure the app directory is enabled (next.config.js must use experimental.appDir = true prior to Next.js 13.4.0).
  3. Modify either app/page.js with the following:
    'use client'
    
    import { useState } from 'react';
    
    function Test() {
      const [selectedTopping, setSelectedTopping] = useState('Medium');
    
      return (
        <div className="flex flex-col items-center">
          <input 
            type="radio" 
            name="topping" 
            value="Regular" 
            id="regular" 
            checked={selectedTopping === 'Regular'}
            onChange={e => setSelectedTopping(e.target.value)}
          />
          <label htmlFor="regular">Regular</label>
    
          <input 
            type="radio" 
            name="topping" 
            value="Medium" 
            id="medium" 
            checked={selectedTopping === 'Medium'}
            onChange={e => setSelectedTopping(e.target.value)}
          />
          <label htmlFor="medium">Medium</label>
    
          <input 
            type="radio" 
            name="topping" 
            value="Large" 
            id="large"
            checked={selectedTopping === 'Large'}
            onChange={e => setSelectedTopping(e.target.value)}
          />
          <label htmlFor="large">Large</label>
        </div>
      )
    }
    
    export default function Page() {
      return <Test />;
    }
    
  4. Run your app in dev mode using yarn dev. If you visit http://localhost:3000 you will notice the controlled radio button is initially selected but then flickers uncollected

Debugging further Inspecting the dom shows the following

<div class="flex flex-col items-center">
  <input type="radio" name="topping" id="regular" value="Regular">
  <label for="regular">Regular</label>
  <input type="radio" name="topping" id="medium" value="Medium" checked="">
  <label for="medium">Medium</label>
  <input type="radio" name="topping" id="large" value="Large">
  <label for="large">Large</label>
</div>

However, the input marked checked="" is not checked and if we query the dom we can see document.getElementById("medium").checked = false

Describe the Bug

There seems to be an issue where React is initially rendering the radio input as selected and then it almost instantly looses it’s selected state. My guess is this has something to do with React’s strict mode. However, this issue doesn’t arise until I enable the appDir.

Expected Behavior

I would expect the controlled radio input selection to visually match the state in React.

Which browser are you using? (if relevant)

1.51.110 Chromium: 113.0.5672.77 (Official Build) (64-bit)

How are you deploying your application? (if relevant)

No response

NEXT-1198

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Reactions: 15
  • Comments: 24 (4 by maintainers)

Commits related to this issue

Most upvoted comments

Fixed in v13.5.6-canary.1 via 0a80017d038e7de7ac16894d4ebc8d64cf7988eb

Setting manually the checked prop with useEffect can temporarily “resolves” the issue.

e.g:

'use client'

import { useState, useId, useEffect } from 'react';

function Test() {
  const toBeSelectedId = useId();
  const [selectedTopping, setSelectedTopping] = useState('Medium');

  useEffect(() => {
    document.querySelector(`[data-id="${toBeSelectedId}"]`).checked = true;
    // or even with
    document.querySelector(`[type=radio][name=topping][value=Medium]`).checked = true;
  }, [toBeSelectedId]);

  return (
    <div className="flex flex-col items-center">
      <input 
        type="radio" 
        name="topping" 
        value="Regular" 
        id="regular" 
        checked={selectedTopping === 'Regular'}
        onChange={e => setSelectedTopping(e.target.value)}
      />
      <label htmlFor="regular">Regular</label>

      <input 
        type="radio" 
        name="topping" 
        value="Medium" 
        id="medium" 
        checked={selectedTopping === 'Medium'}
        onChange={e => setSelectedTopping(e.target.value)}
        // --- HERE
       data-id={toBeSelectedId}
      />
      <label htmlFor="medium">Medium</label>

      <input 
        type="radio" 
        name="topping" 
        value="Large" 
        id="large"
        checked={selectedTopping === 'Large'}
        onChange={e => setSelectedTopping(e.target.value)}
      />
      <label htmlFor="large">Large</label>
    </div>
  )
}

export default function Page() {
  return <Test />;
}

@apostolos is correct, after I checked the reproduction with that version, the bug is gone. Please upgrade!

For Info, the bug not present in prod mode, only in dev mode…

possible refresh causes dev stat

so then it could be fixed smth like this:

  // NOTE: https://github.com/vercel/next.js/issues/49499
  if (process.env.NODE_ENV === 'development') {
      useEffect(() => { 
          for (const el of document.getElementsByName('my-radio')) {
              if (el.hasAttribute('checked')) {
                  (el as HTMLInputElement).checked = true
              }
          }
      }, []);
  }

@ozzyfromspace appreciate the help, but like the other hacks in this thread this doesn’t solve the underlying issue. I know there are multiple ways I can hack together a workaround, but that still doesn’t solve the underlying problem. I’ve also found when managing large scale projects it really easy to introduce lots of npm packages but difficult to remove them later when the project becomes bloated. I’m a fan of Radix, but I cant afford to introduce another UI library just to fix one bug.

To be honest, this isn’t a huge issue for me at the moment. I haven’t noticed it since opening up this thread. My main problem is I’ve come to trust the React team when they say something is stable. Maybe I’ve just gotten lucky, but I’ve never seen a bug like this in a production React build.

I’m realizing the Next.js team moves faster then the React time, and I think I’m going to need to be more careful when upgrading Next versions in the future. I don’t want to sound ungrateful, because it’s amazing what Next team has accomplished in a short time. I just can’t afford to have projects breaking like this when upgrading Next versions.

I’m sorry if I’m blowing the severity of this issue out of proportion. I just never though I’d have to worry about html primitives themselves breaking in a Next.js update.

defaultChecked is also bugged. The radio button should be checked by default when using defaultChecked, but when you reload the page, the radio button is briefly checked and then unckecked again. Here is an example: https://codesandbox.io/p/sandbox/inspiring-babycat-qz92hy

@christianjuth tried with next@latest the bug still persist -> https://codesandbox.io/p/sandbox/smoosh-brook-z4s7ff With older versions (eg. next@13.3.0) it works correctly -> https://codesandbox.io/p/sandbox/beautiful-http-wjnk57

@sophiebits @timneutkens can you pls downgrade react-builtin
This is the incriminated commit that upgrades react to a version with broken controlled radio input https://github.com/vercel/next.js/commit/925bb3b02568ea19deee2baae074c800a834121c.

@christianjuth I’m facing the problem starting from 13.3.1. Tried 13.0.1 and 13.0.2 as you indicated and works perfectly. Did you mean 13.3.1+? The bug is still present in the last 13.4.5

Found that with 13.3.1-canary.16 works, with 13.3.1-canary.17 don’t work.

Same problem here with next@13.3.5-canary.2 + node@18.16.0 + pnpm@7.32.2. I just figured out that the reported bug is not happening when starting production server (pnpm build && pnpm start).

Instead I’m facing another (more cryptic) bug. Don’t know if it’s better to open a separate issue. Starting @christianjuth example on prod server all works great. If I change the onChange behaviour from sync to async this is what happens:

  • pnpm build && pnpm start
  • the app start correctly with Medium radio checked
  • click on Large radio -> after 2 seconds the Large radio is checked + the console prints onChange and onChange setTimeout
  • click on Medium radio -> after 2 seconds the Medium radio is checked + the console prints onChange and onChange setTimeout
  • click on Large radio -> the change happens instantly and no console logs are printed (it seems input are no more controlled by React)
  • click on Regular radio -> the Medium radio will be checked and after 2 seconds the Regular radio is checked

To replicate I just changed few line of code in app/page.tsx:

"use client";

import { useState } from "react";

function Test() {
	const [selectedTopping, setSelectedTopping] = useState("Medium");

	const onChange = (e) => {
		console.log("onChange");

		setTimeout(() => {
			console.log("onChange setTimeout");
			setSelectedTopping(e.target.value);
		}, 2000);
	};

	return (
		<div className="flex flex-col items-center">
			<input
				type="radio"
				name="topping"
				value="Regular"
				id="regular"
				checked={selectedTopping === "Regular"}
				onChange={onChange}
			/>
			<label htmlFor="regular">Regular</label>

			<input
				type="radio"
				name="topping"
				value="Medium"
				id="medium"
				checked={selectedTopping === "Medium"}
				onChange={onChange}
			/>
			<label htmlFor="medium">Medium</label>

			<input
				type="radio"
				name="topping"
				value="Large"
				id="large"
				checked={selectedTopping === "Large"}
				onChange={onChange}
			/>
			<label htmlFor="large">Large</label>
		</div>
	);
}

export default function Page() {
	return <Test />;
}

https://github.com/vercel/next.js/assets/7655943/edb79868-b08c-4ee7-a935-995a2933353b

@lsagetlethias thank you your fix worked for me. I was tearing my hair out last night trying to understand if it was me doing something wrong