Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions packages/prop-flow/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,52 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## To Be Released

## 2.0.0

- **BREAKING CHANGE**: `OptionalProp` is now `DeclaredProp`, with an added
`optional` field, and `Analyzer.listOptionalProps` is now
`listProps(component, { includeRequired })`
- **BREAKING CHANGE**: `PropAnalysis` carries a required `constant` field, so
every `--json` row has one — `null` where there is no finding
- **BREAKING CHANGE**: `SiteKind` no longer has a `spread` member — former
spread sites are now reported as `passthrough` / `real` / `omit` / `manual`
with a note
- All three land in the library API and the `--json` shape; the CLI arguments
and the text output only gained things
- Props that are passed the same value at every call site are reported as such.
Values are read off the type, so `size="sm"`, a `const`, an enum member and a
property of an `as const` object all resolve to one value; anything the
checker cannot pin to a literal, and any `manual` site, leaves the claim
unmade. `coverage: 'all'` says the omissions land on the value too, via the
component's own default — that is the case where the prop can go away
- `--all-props` widens discovery to required props. They are reported only
where they carry a constant value, under a new `required` verdict; without
the flag the output is unchanged
- JSX spreads are resolved instead of being blanket-reported as `manual`: a
spread whose type provably lacks the prop is skipped, `{...props}` and
`{...rest}` are followed one level up, and a spread of an object literal (or
of a `const` bound to one) is read key by key. Only a spread whose type cannot
answer the question, or an optional prop in a spread contesting an earlier
value, still requires a human
- Optional props declared only in a dependency are no longer reported. A
component spreading `React.ComponentProps<'button'>` inherits some 250
optional DOM and ARIA props; a verdict on those is true but not actionable,
and it buried the props the author actually owns
- Fixed attribute precedence: `<C title="x" {...props} />` reported `"x"`, but
JSX resolves last-wins, so the spread overrides the attribute
- Fixed pass-throughs inside render callbacks: `items.map(() => <C x={props.x}/>)`
was classified as a local value, which could turn into a wrong `justified` or
`unnecessary-optional`
- Optional props declared only in a dependency are no longer reported. A
component spreading `React.ComponentProps<'button'>` inherits some 250
optional DOM and ARIA props; a verdict on those is true but not actionable,
and it buried the props the author actually owns
- Fixed a pass-through into a prop that binds its own default: the omissions at
that level were counted as omissions at the leaf, reporting a prop that is in
fact always set as `caller-dead`. `Relay({ size = 'lg' })` forwarding `size`
feeds `'lg'` down, and that is now what the counts and the value say. Each
absorbed omission is listed at the call site where the default fires, so the
pass count still lines up with the sites below it
- A pass-through that climbs into a function which is called rather than
rendered (a `renderX({ … })` test helper) now reports `manual` instead of
counting its invisible callers as zero, which would report a live prop as
`caller-dead`
- `--json` output: `SiteKind` no longer has a `spread` member — former spread
sites are now reported as `passthrough` / `real` / `omit` / `manual` with a
note

## 1.0.0

Expand Down
81 changes: 71 additions & 10 deletions packages/prop-flow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ directory, so it always analyses your code with the compiler your code uses.
## Usage

```bash
$ pnpm prop-flow <file> [propName] [--tsconfig <path>] [--json]
$ pnpm prop-flow <file> [propName] [--tsconfig <path>] [--all-props] [--json]
```

| argument | meaning |
| ------------ | ------------------------------------------------------------------------------------------------------------------- |
| `<file>` | a `.ts`/`.tsx` file containing the component(s) to inspect |
| `[propName]` | one prop; omitted → every optional prop of every component exported from the file |
| `--tsconfig` | override the auto-discovered tsconfig — use the broadest "solution" config so call sites in other packages are seen |
| `--json` | machine-readable output |
| argument | meaning |
| -------------- | ------------------------------------------------------------------------------------------------------------------- |
| `<file>` | a `.ts`/`.tsx` file containing the component(s) to inspect |
| `[propName]` | one prop; omitted → every optional prop of every component exported from the file |
| `--tsconfig` | override the auto-discovered tsconfig — use the broadest "solution" config so call sites in other packages are seen |
| `--all-props` | inspect required props too — reported only where they carry a constant value |
| `--json` | machine-readable output |

