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
4 changes: 4 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ jobs:
- run:
command: |
yarn run tsc --project tsconfig.test.json --noEmit
- run:
# See scripts/typeperf/README.md
name: Type-check perf budget
command: yarn run check:typeperf

unit_tests:
parameters:
Expand Down
1 change: 1 addition & 0 deletions .cursor/rules/ci-config.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ alwaysApply: false
- `scripts/build-legacy-types.sh` builds each TS version concurrently; each `ts<version>/` gets the downleveled `lib` (with `abstract new` rewritten to `new` below 4.2, which `downlevel-dts` misses), then every newer version's `src-*-types` overlay, then its own. Keep overlays to small single-purpose modules (like `NoInfer.ts`, `tupleTypes.ts`) so whole-file copies can't go stale.
- `esmodule-types` also runs `examples/todo-app/tsconfig.typetest-libcheck.json` (`skipLibCheck: false`, `types: []`) so errors inside the legacy outputs fail CI, and `esmodule-types-latest` runs it with `--moduleResolution bundler` (TS 7 removed `node`) to cover `lib/`; the other typetests use `skipLibCheck: true`.
- Any change to legacy types building must leave `ts*/` output byte-identical to master (diff it) unless it intentionally changes published types (then add a changeset).
- `typecheck` also runs `yarn check:typeperf` ([scripts/typeperf](../../scripts/typeperf/README.md)), which reads the `ci:build:types` output from `setup`'s workspace.
- Never `git fetch --depth` the base branch in the relevance check: a shallow fetch severs the merge base and the three-dot diff fails.
- Changing root `package.json` `workspaces` requires updating the `setup` job's workspace trimming step.

Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Monorepo for `@data-client` high performance npm packages.
- `yarn build` - Build all packages
- `yarn test` - Run tests (Jest projects: ReactDOM, Node, ReactNative)
- `yarn lint` / `yarn format` - Linting and formatting
- `yarn check:typeperf` - Type-check cost budget (CI `typecheck` job); run after `yarn ci:build:types` when changing public types, `--update` to re-record. See `scripts/typeperf/README.md`
- Website: `yarn workspace rdc-website typecheck` / `yarn lint --quiet 'website/src/**/*.{ts,tsx}'` / `yarn workspace rdc-website build`; dev: `cd website && yarn start:vscode`

**Test naming**: `*.node.test.ts[x]` (Node), `*.native.test.ts[x]` (RN), `*.test.ts[x]` (regular)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"changeset:version:beta": "run changeset version --snapshot beta && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install",
"changeset:publish:beta": "run build && yarn workspaces foreach -A --no-private --topological npm publish --tolerate-republish --access public --tag beta",
"lint": "eslint",
"check:typeperf": "node scripts/typeperf/check.mjs",
"format": "eslint --fix \"packages/*/src/**/*.{js,ts,tsx,cts}\"",
"clean:types": "rimraf packages/*/*.tsbuildinfo",
"build": "yarn build:types && yarn workspaces foreach -Wptiv --no-private run build",
Expand Down
1 change: 1 addition & 0 deletions scripts/typeperf/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
scenarios/
49 changes: 49 additions & 0 deletions scripts/typeperf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Type-check performance budget

Guards how much work TypeScript does to check code that uses `@data-client` types.
CI runs it in the `typecheck` job; run it locally after `yarn ci:build:types`:

```bash
yarn check:typeperf # every fixture
yarn check:typeperf paths # one fixture
yarn check:typeperf --update # re-record budget.json after an intended change
```

It fails when:

- a stress fixture's **instantiation count** rises more than 10% over [budget.json](./budget.json).
Instantiations are deterministic for a given TypeScript version, so they make a stable
signal; check times are printed for context only.
- a fixture has type errors.
The `patheq` fixture's errors mean the path types (`PathKeys`, `PathArgs`, `ShortenPath`,
`PathArgsAndSearch`, `KeysToArgs`) disagree with the frozen reference implementation in
[patheq/orig.ts](./patheq/orig.ts) on one of ~1500 fuzzed paths. If a path type's behavior
changes on purpose, update `orig.ts` to match.

When a count drops more than 10% below its budget, the check says so; re-record it with
`--update` so the budget catches regressions from the new level.

## Fixtures

[gen.mjs](./gen.mjs) writes `scenarios/<name>/` (gitignored). `N=2` scales them up.

| Fixture | What it stresses |
|---|---|
| resources | 40 `resource()`s × React hooks and Controller calls |
| bigEntity | a 300-field Entity |
| union | a 30-member Union in Collection, Values and nested schemas |
| paths | 150 RestEndpoints with long paths, `extend()` and `paginated()` |
| nested | 12 levels of nested Entity relations |
| vue | 40 resources × Vue composables |
| schemas | Query, All, Invalidate, Array and Object schemas |
| typical | a small, realistic app |
| setValues | `ctrl.set()` values on a Union, Collection and big Entity |
| setUpdaters | `ctrl.set()` updaters spreading `prev` on a 30-member Union |
| patheq | path types against [patheq/orig.ts](./patheq/orig.ts) on fixed-seed fuzzed paths |

