CherryHQ/cherry-studio · error · Error

[theme-contract] ${mode} variable cycle: ${[...stack.slice(c

Error message

[theme-contract] ${mode} variable cycle: ${[...stack.slice(cycleStart), name].join(' -> ')}

What it means

Thrown by assertNoCycles when the var() reference graph in a mode contains a cycle (A -> B -> ... -> A). The validator does a DFS over references; revisiting a node currently on the visit stack is a cycle. The message prints the cycle path so you can see exactly which variables point at each other.

Source

Thrown at packages/ui/scripts/validate-theme-contract.ts:204

      if (!declarations.has(reference)) {
        throw new Error(
          `[theme-contract] ${mode} ${declaration.name} in ${declaration.source} references undefined ${reference}`
        )
      }
    }
  }
}

function assertNoCycles(mode: string, declarations: Map<string, Declaration>): void {
  const visited = new Set<string>()
  const visiting = new Set<string>()
  const stack: string[] = []

  const visit = (name: string): void => {
    if (visited.has(name)) return
    if (visiting.has(name)) {
      const cycleStart = stack.indexOf(name)
      throw new Error(`[theme-contract] ${mode} variable cycle: ${[...stack.slice(cycleStart), name].join(' -> ')}`)
    }

    visiting.add(name)
    stack.push(name)

    const declaration = declarations.get(name)
    if (declaration) {
      for (const reference of extractReferences(declaration.value, declaration.source)) {
        if (declarations.has(reference)) visit(reference)
      }
    }

    stack.pop()
    visiting.delete(name)
    visited.add(name)
  }

  for (const name of declarations.keys()) visit(name)

View on GitHub (pinned to 726446b54c)

Solutions

  1. Inspect the cycle path in the message and re-point at least one declaration in the cycle at a concrete value or a variable OUTSIDE the cycle.
  2. Pick a single owner for the underlying value and have the other variable(s) reference it one-directionally.
  3. Re-run `pnpm --filter @cherrystudio/ui theme:check` and the reference/cycle assertions together.

Example fix

// before (product.css)
--inline-code: var(--inline-code-foreground);
--inline-code-foreground: var(--inline-code);
// after — one-directional
--inline-code: rgba(0, 0, 0, 0.06);
--inline-code-foreground: rgb(218, 97, 92);
Defensive patterns

Strategy: validation

Validate before calling

// Detect cycles via DFS over the var() reference graph before calling the validator.
function hasCycle(decls: Map<string, string>) {
  const color = new Map<string, 0 | 1 | 2>()
  const dfs = (n: string): boolean => {
    color.set(n, 1)
    for (const [, ref] of (decls.get(n) ?? '').matchAll(/var\(\s*(--[a-z0-9-]+)/g)) {
      if (!decls.has(ref)) continue
      if (color.get(ref) === 1) return true
      if (color.get(ref) === undefined && dfs(ref)) return true
    }
    color.set(n, 2)
    return false
  }
  return [...decls.keys()].some((n) => color.get(n) === undefined && dfs(n))
}

Try / catch

try {
  validateThemeContractSources(sources)
} catch (error) {
  if (error instanceof Error && /variable cycle/.test(error.message)) {
    console.error(error.message) // prints the cycle path
    process.exitCode = 1
    return
  }
  throw error
}

Prevention

When it happens

Trigger: `--inline-code: var(--inline-code-foreground);` together with `--inline-code-foreground: var(--inline-code);` in product.css; rewiring two variables to point at each other during a dark-mode refactor; a chain like `--a: var(--b); --b: var(--c); --c: var(--a);`.

Common situations: Swapping which variable is the 'source of truth' and forgetting to break the old back-reference; merge conflicts that combine two half-refactors.

Related errors


AI-assisted analysis of CherryHQ/cherry-studio@726446b54c (2026-08-12). Data as JSON: /api/errors/706b7181c60aeeb3. Report an issue: GitHub.