Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
1 task done
christianjuth opened this issue May 9, 2023 · 24 comments
Closed
1 task done
Labels
bug Issue was opened via the bug report template. linear: next Confirmed issue that is tracked by the Next.js team. locked

Comments

@christianjuth
Copy link

christianjuth commented May 9, 2023

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

@christianjuth christianjuth added the bug Issue was opened via the bug report template. label May 9, 2023
@christianjuth
Copy link
Author

In this video you can see how it flickers on page reload. Note this is the version using pages/ with appDir enabled in Next 13.0.1.

next-radio-bug.mov

@JesseKoldewijn
Copy link
Contributor

Can confirm this both happend on next@canary with its default react.js release and with react@next + react-dom@next

From what I can see happening in the dom it kind of seems like it looses the checked state when the next-route-announcer gets placed in the dom and the title gets set. (not sure if this is related tho)

The value in the state doesn't get changed so logic speaking the radio button should be checked according to the available data in the component.

@timneutkens timneutkens added the linear: next Confirmed issue that is tracked by the Next.js team. label May 17, 2023
@timneutkens timneutkens changed the title Next.js 13.0.1+ breaks radio buttons in both app directory and pages [NEXT-1198] Next.js 13.0.1+ breaks radio buttons in both app directory and pages May 17, 2023
@lsagetlethias
Copy link

lsagetlethias commented May 29, 2023

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 />;
}

@BoxedFruits
Copy link

@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

@lsagetlethias
Copy link

YW. I suppose it can also be done with useRef, didn't test it.

Still an ugly fix tho'.

@Luk-z
Copy link
Contributor

Luk-z commented Jun 13, 2023

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 />;
}
Registrazione.schermo.2023-06-13.alle.16.03.35.mov

@Luk-z
Copy link
Contributor

Luk-z commented Jun 14, 2023

@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.

@Luk-z
Copy link
Contributor

Luk-z commented Jun 14, 2023

There seems to be a related issue in react 18.3.0 canary facebook/react#26876

@Hozeee
Copy link

Hozeee commented Jun 19, 2023

Another hack, remove the "name" attribute, then it will work (the initial value will be set). Since the example is a controlled input, it will work like the regular radio button (only 1 selected). It's annoying, you can't use formData to collect all form elements value.

@Luk-z
Copy link
Contributor

Luk-z commented Jun 22, 2023

@sophiebits @timneutkens can you pls downgrade react-builtin
This is the incriminated commit that upgrades react to a version with broken controlled radio input 925bb3b.

@christianjuth
Copy link
Author

@Luk-z can you check if this has been fixed in the latest next version? I tried updating my original repo that reproduced the issue, and I think it might be working now.

But I wanna make sure I'm not overlooking something.

@Luk-z
Copy link
Contributor

Luk-z commented Jul 24, 2023

@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

@MarvinAsmen
Copy link

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
Copy link
Author

I'm not really sure how to move forward. I've already adopted the app directory in a large project, which despite being "stable" still feels beta imo. I would love to get app directory bug fixes in next >13.3.0, but I'm worried about the stability of react-builtin.

If I upgrade I need to worry not only about every radio button in my project, but also any library I'm using that might be using radio buttons.

Updating feels like it will introduce bugs, but I also don't want to stick with 13.3.0 for fear of app directory routing instability.

For example, I've noticing some weird routing errors where navigating back and forth between two routes get's stuck and refuses to transition to the other route without a manual full page reload. I know the links aren't broken because the transition works the first few times then breaks.

@ozzyfromspace
Copy link

ozzyfromspace commented Jul 31, 2023

I'm not really sure how to move forward. I've already adopted the app directory in a large project, which despite being "stable" still feels beta imo. I would love to get app directory bug fixes in next >13.3.0, but I'm worried about the stability of react-builtin.

If I upgrade I need to worry not only about every radio button in my project, but also any library I'm using that might be using radio buttons.

Updating feels like it will introduce bugs, but I also don't want to stick with 13.3.0 for fear of app directory routing instability.

For example, I've noticing some weird routing errors where navigating back and forth between two routes get's stuck and refuses to transition to the other route without a manual full page reload. I know the links aren't broken because the transition works the first few times then breaks.

Hi @christianjuth I experienced the same issue about 2 weeks ago, the bug is confirmed. Hopefully you have a radio button component that you're using in multiple places, because that will make my proposition easier:

Use radix-ui's radio button primitives. The primitives have a parent Root component (see their docs). If you provide it with a defaultValue (should match one of the values of the children radio input primitives) everything will work as expected -- no flicker, loss of state, etc.

To handle state changes, pass your onChange handler to onValueChange, which returns a string representation of the value of the currently selected radio input. If you're using something like React-hook-form, you can build a basic handler using the setValue function (takes name of key to update, new value, and some optional arguments).

One other thing worth mentioning is that the bug you're experiencing only applies in Dev mode. The bug doesn't apply to the built application, even if you stick to vanilla radio inputs. I prefer radix though cuz then I know immediately how the build inputs will behave.

Best wishes, and let me know if you need further direction. Hopefully this gets you moving again.

@christianjuth
Copy link
Author

christianjuth commented Jul 31, 2023

@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.

@ozzyfromspace
Copy link