To compare TypeScript 6 and 7 or check time, run a compiler on a fixture directly:

```bash
npx tsc6 -p scripts/typeperf/scenarios/paths/tsconfig.json --extendedDiagnostics
npx tsc -p scripts/typeperf/scenarios/paths/tsconfig.json --extendedDiagnostics
```
16 changes: 16 additions & 0 deletions scripts/typeperf/budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"instantiations": {
"bigEntity": 17159,
"nested": 2895,
"patheq": 935715,
"paths": 341361,
"resources": 134294,
"schemas": 101186,
"setUpdaters": 128293,
"setValues": 89601,
"typical": 12995,
"union": 13578,
"vue": 47288
},
"typescript": "6.0.3"
}
114 changes: 114 additions & 0 deletions scripts/typeperf/check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Type-check performance budget for @data-client's public types.
//
// Generates the fixtures (gen.mjs), type-checks each with the `typescript` package,
// and fails when a fixture has errors or its instantiation count exceeds budget.json
// by more than TOLERANCE. Unlike check time, instantiations are deterministic for a
// given TypeScript version, so they are what the budget tracks.
//
// usage: yarn check:typeperf [--update] [scenario...]
// --update rewrite budget.json with the current counts (after an intended change)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';

import { generate } from './gen.mjs';

const dir = path.dirname(fileURLToPath(import.meta.url));
const budgetFile = path.join(dir, 'budget.json');
const TOLERANCE = 0.1;

const args = process.argv.slice(2);
const update = args.includes('--update');
const only = args.filter(a => !a.startsWith('--'));
const budget = JSON.parse(fs.readFileSync(budgetFile, 'utf8'));

// Share parsed lib and @data-client .d.ts files between fixtures; each program still
// gets its own checker, so instantiation counts are unaffected.
const sourceFiles = new Map();
const host = options => {
const h = ts.createCompilerHost(options);
const getSourceFile = h.getSourceFile;
h.getSourceFile = (file, ...rest) => {
if (file.includes('/scenarios/')) return getSourceFile(file, ...rest);
if (!sourceFiles.has(file))
sourceFiles.set(file, getSourceFile(file, ...rest));
return sourceFiles.get(file);
};
return h;
};

const failures = [];
const lowered = [];
let needsUpdate = false;
// A full --update re-records from scratch, dropping removed fixtures
if (update && !only.length) budget.instantiations = {};
console.log(`TypeScript ${ts.version}\n`);
console.log('scenario\tinstantiations\tbudget\tchange\ttime');
for (const s of generate(only)) {
const cfg = ts.getParsedCommandLineOfConfigFile(
path.join(dir, 'scenarios', s, 'tsconfig.json'),
{},
{ ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} },
);
const start = performance.now();
const program = ts.createProgram(
cfg.fileNames,
cfg.options,
host(cfg.options),
);
const diagnostics = ts.getPreEmitDiagnostics(program);
const seconds = (performance.now() - start) / 1000;
const count = program.getInstantiationCount();
if (diagnostics.length) {
failures.push(`${s}: ${diagnostics.length} type errors`);
console.error(
ts.formatDiagnostics(diagnostics.slice(0, 10), {
getCanonicalFileName: f => f,
getCurrentDirectory: () => process.cwd(),
getNewLine: () => '\n',
}),
);
}

const max = budget.instantiations[s];
const change = max ? (count - max) / max : 0;
const pct =
max ? `${change >= 0 ? '+' : ''}${(change * 100).toFixed(1)}%` : 'new';
console.log(`${s}\t${count}\t${max ?? '-'}\t${pct}\t${seconds.toFixed(1)}s`);
if (update) {
budget.instantiations[s] = count;
} else if (!max) {
needsUpdate = true;
failures.push(`${s}: has no budget`);
} else if (change > TOLERANCE) {
needsUpdate = true;
failures.push(
`${s}: ${count} instantiations is ${pct} over its budget of ${max}`,
);
} else if (change < -TOLERANCE) {
lowered.push(s);
}
}

if (update) {
budget.typescript = ts.version;
fs.writeFileSync(budgetFile, JSON.stringify(budget, null, 2) + '\n');
console.log(`\nUpdated ${path.relative(process.cwd(), budgetFile)}`);
} else if (budget.typescript !== ts.version) {
console.log(
`\nbudget.json was recorded with TypeScript ${budget.typescript}; re-record it with --update if the compiler upgrade moved the counts.`,
);
}
if (lowered.length)
console.log(
`\n${lowered.join(', ')} dropped more than ${TOLERANCE * 100}% below budget; run with --update to lock in the win.`,
);
if (failures.length) {
console.error(`\n${failures.join('\n')}`);
if (needsUpdate)
console.error(
'\nIf the change is intended, run `yarn check:typeperf --update` and commit budget.json.',
);
process.exit(1);
}
Loading