Skip to content

Prop type leaks between components sharing a base interface when parsed in one parse([...]) call #545

Description

@JoshSparksSlalom

When several components are parsed together in a single parse([fileA, fileB, ...]) call, a prop's type can leak from one component into another. If two components inherit a same-named prop from the same base interface (declared in node_modules) but the prop resolves to different types, the first component parsed wins and its type is reused for the rest.

The result is order-dependent: reordering the input paths changes which component ends up with the wrong type. Parsing a component on its own always gives the correct type.

Environment

react-docgen-typescript: 2.4.0
typescript: 5.4.x
Node: 22.x
Reproduction
Two components inherit value from ValueBase (@react-types/shared) with different generic arguments:

// AInput.tsx
import type { ValueBase } from '@react-types/shared'
export interface AInputProps extends ValueBase<string> {}      // value?: string

// ZMultiInput.tsx
import type { ValueBase } from '@react-types/shared'
export interface ZMultiInputProps extends ValueBase<string[]> {} // value?: string[]
const parser = withCustomConfig('tsconfig.json', {
  shouldExtractLiteralValuesFromEnum: true,
  shouldRemoveUndefinedFromOptional: true,
})
parser.parse([ZMultiInput, AInput])
//  AInput.value      => string[]   ❌ (should be string)
//  ZMultiInput.value => string[]   ✓

parser.parse([AInput, ZMultiInput])
//  AInput.value      => string     ✓
//  ZMultiInput.value => string     ❌ (should be string[])

parser.parse([AInput])
//  AInput.value      => string     ✓ (correct in isolation)

Whichever component is parsed first determines the resolved value type for both.

A minimal runnable repro (two files + a script) is attached / available here: (https://github.com/JoshSparksSlalom/docgen-issue-repro/tree/main)

Expected behavior

Each component's prop types should be resolved independently of the other files in the same parse([...]) call. AInput.value should always be string and ZMultiInput.value should always be string[], regardless of order.

Root cause

In the properties loop of parser.ts, resolved prop items are cached like this:

const cacheKey = parent?.fileName + '' + propName
if (propertiesOfPropsCache.has(cacheKey)) {
result[propName] = propertiesOfPropsCache.get(cacheKey)
} else {
const propType = checker.getTypeOfSymbolAtLocation(prop, propsObj.valueDeclaration)
// ...
if (parent?.fileName.includes('node_modules')) {
propertiesOfPropsCache.set(parent.fileName + '
' + propName, propItem)
}
}
The cache key is only _. For both components above, value is declared in the same file (@react-types/shared/.../inputs.d.ts, i.e. ValueBase), so both map to the identical key .../inputs.d.ts_value.

But the cached type was resolved via getTypeOfSymbolAtLocation(prop, propsObj.valueDeclaration) in the context of the first component's props object, so it reflects that component's generic instantiation. Every later component that inherits the prop from the same base interface gets a cache hit and receives the wrong, already-resolved type.

Since the cache lives on the Parser instance (shared across all files in one parse([...]) call) and files are processed in input order, the outcome is order-sensitive.

Impact

Projects that batch many components through a single parse call (commonly done for performance) and use a generic base props interface at different instantiations will get silently incorrect prop types for some components. It's easy to miss because the output is structurally valid — only the type string is wrong.

Suggested fixes

Incorporate the resolved type (or the concrete prop symbol identity) into the cache key, not just the declaring file + prop name.
Skip caching for props whose declaring interface is generic (type is not invariant across instantiations).
At minimum, document that parse([...]) is order-sensitive due to this cache, and/or provide a way to disable the cross-component cache.

Workaround

Build the TypeScript program once, but parse each component with its own parser via parseWithProgramProvider, so the cache can't span components:

const program = ts.createProgram(allPaths, options)
for (const file of allPaths) {
const docs = parser.parseWithProgramProvider([file], () => program)
// ...
}
This keeps the one-time program build (the expensive step) while isolating each component's prop resolution.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions