🔁

循環参照データ作成でMaximum call stack size exceeded

2023/04/15に公開2

概要

react native + expo + typescriptで下記のエラー。

[Unhandled promise rejection: RangeError: Maximum call stack size exceeded.]

このエラーは無限ループをするとよく発生するのですが、今回は目を皿のようにして何度も確認しましたが無限ループはありません。

結論からいうと(恐らく)循環参照が原因でした。

いわゆるナイーブツリーのデータをAPIから取得し、それぞれの子要素に親の参照を持たせようとしたら発生しました。

データ

わかりやすくデータは端折り、数も減らしました。実際はもっと大きなデータです。

{
  "preferences": [
    {
      "id": 1,
      "parentId": null,
      "name": "ルートアイテム1",
      "isLeaf": false,
      "children": [
        {
          "id": 2,
          "parentId": 1,
          "name": "子要素1",
          "isLeaf": false,
          "children": [
            {
              "id": 3,
              "parentId": 2,
              "name": "孫要素1",
              "isLeaf": true
            }
          ]
        }
      ]
    }
  ]
}

問題のコード

// 各要素の型
export interface MatchingFactor {
  id: number;
  name: string;
  isLeaf: boolean;
  parentId?: number;
  children?: MatchingFactor[];
  parent?: MatchingFactor;
}
/**
 * 実際にはreduxのreducerの中で読み込まれた時のみ一回だけ行なっています。
 * action.payload.preferencesの中身は先ほどのデータ。
 */
mfFns.complementParents(action.payload.preferences);
// complementParentsの実装。
export const complementParents = (parents: MatchingFactor[]) => {
  parents.forEach(parent => {
    if (parent.children) {
      // 子供たちに自分をセット。
      parent.children.forEach(child => {
        child.parent = parent;
      });
      complementParents(parent.children);
    }
  });
};

何となく動きそうなんですけど、parentがchildrenを持っていてその中に自分がいるので循環参照が起きてます。これを解消したら治ったので恐らくそれが原因です。

動いたコード

// 各要素の型
export interface MatchingFactor {
  id: number;
  name: string;
  isLeaf: boolean;
  parentId?: number;
  children?: MatchingFactor[];
  getParent: MatchingFactor | undefined;
}
export const complementParents = (parents: MatchingFactor[]) => {
  parents.forEach(parent => {
    // ルートの親には undefined を返す関数をセット。
    if (isNotDefined(parent.getParent)) {
      parent.getParent = () => undefined;
    }

    if (parent.children) {
      // 子供たちに自分をかえす関数をセット。
      parent.children.forEach(child => {
        child.getParent = () => parent;
      });
      complementParents(parent.children);
    }
  });
}

参照をそのまま持たすのではなく参照を返す関数を持たせたら動きました。

感想

Maximum call stack size exceeded自体は関数を再起的に呼び出して、条件ミスって無限ループした時にでるので、なにかミスってるかと何度も見直しました。

またこちらの記事にもあるりますが、容量オーバー的でも発生するよう。記事はapplyのRangeErrorなので少し趣が違うが一応データをサーバー側でLIMITして最小限にして試しても発生。

ただ、それをしたことによりconsole.log()でデータを見渡せるようになり、children: [Circular]となっているのを見つけて、もしやと思って関数にしたら動いた次第。

Discussion

nap5nap5

それぞれの子要素に親の参照を持たせよう

データをまずはflattenしてそのあとにpopulateするのがいいんじゃないかと思いました。
childrenoptionalOmittableにするのではなく、必須にして空配列を初期値で入れておくのがいいんじゃないかとも思いました。

データ構造

export type MatchingFactor = {
  id: number;
  name: string;
  isLeaf: boolean;
  children: MatchingFactor[];
  parentId?: number | null;
  parent?: MatchingFactor | null;
}

ワークアラウンド

export const populateParent = (parents: MatchingFactor[], lookedParents: MatchingFactor[]) => {
  const results: MatchingFactor[] = []
  const walk = (node: MatchingFactor) => {
    const item: MatchingFactor = {
      ...node,
      parent: lookedParents.find(d => d.id === node.parentId) ?? null
    }
    results.push(item)
    node.children.forEach(walk)
  }
  parents.forEach(walk)
  return results
};

