Skip to content

Listbox becomes non-interactive after soft navigation when Next.js cacheComponents is enabled #3880

Description

@mordechaim

What package within Headless UI are you using?

@headlessui/react

What version of that package are you using?

v2.2.10

What browser are you using?

Chrome (not browser-specific)

Reproduction URL

https://github.com/mordechaim/headless-listbox-navigation

The reproduction is a fresh create-next-app on Next.js 16 (16.2.9) + React 19.2.7 + @headlessui/react@2.2.10. The only non-default config needed is cacheComponents: true in next.config.ts.

Steps:

  1. On /, open the Listbox and select an option → onChange fires (works).
  2. Soft-navigate / → /a → / (client <Link> navigation, no reload).
  3. Open the Listbox again and click an option.

With cacheComponents: true the Listbox is now dead; remove that flag (default config) and everything works. Menu is unaffected either way.

Describe your issue

When cacheComponents is enabled, after a soft client navigation away from and back to a route, every Listbox on that route renders and opens normally but is functionally dead: clicking an option doesn't select or close it, onChange never fires, and outside-click no longer dismisses it. Only re-clicking the button closes it. Menu is not affected.

Next.js uses <Activity> behind the scenes, whih is responsible for this.

Root cause (traced through the 2.2.10 source):

  • With cacheComponents, returning to a route preserves the existing Listbox component instance instead of remounting it, so useListboxMachine's useMemo(() => ListboxMachine.new(), []) returns the same machine (without cacheComponents the route remounts and a fresh machine is created — hence no bug).
  • While the route is cached/hidden, React runs the effect cleanup for useOnUnmount, whose deferred microtask fires and calls machine.dispose(). dispose() runs disposables.dispose(), tearing down every handler registered in the machine constructor via this.on(...).
  • One of those is the SelectOption handler, which is what calls this.actions.onChange(value) and closes the listbox. After disposal it's gone, and on return the component reuses the disposed machine (useMemo deps are [], so it's never recreated).
  • Clicking an option still calls machine.actions.selectOption(value)send(SelectOption); the reducer updates internal state, but the constructor-registered side effect (onChange + close) never runs. Open/close still works because that's a pure reducer transition with no torn-down side effect.

Why Menu is immune: menu item activation runs in the component layer, not via a constructor-registered machine on(...) side effect, so a disposed-and-reused Menu machine still works.

Instrumented confirmation (broken state, after nav-back): the option's onClick fires with disabled=false and calls selectOption, but the machine's onChange action never runs.

Suggested fix

Recreate the machine when it has been disposed, instead of reusing it, in the machine glue — roughly: keep the machine in a ref and create a new one whenever the current is null or disposed (with dispose() setting a disposed flag), still disposing on unmount. This restores a fully-wired machine on return with no leak; the same applies to the combobox/menu/popover machine glues.

Workaround

Use a key to force unmounting:

const [key, setKey] = useState(() => Math.random())
useEffect(() => () => setKey(Math.random()), [])

return <Listbox key={key}>
</Listbox>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions