Skip to content

Commit 4793bee

Browse files
kaneelGuillaume
andauthored
fix(internal): fix broken builds with fresh checkout on both typecheck and api-check (tldraw#8249)
While working on tldraw#8194 I came upon some issues with tldraw/driver, I threw Claude at it until I could actually yarn typecheck and yarn api-check locally and it fixed the issue on my branch. ### Commit 1: "Fix: internal typecheck" This fixes the internal/scripts/typecheck.ts script, which runs tsgo --build across all workspace packages. The problem: On a fresh checkout, tsgo --build can't always resolve the full project-reference graph correctly when all package tsconfigs are passed in a single invocation. The previous workaround was to "bootstrap" packages first, but it was passing them all at once — which still left ordering issues if package A depends on package B but B hadn't been built yet. The fix: It adds a topoSortTsconfigs() function that reads each tsconfig's references field and performs a topological sort (depth-first). Then instead of one bulk tsgo --build ...all-packages, it builds each package tsconfig one at a time in topological order — leaves first, dependents after. This ensures each package's declaration outputs exist before any package that depends on them is built. ### Commit 2: "fix(internal): api-check" This fixes the internal/scripts/api-check.ts script, which verifies that the public API surface of published packages type-checks correctly against their declared dependencies. Two problems fixed: Broken tiptap versions from npm. The script was doing a bare npm install @tiptap/core which pulled the latest version from the registry (3.20.3), which turned out to be a broken release missing its dist/ directory. The fix introduces a getPinnedVersions() function that reads the actual installed version from the workspace's node_modules and installs that exact version (e.g. @tiptap/core@3.13.0). It also adds @tiptap/pm, react, and react-dom as dependencies (previously missing), while removing the old @tiptap/react and @tiptap/core entries from the unpinned list. Module resolution for tiptap subpath exports. Packages like @tiptap/pm use subpath exports (@tiptap/pm/state, @tiptap/pm/model, etc.) which only resolve when TypeScript's moduleResolution is set to "bundler". The generated tsconfig had no explicit module settings, so tsc defaulted to node10 resolution which ignores the exports field entirely. The fix adds module: "esnext" and moduleResolution: "bundler" to the generated tsconfig. ### Change type - [x] `bugfix` - [ ] `improvement` - [ ] `feature` - [x] `api` - [x] `other` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches internal build/typecheck tooling and changes dependency resolution/order; mistakes could break CI or local developer workflows but does not affect runtime product behavior. > > **Overview** > Fixes fresh-checkout failures in internal TypeScript verification scripts. > > `internal/scripts/typecheck.ts` now topologically sorts package `tsconfig.json` files by their `references` and bootstraps `tsgo --build` one package at a time (leaves first) to ensure declarations exist before dependents build. > > `internal/scripts/api-check.ts` updates the generated `tsconfig` to use `module: "esnext"` and `moduleResolution: "bundler"` and pins `@tiptap/*` installs to the workspace-resolved versions (plus adds `react`/`react-dom` deps) to avoid broken upstream releases and subpath export resolution issues during the API typecheck. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d2f78eb. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Guillaume <guillaume@tldraw.com>
1 parent a91c1d1 commit 4793bee

2 files changed

Lines changed: 68 additions & 6 deletions

File tree

internal/scripts/api-check.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { writeFileSync } from 'fs'
1+
import { readFileSync, writeFileSync } from 'fs'
22
import { join, resolve } from 'path'
33
import { exec } from './lib/exec'
44
import { readFileIfExists } from './lib/file'
@@ -14,17 +14,31 @@ const packagesOurTypesCanDependOn = [
1414
'@types/react-dom',
1515
'eventemitter3',
1616
'nanoevents',
17-
'@tiptap/react',
18-
'@tiptap/core',
17+
'react',
18+
'react-dom',
1919
]
2020

21+
// These packages use subpath exports which require moduleResolution: "bundler".
22+
// We pin to the workspace's resolved versions to avoid installing broken releases.
23+
const pinnedPackages = ['@tiptap/core', '@tiptap/react', '@tiptap/pm']
24+
25+
function getPinnedVersions(): string[] {
26+
return pinnedPackages.map((pkg) => {
27+
const pkgJsonPath = resolve(`./node_modules/${pkg}/package.json`)
28+
const { version } = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
29+
return `${pkg}@${version}`
30+
})
31+
}
32+
2133
main()
2234

2335
async function main() {
2436
const tsconfig: any = {
2537
compilerOptions: {
2638
lib: ['esnext', 'dom'],
2739
strict: true,
40+
module: 'esnext',
41+
moduleResolution: 'bundler',
2842
rootDir: '.',
2943
paths: {},
3044
esModuleInterop: true,
@@ -61,7 +75,9 @@ async function main() {
6175
writeFileSync(`${tempDir}/tsconfig.json`, JSON.stringify(tsconfig, null, '\t'), 'utf8')
6276
writeFileSync(`${tempDir}/package.json`, JSON.stringify({ dependencies: {} }, null, '\t'), 'utf8')
6377

64-
await exec('npm', ['install', ...packagesOurTypesCanDependOn], { pwd: tempDir })
78+
await exec('npm', ['install', ...packagesOurTypesCanDependOn, ...getPinnedVersions()], {
79+
pwd: tempDir,
80+
})
6581
await exec(resolve('./node_modules/.bin/tsc'), [], { pwd: tempDir })
6682

6783
await exec('rm', ['-rf', tempDir])

internal/scripts/typecheck.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,19 @@ async function main() {
2727

2828
// tsgo currently struggles to resolve some workspace package imports on a fresh checkout
2929
// when all projects are passed in a single --build invocation. Building package
30-
// workspaces first produces the declaration outputs needed for the full graph.
30+
// workspaces first in topological order (leaves before dependents) produces the
31+
// declaration outputs needed for the full graph.
3132
const packageTsconfigFiles = tsconfigFiles.filter((file) =>
3233
file.includes(`${path.sep}packages${path.sep}`)
3334
)
3435
if (packageTsconfigFiles.length > 0) {
36+
const sortedPackageTsconfigFiles = await topoSortTsconfigs(packageTsconfigFiles)
3537
const bootstrapArgs = ['--build']
3638
if (process.argv.includes('--force')) bootstrapArgs.push('--force')
3739
nicelog('Bootstrapping tsgo package references')
38-
execFileSync(compilerPath, [...bootstrapArgs, ...packageTsconfigFiles], { stdio: 'inherit' })
40+
for (const tsconfigFile of sortedPackageTsconfigFiles) {
41+
execFileSync(compilerPath, [...bootstrapArgs, tsconfigFile], { stdio: 'inherit' })
42+
}
3943
}
4044

4145
// In watch mode, use execFileSync with inherited stdio - it handles everything
@@ -103,6 +107,48 @@ async function main() {
103107
})
104108
}
105109

110+
/**
111+
* Sort tsconfig files in topological order based on their `references` fields,
112+
* so that leaf packages (no dependencies) come first and dependents come after
113+
* all their dependencies. This is needed because tsgo --build doesn't always
114+
* resolve the reference graph correctly on a fresh checkout.
115+
*/
116+
async function topoSortTsconfigs(tsconfigFiles: string[]): Promise<string[]> {
117+
const dirToFile = new Map<string, string>()
118+
const deps = new Map<string, string[]>()
119+
120+
for (const file of tsconfigFiles) {
121+
const dir = path.dirname(file)
122+
dirToFile.set(dir, file)
123+
const tsconfig = await readJsonIfExists(file)
124+
const refs: string[] = []
125+
if (tsconfig?.references) {
126+
for (const ref of tsconfig.references) {
127+
refs.push(path.resolve(dir, ref.path))
128+
}
129+
}
130+
deps.set(dir, refs)
131+
}
132+
133+
const visited = new Set<string>()
134+
const sorted: string[] = []
135+
136+
function visit(dir: string) {
137+
if (visited.has(dir)) return
138+
visited.add(dir)
139+
for (const dep of deps.get(dir) ?? []) {
140+
if (dirToFile.has(dep)) visit(dep)
141+
}
142+
sorted.push(dirToFile.get(dir)!)
143+
}
144+
145+
for (const dir of dirToFile.keys()) {
146+
visit(dir)
147+
}
148+
149+
return sorted
150+
}
151+
106152
main().catch((err) => {
107153
console.error('Unexpected error:', err)
108154
process.exit(1)

0 commit comments

Comments
 (0)