```
$ pnpm prop-flow src/Button.tsx title
Expand All @@ -61,10 +62,50 @@ justified Button.title
| `caller-dead` | no call site passes it → optional and always `undefined` |
| `unused-component` | the component itself has no call sites in the Program |
| `manual` | an unreadable spread or a contested override blocks a static conclusion |
| `required` | the prop has no `?` to judge — listed only for its constant value |

Exit codes: `0` success, `1` nothing to do (usage printed), `2` a handled
failure (message on stderr).

## Constant values

A prop that is passed the same value at every call site carries no information:
the value can be inlined and the prop dropped. That question is **orthogonal**
to the `?` — a prop can be `justified` (some call sites omit it) and still be
constant everywhere it is passed, which is the most interesting combination of
all. So it is reported as its own field rather than as a verdict:

```
justified Chip.variant
passes=2 omits=1 ambiguous=0
constant="danger" coverage=passes
real src/App.tsx:4:7 (string literal)
real src/App.tsx:5:7 (string literal)
omit src/App.tsx:6:7
→ genuinely sometimes-absent. The `?` is correct.
→ every passing call site sends "danger"; the value could be inlined.
```

Values are read off the **type**, not off the syntax, so `size="sm"`,
`size={'sm'}`, a `const SIZE = 'sm'`, an enum member and a property of an
`as const` object all resolve to the same value — and anything the checker
cannot pin to a single literal (a call, a parameter, a widened `let`) leaves
the claim unmade. `<C dense />` counts as `true`.

Two coverages: `passes` means every call site that passes the prop agrees; `all`
means nothing anywhere sees another value — either there are no omissions, or
the component's own binding default is that same value, so the omissions land
on it too. `all` is the case where the prop can go away entirely.

Nothing is reported below two passing sites — with one, "always the same value"
is trivially true. And constant does not mean wrong: `variant="danger"` on the
two delete buttons is constant and correct, which is why the hint stops at
"could be inlined".

`--all-props` widens *discovery* to required props, not the report: a required
prop shows up only when it actually carries a constant value, under the
`required` verdict. Without the flag the output is exactly as it was.

## API

The same analysis is available programmatically:
Expand All @@ -76,15 +117,35 @@ const report = analyseProps({ file: 'src/Button.tsx', prop: 'title' });
process.stdout.write(formatText(report));
```

`analyseProps` accepts `{ cwd, file, prop, ts, tsconfig }` and returns a
`Report`; passing `ts` injects a specific compiler instead of resolving one
`analyseProps` accepts `{ allProps, cwd, file, prop, ts, tsconfig }` and returns
a `Report`; passing `ts` injects a specific compiler instead of resolving one
from `cwd`.

## Limitations

Pass-throughs are followed through plain identifiers and `props.x` member
access, including inside render callbacks — a `props.x` in `items.map(…)` is
still traced to the surrounding component.
still traced to the surrounding component. When a level of the chain binds its
own default, an omission at *its* call sites is counted as a pass of that
default rather than as an omission at the leaf: `Relay({ size = 'lg' })`
forwarding `size` feeds `'lg'` down, not `undefined`. Those absorbed omissions
are listed individually, at the call site where the default fires:

```
NEEDLESS ? Hop.size
passes=2 omits=0 ambiguous=0
constant="lg" coverage=all
passthrough src/Relay.tsx:9:10 → Relay.size (omissions fall back to its default)
real src/App.tsx:14:7 → Relay.size (the default fires here)
real src/App.tsx:15:7 → Relay.size (the default fires here)
```

Otherwise the counts are of *leaves*, not of lines: one pass-through site can
stand for a whole subtree of passes and omissions below it.

A constancy claim needs every call site to be readable. One `manual` site sinks
it — an unreadable spread could be carrying any value at all — as does a single
value the checker cannot pin to a literal.

A spread is only ambiguous when it can actually reach the prop. `{...x}` whose
type provably lacks the prop is skipped; `{...props}` and `{...rest}` are
Expand Down
198 changes: 198 additions & 0 deletions packages/prop-flow/fixtures/basic/constant.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// What makes a prop constant across its call sites, and what breaks the claim.
// Each component below is one case; `ConstantApp` at the bottom is the only
// render root in this file, so nothing here moves the counts the other fixtures
// assert.

export interface ChipProps {
id: string;
/** justified AND constant: two passes of one value, plus an omission */
variant?: string;
}

export function Chip({ id, variant }: ChipProps) {
return <b data-id={id} data-variant={variant} />;
}

export interface TileProps {
id: string;
/** constant with coverage=all: the omission lands on the same value */
size?: string;
}

export function Tile({ id, size = 'md' }: TileProps) {
return <i data-id={id} data-size={size} />;
}

export interface FlagProps {
/** constant `true`: boolean shorthand carries a value without an expression */
dense?: boolean;
id: string;
}

