[Bug]: Accordion crashes for scalar `defaultOpenItems={0}` (headless and styled v9)

オープン 初心者向け
#37,066 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 2 日以内に返信

@arniebradfo がすでに取り組んでいます。

2026年11月2日 から。

  • #37067 @arniebradfo による — オープン

評価

難易度
2/5
見積もり時間
1〜3時間
初心者へのやさしさ
72/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
react, typescript
領域
frontend

調査の方向性

react-accordion の useAccordionBase_unstable で共有しているステート初期化から調べてください。ここでは defaultOpenItems を真偽値チェックで保護してから useControllableState に渡しています。react-utilities の useControllableState が関数ではない defaultState をどう扱うか、また useAccordionItem が openItems.includes をどう呼び出しているかを確認してください。defaultOpenItems={0} と {false} を指定したときに、例外を投げずに対応する項目が開き、配列 [0] の動作と一致し、既存の Accordion テストがすべて通れば完了です。

索引モデルが issue の本文から書いたものです。

説明

Summary

An Accordion with a valid scalar default item value of 0 (defaultOpenItems={0}) throws during its initial render:

TypeError: ctx.openItems.includes is not a function

The equivalent array form, defaultOpenItems={[0]}, renders correctly and opens the item.

This reproduces through the public headless Accordion API in
@fluentui/[email protected]. It also reproduces through the public
styled Accordion in @fluentui/[email protected]. Both paths use the same state
initialization logic.

Scalar false produces the same error. This is a runtime bug with supported values, not a
TypeScript error or a requirement to use an array: the public type explicitly accepts a scalar
or an array.

TLDR;
  • ❌ Fails: defaultOpenItems={0} - Throws during initial rendering instead of opening the item with value={0}.
  • ❌ Fails: defaultOpenItems={false} - Throws during initial rendering instead of opening the item with value={false}.
  • ❌ Fails on interaction: defaultOpenItems="" - Initially opens the item with value="", but its first close attempt leaves it open with collapsible. With multiple also enabled, that click throws previousOpenItems.filter is not a function.
  • ✅ Works: defaultOpenItems={[0]} - Opens the item with value={0}.
  • ✅ Works: defaultOpenItems={[false]} - Opens the item with value={false}.
  • ✅ Works: defaultOpenItems={1} - Opens the item with value={1}.
  • ✅ Works: defaultOpenItems="section" - Opens the item with value="section".
  • ✅ Works: defaultOpenItems={[]} - Starts with no items open.
  • ✅ Works: defaultOpenItems={[""]} - Opens the item with value="" and closes it correctly with collapsible.
  • ✅ Works: defaultOpenItems omitted - Starts with no items open.
  • ✅ Works: defaultOpenItems={undefined} - Starts with no items open.
  • ✅ Works: openItems={0} - Opens the item with value={0} in controlled mode.
  • ✅ Works: openItems={false} - Opens the item with value={false} in controlled mode.
  • ✅ Works: openItems={[]} - Keeps all items closed in controlled mode.

Each opening example assumes a matching AccordionItem.value. openItems={false} identifies
the false-valued item; it does not mean "close everything." Controlled changes require updating
openItems through onToggle.

Tested environment

Item Version / environment
@fluentui/react-headless-components-preview 0.3.2
Resolved @fluentui/react-accordion 9.12.5
Resolved @fluentui/react-utilities 9.27.0
React 18.3.1
React DOM 18.3.1
TypeScript 5.5.4
Node.js 22.23.1
Browser Playwright Chromium 151.0.7922.34
Operating system Windows
Rendering paths tested Client rendering and renderToString
Build modes tested Development client/SSR and production SSR

The same truthiness check is present in the repository's master source when inspected on
2026-11-02. I have not built master or tested other published package versions.

