{"record":{"id":"96870d29ba38ba4f","repo":"chartjs/Chart.js","slug":"recursion-detected-array-from-stack-join","errorCode":null,"errorMessage":"Recursion detected: ${Array.from(_stack).join('->')}->${prop}","messagePattern":"Recursion detected: (.+?)->(.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"src/helpers/helpers.config.ts","lineNumber":263,"sourceCode":"  if (isArray(value) && value.length) {\n    value = _resolveArray(prop, value, target, descriptors.isIndexable);\n  }\n  if (needsSubResolver(prop, value)) {\n    // if the resolved value is an object, create a sub resolver for it\n    value = _attachContext(value, _context, _subProxy && _subProxy[prop], descriptors);\n  }\n  return value;\n}\n\nfunction _resolveScriptable(\n  prop: string,\n  getValue: (ctx: AnyObject, sub: AnyObject) => unknown,\n  target: ContextCache,\n  receiver: AnyObject\n) {\n  const {_proxy, _context, _subProxy, _stack} = target;\n  if (_stack.has(prop)) {\n    throw new Error('Recursion detected: ' + Array.from(_stack).join('->') + '->' + prop);\n  }\n  _stack.add(prop);\n  let value = getValue(_context, _subProxy || receiver);\n  _stack.delete(prop);\n  if (needsSubResolver(prop, value)) {\n    // When scriptable option returns an object, create a resolver on that.\n    value = createSubResolver(_proxy._scopes, _proxy, prop, value);\n  }\n  return value;\n}\n\nfunction _resolveArray(\n  prop: string,\n  value: unknown[],\n  target: ContextCache,\n  isIndexable: (key: string) => boolean\n) {\n  const {_proxy, _context, _subProxy, _descriptors: descriptors} = target;","sourceCodeStart":245,"sourceCodeEnd":281,"githubUrl":"https://github.com/chartjs/Chart.js/blob/cb02e1d207bd4c4c40b20c259017f85f26f1e30a/src/helpers/helpers.config.ts#L245-L281","documentation":"When a Chart.js option is a scriptable function, the config resolver invokes it and tracks the property name in a per-context _stack to detect cycles. If resolving a scriptable option references itself (directly or through sub-resolvers), _stack.has(prop) becomes true and the resolver throws to prevent infinite recursion / stack overflow. This protects option resolution for scriptable values that read other options via the resolver proxy.","triggerScenarios":"A scriptable option function that reads the same option (or a chain leading back to it) from the context/sub-proxy, e.g. options.borderColor = (ctx) => ctx.chart.options.borderColor; returning an object that re-triggers resolution of the same key; mutual references between two scriptable options.","commonSituations":"Writing a scriptable color/size that references ctx.chart.options.X where X is the same option; spreading the resolver into itself; sub-options that loop back through needsSubResolver; misusing the scriptable context's subProxy to read parent options that recurse.","solutions":["Read the value from a DIFFERENT, non-scriptable option or from the raw data/context instead of re-entering the same key.","Hoist the constant out of the function: compute the base value once outside the scriptable closure.","Break the cycle by accessing ctx.parsed / ctx.dataset / raw data rather than ctx.chart.options.<sameKey>.","Audit the resolution chain shown in the message (a->b->...->prop) and remove the back-reference."],"exampleFix":"// before - borderColor reads itself via options -> recursion\noptions.borderColor = (ctx) => ctx.chart.options.borderColor;\n\n// after - read from a separate config value or data\noptions.borderColor = (ctx) => ctx.dataset.borderColor || '#000';","handlingStrategy":"try-catch","validationCode":"// Pre-scan scriptable options for self-references before applying the config (heuristic).\nfunction detectSelfReference(optionFns) {\n  for (const [key, fn] of Object.entries(optionFns)) {\n    const src = String(fn);\n    // crude check: does the function body re-read the same option key?\n    if (new RegExp(`\\\\boptions\\\\.${key}\\\\b`).test(src)) {\n      console.warn(`Scriptable option '${key}' appears to reference itself; this may recurse.`);\n    }\n  }\n}","typeGuard":null,"tryCatchPattern":"// Wrap option resolution in tests to surface recursion as a readable failure.\ntry {\n  chart.update();\n} catch (e) {\n  if (/Recursion detected/.test(String(e?.message))) {\n    console.error('Scriptable option cycle:', e.message);\n    // strip scriptable functions and retry to isolate the offender\n  } else {\n    throw e;\n  }\n}","preventionTips":["Never read the same option key from ctx.chart.options inside its own scriptable function.","Prefer reading ctx.parsed / ctx.dataset / ctx.raw over re-entering options.","Hoist constants outside the scriptable closure when the value does not depend on context.","When a scriptable option must depend on another option, ensure that other option is non-scriptable."],"tags":["scriptable-options","recursion","configuration","resolver"],"backgroundTag":null,"analyzedSha":"cb02e1d207bd4c4c40b20c259017f85f26f1e30a","analyzedAt":"2026-08-12T23:38:10.965Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}