@christianjuth that's fair, I completely see where you're coming from. Best wishes with your project, and feel free to tag me if you have any bugs in general. Sometimes it's not a bug per-se, but a non-intuitive way that the new App router handles something or other. Cheers.

@rodolfoag
Copy link

YW. I suppose it can also be done with useRef, didn't test it.

Still an ugly fix tho'.

It does work with useRef.

Indeed a weird bug/solution.

const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
  const input = inputRef.current;
  if (input && checked) {
    input.checked = true;
  }
}, []);

return <input ref={inputRef}...

@MwSpaceLLC
Copy link

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

possible refresh causes dev stat

@Yeti-or
Copy link

Yeti-or commented Aug 24, 2023

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
              }
          }
      }, []);
  }

@octovolt
Copy link

octovolt commented Sep 19, 2023

Tested and working:

// NOTE: https://github.com/vercel/next.js/issues/49499
if (process.env.NODE_ENV === 'development') {
  useEffect(() => {
    document.querySelectorAll('input[type=radio]').forEach((elem) => {
      if (elem.hasAttribute('checked')) {
        (elem as HTMLInputElement).checked = true
      }
    });
  }, []);
}

This line required configuration so I went with the more standard syntax above.

for (const el of document.getElementsByName('my-radio')) {

FWIW, I am only seeing this bug when I am running a proxy server to get https working locally. I am using mkcert for the local cert and local-ssl-proxy to run the proxy on localhost:3001, while the normal server is running on localhost:3000.

So https://localhost:3001 has the bug but http://localhost:3000 does not. Pretty weird, but that's what I'm seeing.

sophiebits added a commit to facebook/react that referenced this issue Sep 21, 2023
Fixes whatever part of #26876 and vercel/next.js#49499 that #27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with latest stable release (18.2.0). It's awkward that we keep the user's state even for controlled inputs.

Previously the .defaultChecked assignment done in updateInput() was changing the actual checkedness because the dirty flag wasn't getting set, meaning that hydrating could change which radio button is checked, even in the absence of user interaction! Now we go back to always detaching again.
sophiebits added a commit to facebook/react that referenced this issue Sep 21, 2023
Fixes whatever part of #26876 and vercel/next.js#49499 that #27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with latest stable release (18.2.0). It's awkward that we keep the user's state even for controlled inputs.

Previously the .defaultChecked assignment done in updateInput() was changing the actual checkedness because the dirty flag wasn't getting set, meaning that hydrating could change which radio button is checked, even in the absence of user interaction! Now we go back to always detaching again.
sophiebits added a commit to facebook/react that referenced this issue Sep 21, 2023
Fixes whatever part of #26876 and vercel/next.js#49499 that #27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with latest stable release (18.2.0). It's awkward that we keep the user's state even for controlled inputs.

Previously the .defaultChecked assignment done in updateInput() was changing the actual checkedness because the dirty flag wasn't getting set, meaning that hydrating could change which radio button is checked, even in the absence of user interaction! Now we go back to always detaching again.
sophiebits added a commit to facebook/react that referenced this issue Oct 2, 2023
Fixes whatever part of #26876
and vercel/next.js#49499 that
#27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with
latest stable release (18.2.0). It's awkward that we keep the user's
state even for controlled inputs, so the DOM is out of sync with React
state.

Previously the .defaultChecked assignment done in updateInput() was
changing the actual checkedness because the dirty flag wasn't getting
set, meaning that hydrating could change which radio button is checked,
even in the absence of user interaction! Now we go back to always
detaching again.
github-actions bot pushed a commit to facebook/react that referenced this issue Oct 3, 2023
Fixes whatever part of #26876
and vercel/next.js#49499 that
#27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with
latest stable release (18.2.0). It's awkward that we keep the user's
state even for controlled inputs, so the DOM is out of sync with React
state.

Previously the .defaultChecked assignment done in updateInput() was
changing the actual checkedness because the dirty flag wasn't getting
set, meaning that hydrating could change which radio button is checked,
even in the absence of user interaction! Now we go back to always
detaching again.

DiffTrain build for [db69f95](db69f95)
alunyov pushed a commit to alunyov/react that referenced this issue Oct 11, 2023
Fixes whatever part of facebook#26876
and vercel/next.js#49499 that
facebook#27394 didn't fix, probably.

From manual tests I believe this behavior brings us back to parity with
latest stable release (18.2.0). It's awkward that we keep the user's
state even for controlled inputs, so the DOM is out of sync with React
state.

Previously the .defaultChecked assignment done in updateInput() was
changing the actual checkedness because the dirty flag wasn't getting
set, meaning that hydrating could change which radio button is checked,
even in the absence of user interaction! Now we go back to always
detaching again.
@apostolos
Copy link
Contributor

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

@balazsorban44
Copy link
Member

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

Copy link
Contributor

github-actions bot commented Nov 1, 2023

This closed issue has been automatically locked because it had no new activity for 2 weeks. If you are running into a similar issue, please create a new issue with the steps to reproduce. Thank you.

@github-actions github-actions bot added the locked label Nov 1, 2023
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Nov 1, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
bug Issue was opened via the bug report template. linear: next Confirmed issue that is tracked by the Next.js team. locked
Projects
None yet
Development

No branches or pull requests