export function Flag({ dense, id }: FlagProps) {
return <u data-dense={dense} data-id={id} />;
}

export enum Tone {
Danger = 'danger',
Muted = 'muted',
}

const dangerTone = Tone.Danger;
const preset = { tone: Tone.Danger } as const;

export interface TagProps {
id: string;
/** constant: the same enum member written three different ways */
tone?: Tone;
}

export function Tag({ id, tone }: TagProps) {
return <em data-id={id} data-tone={tone} />;
}

export interface BlurProps {
id: string;
/** not constant: one call site computes its value, which could be anything */
label?: string;
}

export function Blur({ id, label }: BlurProps) {
return <s data-id={id}>{label}</s>;
}

function compute(): string {
return 'computed';
}

export interface SoloProps {
/** not constant: a single passing call site agrees only with itself */
hint?: string;
id: string;
}

export function Solo({ hint, id }: SoloProps) {
return <q data-id={id}>{hint}</q>;
}

export interface HopProps {
id: string;
/** constant `'lg'`: what reaches it is Relay's default, not `undefined` */
size?: string;
}

export function Hop({ id, size }: HopProps) {
return <mark data-id={id} data-size={size} />;
}

export interface RelayProps {
id: string;
size?: string;
}

/** Carries a default, so an omission at ITS call sites forwards `'lg'` on. */
export function Relay({ id, size = 'lg' }: RelayProps) {
return <Hop id={id} size={size} />;
}

export interface DimProps {
id: string;
/** not constant: the default the omissions fall back to is computed */
size?: string;
}

export function Dim({ id, size }: DimProps) {
return <small data-id={id} data-size={size} />;
}

/** The same absorption as Relay, but a computed default has no value to give. */
export function Vague({ id, size = compute() }: DimProps) {
return <Dim id={id} size={size} />;
}

export interface KeepProps {
id: string;
/** constant, but only over the passes: the default is a different value */
weight?: string;
}

export function Keep({ id, weight = 'bold' }: KeepProps) {
return <strong data-id={id} data-weight={weight} />;
}

export interface DriftProps {
id: string;
/** the same split for the other reason: the default cannot be read at all */
label?: string;
}

export function Drift({ id, label = compute() }: DriftProps) {
return <cite data-id={id}>{label}</cite>;
}

export interface GaugeProps {
id: string;
/** constant `42`: a number is read off the type, exactly as a string is */
span?: number;
}

export function Gauge({ id, span }: GaugeProps) {
return <output data-id={id} data-span={span} />;
}

export interface ToggleProps {
id: string;
/** constant `true` written out: booleans are not `isLiteral()` literals */
on?: boolean;
}

export function Toggle({ id, on }: ToggleProps) {
return <label data-id={id} data-on={on} />;
}

export interface ReqProps {
id: string;
/** required, yet fed one and the same value everywhere — the --all-props case */
kind: string;
}

export function Req({ id, kind }: ReqProps) {
return <span data-id={id} data-kind={kind} />;
}

export function ConstantApp() {
return (
<div>
<Chip id="c1" variant="danger" />
<Chip id="c2" variant="danger" />
<Chip id="c3" />
<Tile id="t1" size="md" />
<Tile id="t2" size="md" />
<Tile id="t3" />
<Flag dense id="f1" />
<Flag dense id="f2" />
<Tag id="enum" tone={Tone.Danger} />
<Tag id="alias" tone={dangerTone} />
<Tag id="member" tone={preset.tone} />
<Blur id="b1" label="fixed" />
<Blur id="b2" label={compute()} />
<Solo hint="once" id="s1" />
<Relay id="r1" />
<Relay id="r2" />
<Vague id="v1" />
<Vague id="v2" />
<Keep id="k1" weight="light" />
<Keep id="k2" weight="light" />
<Keep id="k3" />
<Drift id="d1" label="fixed" />
<Drift id="d2" label="fixed" />
<Drift id="d3" />
<Gauge id="g1" span={42} />
<Gauge id="g2" span={42} />
<Toggle id="tg1" on={true} />
<Toggle id="tg2" on={true} />
<Req id="q1" kind="primary" />
<Req id="q2" kind="primary" />
</div>
);
}
2 changes: 1 addition & 1 deletion packages/prop-flow/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fxone/prop-flow",
"version": "1.0.0",
"version": "2.0.0",
"description": "trace an optional prop across every JSX call site and tell whether its `?` is justified",
"keywords": [
"typescript",
Expand Down
Loading
Loading