Minimal reproduction: no browser or app scaffold required

  1. In an empty folder, initialize a package and install the versions below:

    npm init -y
    npm install --save-exact [email protected] [email protected] @fluentui/[email protected] @fluentui/[email protected] @fluentui/[email protected]
    

    The underlying Accordion and utility packages are pinned to match the tested environment.
    The headless preview otherwise resolves them through dependency ranges.

  2. Save the following as repro.cjs:

    const React = require('react');
    const { renderToString } = require('react-dom/server');
    const {
      Accordion,
      AccordionItem,
      AccordionHeader,
      AccordionPanel,
    } = require('@fluentui/react-headless-components-preview/accordion');
    
    const tree = React.createElement(
      Accordion,
      { defaultOpenItems: 0, collapsible: true },
      React.createElement(
        AccordionItem,
        { value: 0 },
        React.createElement(AccordionHeader, null, 'Section zero'),
        React.createElement(AccordionPanel, null, 'Panel content'),
      ),
    );
    
    console.log(renderToString(tree));
    
  3. Run:

    node repro.cjs
    
  4. Observe the exception before the Accordion renders.

  5. Change only the Accordion prop:

    { defaultOpenItems: [0], collapsible: true }
    
  6. Run the script again. It now returns HTML, and the header has aria-expanded="true".

Reproduce through styled Fluent v9

Change only the import in the same script:

} = require('@fluentui/react-accordion');

Scalar 0 still throws. The array [0] still works. This confirms that the defect is shared
Accordion behavior rather than a headless-specific styling or rendering layer.

Minimal client reproduction

Use this component in a React 18.3.1 app with the package versions above:

import {
  Accordion,
  AccordionItem,
  AccordionHeader,
  AccordionPanel,
} from '@fluentui/react-headless-components-preview/accordion';

export default function App() {
  return (
    <Accordion defaultOpenItems={0} collapsible>
      <AccordionItem value={0}>
        <AccordionHeader>Section zero</AccordionHeader>
        <AccordionPanel>Panel content</AccordionPanel>
      </AccordionItem>
    </Accordion>
  );
}
  1. Mount App.
  2. The Accordion subtree fails during initial rendering with
    ctx.openItems.includes is not a function.
  3. Replace defaultOpenItems={0} with defaultOpenItems={[0]}.
  4. The component mounts and the header reports aria-expanded="true".

The client failure was verified in Chromium with an error boundary used only to observe the
exception. The reproduction uses public components, not internal hooks, deprecated navigation
props, or custom wrappers. No provider, custom CSS, icons, or component-specific polyfill is
required to reproduce the crash.

Expected behavior

  • defaultOpenItems={0} behaves like defaultOpenItems={[0]}.
  • The item with value={0} starts open.
  • Its header has aria-expanded="true".
  • The Accordion renders without throwing.
  • Scalar and array forms use the same normalized internal array state.
  • Other supported scalar values are not treated as absent just because they are falsy.

Actual behavior

The uncontrolled state stores the scalar value 0 instead of an array such as [0].
AccordionItem then calls .includes() on that number, causing the initial render to fail.

Relevant stack frames from the Node reproduction, with local path prefixes omitted:

TypeError: ctx.openItems.includes is not a function
    at ...\@fluentui\react-accordion\lib-commonjs\components\AccordionItem\useAccordionItem.cjs:17:84
    at ...\@fluentui\react-accordion\lib-commonjs\contexts\accordion.cjs:31:152
    at useContextSelector (...\@fluentui\react-context-selector\lib-commonjs\useContextSelector.cjs:22:27)

This is not only a development warning. Production SSR throws the same exception.

Observed input matrix

The item value matches the supplied default or controlled value in each row. For an omitted,
undefined, or empty-array value, the item value is 0.

Prop Observed result Verification
defaultOpenItems={0} Throws: ctx.openItems.includes is not a function Client and SSR
defaultOpenItems={false} Throws the same error Client and SSR
defaultOpenItems={null} Throws: Cannot read properties of null (reading 'includes') SSR
defaultOpenItems={[0]} Renders; matching item starts open Client and SSR
defaultOpenItems={[false]} Renders; matching item starts open and closes with collapsible Client and SSR
defaultOpenItems={1} Renders; matching item starts open SSR
defaultOpenItems="section" Renders; matching item starts open SSR
openItems={0} Renders; matching item starts open Client and SSR
openItems={false} Renders; matching item starts open Client and SSR
openItems={[]} Renders; all items stay closed in controlled mode SSR
defaultOpenItems={[]} Renders; item starts closed SSR
defaultOpenItems omitted Renders; item starts closed Client and SSR
defaultOpenItems={undefined} Renders; item starts closed SSR
defaultOpenItems="", with collapsible Renders open; first close attempt incorrectly keeps the item open Client and SSR for initial rendering; client for interaction
defaultOpenItems="", with collapsible and multiple Renders open; clicking throws previousOpenItems.filter is not a function Client
defaultOpenItems={[""]}, with collapsible Renders open; clicking closes the matching item correctly Client and SSR

