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.
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:
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.