export const flattenTree = (parents: MatchingFactor[]) => {
  const results: MatchingFactor[] = []
  const walk = (node: MatchingFactor) => {
    results.push(node)
    node.children.forEach(walk)
  }
  parents.forEach(walk)
  return results
}

テストコード

import { test, expect } from "vitest";

import { MatchingFactor, flattenTree, populateParent } from ".";

test("flattenTree", () => {
  const inputData: MatchingFactor[] = [
    {
      id: 1,
      parentId: null,
      name: "ルートアイテム1",
      isLeaf: false,
      children: [
        {
          id: 2,
          parentId: 1,
          name: "子要素1",
          isLeaf: false,
          children: [
            {
              id: 3,
              parentId: 2,
              name: "孫要素1",
              isLeaf: true,
              children: [],
            },
          ],
        },
      ],
    },
  ];
  const outputData = flattenTree(inputData);
  expect(outputData).toStrictEqual([
    {
      id: 1,
      parentId: null,
      name: "ルートアイテム1",
      isLeaf: false,
      children: [
        {
          id: 2,
          parentId: 1,
          name: "子要素1",
          isLeaf: false,
          children: [
            {
              id: 3,
              parentId: 2,
              name: "孫要素1",
              isLeaf: true,
              children: [],
            },
          ],
        },
      ],
    },
    {
      id: 2,
      parentId: 1,
      name: "子要素1",
      isLeaf: false,
      children: [
        {
          id: 3,
          parentId: 2,
          name: "孫要素1",
          isLeaf: true,
          children: [],
        },
      ],
    },
    {
      id: 3,
      parentId: 2,
      name: "孫要素1",
      isLeaf: true,
      children: [],
    },
  ]);
});

test("populateParent", () => {
  const inputData: MatchingFactor[] = [
    {
      id: 1,
      parentId: null,
      name: "ルートアイテム1",
      isLeaf: false,
      children: [
        {
          id: 2,
          parentId: 1,
          name: "子要素1",
          isLeaf: false,
          children: [
            {
              id: 3,
              parentId: 2,
              name: "孫要素1",
              isLeaf: true,
              children: [],
            },
          ],
        },
      ],
    },
  ];
  const flattenedTree = flattenTree(inputData);
  const outputData = populateParent(inputData, flattenedTree);
  expect(outputData).toStrictEqual([
    {
      id: 1,
      parentId: null,
      name: "ルートアイテム1",
      isLeaf: false,
      children: [
        {
          id: 2,
          parentId: 1,
          name: "子要素1",
          isLeaf: false,
          children: [
            {
              id: 3,
              parentId: 2,
              name: "孫要素1",
              isLeaf: true,
              children: [],
            },
          ],
        },
      ],
      parent: null,
    },
    {
      id: 2,
      parentId: 1,
      name: "子要素1",
      isLeaf: false,
      children: [
        {
          id: 3,
          parentId: 2,
          name: "孫要素1",
          isLeaf: true,
          children: [],
        },
      ],
      parent: {
        id: 1,
        parentId: null,
        name: "ルートアイテム1",
        isLeaf: false,
        children: [
          {
            id: 2,
            parentId: 1,
            name: "子要素1",
            isLeaf: false,
            children: [
              {
                id: 3,
                parentId: 2,
                name: "孫要素1",
                isLeaf: true,
                children: [],
              },
            ],
          },
        ],
      },
    },
    {
      id: 3,
      parentId: 2,
      name: "孫要素1",
      isLeaf: true,
      children: [],
      parent: {
        id: 2,
        parentId: 1,
        name: "子要素1",
        isLeaf: false,
        children: [
          {
            id: 3,
            parentId: 2,
            name: "孫要素1",
            isLeaf: true,
            children: [],
          },
        ],
      },
    },
  ]);
});

demo code.

https://codesandbox.io/p/sandbox/recursing-leakey-yz33fw?file=%2Fsrc%2Findex.ts%3A1%2C1

MasamotoMasamoto

ありがとうございます!
今別案件でバタバタしてるので細かいところ見てませんが時間できたらやってみます。