Skip to content

Commit 9706f33

Browse files
feat: react mode baseline type checking. (#15)
1 parent ce96325 commit 9706f33

9 files changed

Lines changed: 1148 additions & 99 deletions

File tree

docs/next-steps.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,23 @@ Focused follow-up work for `@knighted/develop`.
1919
- Detect transient CDN/module loading failures and surface a clear recovery action in-app.
2020
- Add a user-triggered retry path (for example, Reload page / Force reload) when runtime bootstrap imports fail.
2121
- Consider an optional automatic one-time retry before showing recovery controls, while avoiding infinite reload loops.
22+
23+
5. **Type reference parsing hardening (TS preprocessor-first)**
24+
- Transition declaration/reference discovery in in-browser type diagnostics to a TypeScript preprocessor-first flow (`ts.preProcessFile`) instead of regex-driven parsing.
25+
- Scope this to the lazy React type environment loader first, then evaluate whether the same parser path should be reused for all type package graph walking.
26+
- Keep current lazy-loading behavior intact: no React type graph fetch until the user switches to React render mode and triggers Typecheck.
27+
- Preserve CDN provider fallback behavior and existing diagnostics UX while changing parser internals.
28+
- Add a strict fallback contract:
29+
- Primary: `preProcessFile` outputs (`importedFiles`, `referencedFiles`, `typeReferenceDirectives`).
30+
- Secondary fallback only when unavailable: current lightweight parsing logic.
31+
- Never treat commented example code as imports/references.
32+
- Add guardrails around known failure classes discovered during development:
33+
- Relative declaration references like `global.d.ts` must resolve as file paths, not package names.
34+
- Extensionless declaration references (for example `./user-context`) must attempt `.d.ts` candidates first.
35+
- Avoid noisy parallel fetch fan-out for bad candidates; use ordered fallback to reduce 404/CORS console noise.
36+
- Add focused test coverage (unit or Playwright) that proves:
37+
- React-mode typecheck does not trigger fake fetches from commented examples in declaration files.
38+
- React-mode typecheck resolves `react` and `react-dom/client` without module-not-found diagnostics.
39+
- DOM mode still avoids React type graph hydration.
40+
- Suggested implementation prompt:
41+
- "Refactor `src/modules/type-diagnostics.js` to make TypeScript preprocessor parsing (`preProcessFile`) the source of truth for declaration graph discovery in the lazy React type loader. Keep current CDN fallback and lazy hydration semantics. Ensure references from comments are ignored, `*.d.ts`/relative path handling is correct, and candidate fetch ordering minimizes noisy failed requests. Add regression coverage for `global.d.ts` and commented `./user-context` examples. Validate with `npm run lint`, `npm run build:esm`, and targeted React/typecheck Playwright runs."

docs/type-checking.md

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# Type Checking In The Browser
2+
3+
This document explains how `@knighted/develop` performs TypeScript diagnostics directly in the browser, including the general flow for all render modes and the React-mode-specific type graph loading path.
4+
5+
## Goals
6+
7+
- Provide on-demand TypeScript diagnostics without a local build step.
8+
- Keep render/preview UX responsive while type checks run.
9+
- Support a generic baseline in DOM mode.
10+
- Support a realistic React typing environment in React mode.
11+
- Preserve CDN fallback behavior so diagnostics can still run when one provider fails.
12+
13+
## High-Level Architecture
14+
15+
Browser type checking is implemented by combining three pieces:
16+
17+
1. TypeScript compiler runtime loaded from CDN.
18+
2. Virtual filesystem assembled in memory from source + declaration files.
19+
3. Custom TypeScript host and module resolution bridge that reads from the virtual filesystem.
20+
21+
At runtime this is managed by `createTypeDiagnosticsController` in `src/modules/type-diagnostics.js` and wired from `src/app.js`.
22+
23+
## Generic Typecheck Flow (All Modes)
24+
25+
When the user clicks Typecheck:
26+
27+
1. Ensure TypeScript compiler runtime is loaded from CDN.
28+
2. Build (or reuse) TypeScript standard library declarations (`lib.*.d.ts`).
29+
3. Read current editor source (`component.tsx`).
30+
4. Build a virtual file map for the current run.
31+
5. Create a TypeScript `Program` with a custom host.
32+
6. Collect diagnostics and display formatted output in the diagnostics UI.
33+
34+
### Compiler Loading
35+
36+
- TypeScript runtime is loaded using `importFromCdnWithFallback`.
37+
- The selected provider is remembered for downstream declaration URL generation.
38+
39+
### Standard Library Hydration
40+
41+
- `getTypeScriptLibUrls(...)` provides provider-prioritized URLs for TS lib declarations.
42+
- Triple-slash `reference lib` and `reference path` directives are followed recursively.
43+
- Loaded files are cached in memory so repeated checks do not re-fetch.
44+
45+
### Program Options (Generic)
46+
47+
- `jsx: Preserve`
48+
- `target: ES2022`
49+
- `module: ESNext`
50+
- `moduleResolution: Bundler` (fallback NodeNext/NodeJs)
51+
- `strict: true`
52+
- `noEmit: true`
53+
- `skipLibCheck: true`
54+
- `types: []` to disable implicit ambient type package scanning in this virtual environment
55+
56+
The explicit `types: []` avoids TypeScript attempting implicit `@types/*` discovery that does not map to a real disk `node_modules` in browser.
57+
58+
## DOM Mode Behavior
59+
60+
DOM mode uses a lightweight ambient JSX definition that is injected into the virtual filesystem as a synthetic declaration file.
61+
62+
This keeps the baseline path minimal and avoids loading React type packages when they are not needed.
63+
64+
## React Mode Behavior: Lazy CDN Type Hydration
65+
66+
React mode enables an additional lazy type graph loader:
67+
68+
- Trigger condition: render mode is `react` and Typecheck is run.
69+
- Root packages: `@types/react` and `@types/react-dom`.
70+
- Transitive dependencies are discovered and loaded on demand.
71+
- Everything is cached after first load.
72+
73+
### CDN Type Package URL Strategy
74+
75+
`getTypePackageFileUrls(...)` generates candidate URLs for type package files with a fallback order that favors raw package CDNs before esm-hosted variants.
76+
77+
Current priority for type package files:
78+
79+
1. jsDelivr
80+
2. unpkg
81+
3. active TypeScript provider (if present)
82+
4. esm.sh
83+
84+
This ordering reduces issues from transformed declaration content.
85+
86+
### Declaration Graph Discovery
87+
88+
For each loaded declaration file:
89+
90+
1. Parse references with `ts.preProcessFile` when available.
91+
2. Fallback to a minimal regex parser only if preprocessor is unavailable.
92+
3. Follow imports/references/type directives recursively.
93+
94+
Guardrails:
95+
96+
- Relative declaration references are treated as paths.
97+
- Extensionless references try `.d.ts` candidates first.
98+
- Absolute URL specifiers are ignored.
99+
- Commented example imports are not treated as real dependencies.
100+
101+
### Candidate File Resolution
102+
103+
When a declaration path is ambiguous, candidates are tried in ordered fallback:
104+
105+
1. `<path>.d.ts`
106+
2. script-extension-normalized `.d.ts`
107+
3. `<path>/index.d.ts`
108+
4. raw `<path>`
109+
110+
This reduces noisy failed requests and improves compatibility with DefinitelyTyped layouts.
111+
112+
## Virtual Filesystem Design
113+
114+
The virtual filesystem is a `Map<string, string>` where keys are normalized virtual paths.
115+
116+
Typical entries include:
117+
118+
- `component.tsx`
119+
- `lib.esnext.full.d.ts` and referenced TS lib files
120+
- `knighted-jsx-runtime.d.ts` (DOM mode only)
121+
- `node_modules/@types/react/...`
122+
- `node_modules/@types/react-dom/...`
123+
- transitive type deps like `node_modules/csstype/...`
124+
125+
The loader maintains:
126+
127+
- loaded file content cache
128+
- package manifest cache
129+
- package entrypoint cache
130+
- in-flight promise dedupe for concurrent requests
131+
132+
## TypeScript Host + Resolver Bridge
133+
134+
A custom host is supplied to TypeScript `createProgram(...)` and reads from the virtual map:
135+
136+
- `fileExists`
137+
- `readFile`
138+
- `directoryExists`
139+
- `getDirectories`
140+
- `getSourceFile`
141+
- `resolveModuleNames`
142+
143+
Resolver strategy:
144+
145+
1. Ask TypeScript `resolveModuleName(...)` first.
146+
2. If unresolved and React type graph is active, resolve via virtual `node_modules` candidates.
147+
148+
This allows TypeScript diagnostics to behave like a project-backed environment while operating purely in browser memory.
149+
150+
## Diagnostics UI Integration
151+
152+
- Typecheck state is surfaced via loading/neutral/ok/error states.
153+
- Results are formatted with line/column when available.
154+
- Existing render status is preserved and adjusted when type errors are present.
155+
- Re-check scheduling is supported when unresolved type errors already exist.
156+
157+
## Known Constraints
158+
159+
- This is intentionally diagnostics-only (`noEmit`).
160+
- Type package compatibility still depends on CDN availability.
161+
- Browser security and CDN headers may surface noisy network failures on provider fallback paths.
162+
- Complex package resolution edge cases may still require targeted guardrails.
163+
164+
## Why This Approach
165+
166+
Compared to a server-side typecheck service, this approach keeps feedback local to the browser session and aligns with the CDN-first architecture of `@knighted/develop`.
167+
168+
Compared to a purely regex-driven declaration walker, TypeScript preprocessor parsing gives a more robust dependency graph with fewer false positives.
169+
170+
## Validation And Regression Coverage
171+
172+
Recent changes are protected with Playwright coverage that checks:
173+
174+
- React-mode Typecheck succeeds.
175+
- Expected `@types/react` loading occurs.
176+
- Malformed type fetch URL patterns do not occur.
177+
178+
Recommended local validation when changing this system:
179+
180+
```bash
181+
npm run lint
182+
npm run build:esm
183+
npm run test:e2e -- --grep "react mode typecheck"
184+
```
185+
186+
## Future Improvements
187+
188+
- Add explicit lazy-loading assertions (no `@types/*` requests before first React-mode Typecheck).
189+
- Expand diagnostics UI with jump-to-line navigation and richer context.
190+
- Consider optional user-configurable extra type roots after baseline stability is proven.

playwright/app.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,72 @@ test('transpiles TypeScript annotations in component source', async ({ page }) =
334334
await expect(page.locator('#preview-host button')).toContainText('typed')
335335
})
336336

