remix-run/remix · error · ParseError

unmatched (

Error message

unmatched (

What it means

After scanning a pattern part, an opening '(' for an optional group remains on the stack with no closing ')'. Optional groups must be closed within the same part, so parsePart throws 'unmatched (' positioned at the unclosed paren's source index.

Source

Thrown at packages/route-pattern/src/lib/route-pattern/parse.ts:135

      continue
    }

    if (char === '\\') {
      if (i + 1 === span[1]) {
        throw new ParseError('dangling escape', source, i)
      }
      let text = source.slice(i + 1, i + 2)
      appendText(text)
      i += 2
      continue
    }

    appendText(char)
    i += 1
  }
  let unmatchedOptional = optionalStack.at(-1)
  if (unmatchedOptional !== undefined) {
    throw new ParseError('unmatched (', source, unmatchedOptional.sourceIndex)
  }
  if (emptyOptionals.length > 0) {
    throw new ParseError('empty optional', source, emptyOptionals[0])
  }

  validateTokenGrammar(source, tokens, optionals, tokenIndices)
  validateOptionalCaptureSchemas(source, tokens, optionals, tokenIndices)

  return { tokens, optionals, type: options.type }
}

function validateTokenGrammar(
  source: string,
  tokens: ReadonlyArray<PartPatternToken>,
  optionals: ReadonlyMap<number, number>,
  tokenIndices: ReadonlyArray<number>,
): void {
  for (let [index, token] of tokens.entries()) {

View on GitHub (pinned to 9696913134)

Solutions

  1. Close every optional group: '/users(/edit)'
  2. Use the error index to find which '(' is unclosed
  3. Format/align long patterns so pairs are visually obvious; lint patterns in tests

Example fix

// before
let pattern = parseRoutePattern('/users(/edit')

// after
let pattern = parseRoutePattern('/users(/edit)')
Defensive patterns

Strategy: validation

Validate before calling

function isBalanced(s: string) {
  let n = 0
  for (let c of s) { if (c === '(') n++; if (c === ')') n-- }
  return n === 0
}

Type guard

null

Try / catch

try { parseRoutePattern(source) } catch (e) { if (e instanceof ParseError && e.message === 'unmatched (') { /* close the group at e.position */ } }

Prevention

When it happens

Trigger: Calling parseRoutePattern with a pattern like '/users(/edit' or '/files(/a(/b)' where at least one group never closes. The error points at the innermost/last unclosed '('.

Common situations: Refactoring optional segments and dropping the closer; generators that open groups conditionally but always intend to close; long patterns where the closer was accidentally deleted with a suffix.

Related errors


AI-assisted analysis of remix-run/remix@9696913134 (2026-08-27). Data as JSON: /api/errors/0550400421a6cddf. Report an issue: GitHub.