The 0 failure also reproduces in production SSR with all four combinations of
multiple={false/true} and collapsible={false/true}. The corresponding [0] controls work in
all four combinations.

The null result is included as an additional observation. Numeric 0 alone demonstrates the
bug without relying on unusual item values.

Public type contract

The installed API allows scalar values:

defaultOpenItems?: Value | Value[];
openItems?: Value | Value[];

AccordionItemValue is unknown. Both of these assignments type-check against the public
headless export with TypeScript 5.5.4, strict checking, and no casts:

import type { AccordionProps } from '@fluentui/react-headless-components-preview/accordion';

const numericDefault: AccordionProps = { defaultOpenItems: 0 };
const booleanDefault: AccordionProps = { defaultOpenItems: false };

The client example above also type-checks without casts.

Root cause

The shared useAccordionBase_unstable passes this expression to useControllableState:

defaultState: defaultOpenItems && (() => initializeUncontrolledOpenItems({ defaultOpenItems, multiple })),

JavaScript's && returns the left operand when it is falsy. For scalar 0, the expression
therefore passes defaultState: 0, not an initializer or undefined. For scalar false, it
passes defaultState: false.

useControllableState uses initialState only when defaultState === undefined. A provided
non-function default is stored directly. The resulting openItems is therefore a number or
boolean rather than an array.

The existing initializeUncontrolledOpenItems already knows how to wrap a scalar in an array.
The truthiness guard prevents that function from running for falsy scalar values.

The controlled openItems path does not have the same problem: it calls normalizeValues,
which checks for undefined explicitly and wraps provided scalar values. This explains why
openItems={0} works while defaultOpenItems={0} fails.

AccordionItem subsequently evaluates:

ctx.openItems.includes(value)

That call throws when the unnormalized default is a number or boolean.

Source references

Exact published sources used for the reproduction:

Repository source:

The source snippets above are from Microsoft's MIT-licensed Fluent UI implementation.
They identify the cause; consumers do not need to call these internal hooks to reproduce it.

Suggested fix direction

Check whether a default was provided, rather than whether it is truthy. For example:

defaultState:
  defaultOpenItems !== undefined
    ? () => initializeUncontrolledOpenItems({ defaultOpenItems, multiple })
    : undefined,

This preserves the omitted-default path and sends provided scalar values through the existing
normalization function. It also preserves the existing initialization policy for arrays and
multiple.

This is a suggested upstream change, not a patch applied in the consumer project.

Suggested regression coverage

  1. Uncontrolled scalar 0 renders and opens the matching item.
  2. Uncontrolled scalar false renders and opens the matching item.
  3. Uncontrolled scalar "" initializes array state and closes on its first click with
    collapsible, both with and without multiple. The current code renders initially but fails
    that interaction, so a render-only test would not prove normalization.
  4. Scalar and array forms produce equivalent initial expanded state and toggle results.
  5. Omitted defaultOpenItems retains the existing initial closed state.
  6. Controlled scalars openItems={0} and openItems={false} continue to work, including toggle
    callbacks and caller-managed state updates.
  7. Exercise multiple and collapsible combinations.
  8. Cover the shared base behavior and at least one public headless-component regression, since
    the same code is used by styled Fluent v9 and the headless facade.

Impact and temporary workaround

Numeric IDs starting at 0 are common when generating Accordion items from data. Selecting the
first such item through the documented scalar default form crashes the affected component
subtree instead of rendering a valid initial state. The same failure prevents SSR from returning
the Accordion markup.

Consumers can temporarily pass an array without changing the item value:

<Accordion defaultOpenItems={[0]}>
  <AccordionItem value={0}>
    <AccordionHeader>Section zero</AccordionHeader>
    <AccordionPanel>Panel content</AccordionPanel>
  </AccordionItem>
</Accordion>

For a boolean item value, the corresponding workaround is defaultOpenItems={[false]}.
This does not require converting the component to controlled state or renaming valid item IDs.

主要言語
TypeScript
スター
20.4k
フォーク
3k
平均マージ
3日 21時間
マージ済み PR(30日)
99

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

  • Dockerfile・Docker Compose ファイルなし
  • プルリクエストのテンプレートあり
  • コントリビューションガイドなし

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

microsoft/fluentui のほかの issue

microsoft/fluentui の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。