337+
test('react mode typecheck loads types without malformed URL fetches', async ({
338+
page,
339+
}) => {
340+
await waitForInitialRender(page)
341+
342+
await ensurePanelToolsVisible(page, 'component')
343+
344+
const typeRequestUrls: string[] = []
345+
page.on('request', request => {
346+
const url = request.url()
347+
if (url.includes('@types/')) {
348+
typeRequestUrls.push(url)
349+
}
350+
})
351+
352+
await page.locator('#render-mode').selectOption('react')
353+
await page.getByRole('button', { name: 'Typecheck' }).click()
354+
355+
await page.locator('#diagnostics-toggle').click()
356+
await expect(page.locator('#diagnostics-component')).toContainText(
357+
'No TypeScript errors found.',
358+
)
359+
360+
const diagnosticsText = await page.locator('#diagnostics-component').innerText()
361+
expect(diagnosticsText).not.toContain("Cannot find type definition file for 'react'")
362+
expect(diagnosticsText).not.toContain(
363+
"Cannot find type definition file for 'react-dom'",
364+
)
365+
366+
expect(typeRequestUrls.some(url => url.includes('@types/react'))).toBeTruthy()
367+
368+
const malformedTypeRequestPatterns = [
369+
'/@types/global.d.ts/package.json',
370+
'/user-context',
371+
'/https:/',
372+
]
373+
374+
for (const pattern of malformedTypeRequestPatterns) {
375+
expect(typeRequestUrls.some(url => url.includes(pattern))).toBeFalsy()
376+
}
377+
})
378+
379+
test('react mode executes default React import without TDZ runtime failure', async ({
380+
page,
381+
}) => {
382+
await waitForInitialRender(page)
383+
384+
await ensurePanelToolsVisible(page, 'component')
385+
386+
await page.getByLabel('ShadowRoot (open)').uncheck()
387+
await page.locator('#render-mode').selectOption('react')
388+
await setComponentEditorSource(
389+
page,
390+
[
391+
"import React from 'react'",
392+
'const App = () => <button>react default import works</button>',
393+
].join('\n'),
394+
)
395+
396+
await expect(page.locator('#status')).toHaveText('Rendered')
397+
await expect(page.locator('#preview-host button')).toContainText(
398+
'react default import works',
399+
)
400+
await expect(page.locator('#preview-host pre')).toHaveCount(0)
401+
})
402+
337403
test('clearing component source reports clear action without error status', async ({
338404
page,
339405
}) => {

src/app.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
cdnImports,
3+
getTypePackageFileUrls,
34
getTypeScriptLibUrls,
45
importFromCdnWithFallback,
56
} from './modules/cdn.js'
@@ -391,7 +392,9 @@ const typeDiagnostics = createTypeDiagnosticsController({
391392
cdnImports,
392393
importFromCdnWithFallback,
393394
getTypeScriptLibUrls,
395+
getTypePackageFileUrls,
394396
getJsxSource: () => getJsxSource(),
397+
getRenderMode: () => renderMode.value,
395398
setTypecheckButtonLoading,
396399
setTypeDiagnosticsDetails,
397400
setStatus,

src/bootstrap.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getPrimaryCdnImportUrls } from './modules/cdn.js'
99
const preloadImportKeys = [
1010
'cssBrowser',
1111
'jsxDom',
12-
'jsxTranspile',
12+
'jsxTransform',
1313
'jsxReact',
1414
'react',
1515
'reactDomClient',

src/modules/cdn.js

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ export const cdnImportSpecs = {
5454
esm: '@knighted/jsx',
5555
jspmGa: 'npm:@knighted/jsx',
5656
},
57-
jsxTranspile: {
58-
importMap: '@knighted/jsx/transpile',
59-
esm: '@knighted/jsx/transpile',
60-
jspmGa: 'npm:@knighted/jsx/transpile',
57+
jsxTransform: {
58+
importMap: '@knighted/jsx/transform',
59+
esm: '@knighted/jsx/transform',
60+
jspmGa: 'npm:@knighted/jsx/transform',
6161
},
6262
jsxReact: {
6363
importMap: '@knighted/jsx/react',
@@ -234,6 +234,16 @@ const typeScriptLibBaseByProvider = {
234234
jsdelivr: `https://cdn.jsdelivr.net/npm/typescript@${typeScriptVersion}/lib`,
235235
}
236236

237+
const typePackageVersionByName = {
238+
'@types/react': '19.2.2',
239+
'@types/react-dom': '19.2.1',
240+
'@types/prop-types': '15.7.15',
241+
'@types/scheduler': '0.26.0',
242+
csstype: '3.1.3',
243+
}
244+
245+
const getTypePackageVersion = packageName => typePackageVersionByName[packageName]
246+
237247
/*
238248
* Keep a reliable fallback order for .d.ts files when the active module provider
239249
* does not host TypeScript lib declarations consistently (e.g. import maps/jspmGa).
@@ -257,6 +267,47 @@ const getTypeScriptLibProviderPriority = typeScriptProvider => {
257267
return [...new Set(ordered)]
258268
}
259269

270+
const typePackageBaseByProvider = {
271+
esm: packageName => {
272+
const version = getTypePackageVersion(packageName)
273+
const versionSegment = version ? `@${version}` : ''
274+
return `https://esm.sh/${packageName}${versionSegment}`
275+
},
276+
unpkg: packageName => {
277+
const version = getTypePackageVersion(packageName)
278+
const versionSegment = version ? `@${version}` : ''
279+
return `https://unpkg.com/${packageName}${versionSegment}`
280+
},
281+
jsdelivr: packageName => {
282+
const version = getTypePackageVersion(packageName)
283+
const versionSegment = version ? `@${version}` : ''
284+
return `https://cdn.jsdelivr.net/npm/${packageName}${versionSegment}`
285+
},
286+
}
287+
288+
export const getTypePackageFileUrls = (
289+
packageName,
290+
fileName,
291+
{ typeScriptProvider } = {},
292+
) => {
293+
const normalizedFileName =
294+
typeof fileName === 'string' && fileName.length > 0 ? fileName : 'package.json'
295+
const typePackageProviderPriority = [
296+
'jsdelivr',
297+
'unpkg',
298+
...(typeof typeScriptProvider === 'string' ? [typeScriptProvider] : []),
299+
'esm',
300+
]
301+
const providerOrderedBases = [...new Set(typePackageProviderPriority)]
302+
.map(provider => {
303+
const createBase = typePackageBaseByProvider[provider]
304+
return typeof createBase === 'function' ? createBase(packageName) : null
305+
})
306+
.filter(Boolean)
307+
308+
return providerOrderedBases.map(baseUrl => `${baseUrl}/${normalizedFileName}`)
309+
}
310+
260311
export const getTypeScriptLibUrls = (fileName, { typeScriptProvider } = {}) => {
261312
const providerOrderedBases = getTypeScriptLibProviderPriority(typeScriptProvider)
262313
.map(provider => typeScriptLibBaseByProvider[provider])

0 commit comments

Comments
 (0)