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
20 changes: 20 additions & 0 deletions docs/next-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,23 @@ Focused follow-up work for `@knighted/develop`.
- Detect transient CDN/module loading failures and surface a clear recovery action in-app.
- Add a user-triggered retry path (for example, Reload page / Force reload) when runtime bootstrap imports fail.
- Consider an optional automatic one-time retry before showing recovery controls, while avoiding infinite reload loops.

5. **Type reference parsing hardening (TS preprocessor-first)**
- Transition declaration/reference discovery in in-browser type diagnostics to a TypeScript preprocessor-first flow (`ts.preProcessFile`) instead of regex-driven parsing.
- 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.
- Keep current lazy-loading behavior intact: no React type graph fetch until the user switches to React render mode and triggers Typecheck.
- Preserve CDN provider fallback behavior and existing diagnostics UX while changing parser internals.
- Add a strict fallback contract:
- Primary: `preProcessFile` outputs (`importedFiles`, `referencedFiles`, `typeReferenceDirectives`).
- Secondary fallback only when unavailable: current lightweight parsing logic.
- Never treat commented example code as imports/references.
- Add guardrails around known failure classes discovered during development:
- Relative declaration references like `global.d.ts` must resolve as file paths, not package names.
- Extensionless declaration references (for example `./user-context`) must attempt `.d.ts` candidates first.
- Avoid noisy parallel fetch fan-out for bad candidates; use ordered fallback to reduce 404/CORS console noise.
- Add focused test coverage (unit or Playwright) that proves:
- React-mode typecheck does not trigger fake fetches from commented examples in declaration files.
- React-mode typecheck resolves `react` and `react-dom/client` without module-not-found diagnostics.
- DOM mode still avoids React type graph hydration.
- Suggested implementation prompt:
- "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."
190 changes: 190 additions & 0 deletions docs/type-checking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Type Checking In The Browser

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.

## Goals

- Provide on-demand TypeScript diagnostics without a local build step.
- Keep render/preview UX responsive while type checks run.
- Support a generic baseline in DOM mode.
- Support a realistic React typing environment in React mode.
- Preserve CDN fallback behavior so diagnostics can still run when one provider fails.

## High-Level Architecture

Browser type checking is implemented by combining three pieces:

1. TypeScript compiler runtime loaded from CDN.
2. Virtual filesystem assembled in memory from source + declaration files.
3. Custom TypeScript host and module resolution bridge that reads from the virtual filesystem.

At runtime this is managed by `createTypeDiagnosticsController` in `src/modules/type-diagnostics.js` and wired from `src/app.js`.

## Generic Typecheck Flow (All Modes)

When the user clicks Typecheck:

1. Ensure TypeScript compiler runtime is loaded from CDN.
2. Build (or reuse) TypeScript standard library declarations (`lib.*.d.ts`).
3. Read current editor source (`component.tsx`).
4. Build a virtual file map for the current run.
5. Create a TypeScript `Program` with a custom host.
6. Collect diagnostics and display formatted output in the diagnostics UI.

### Compiler Loading

- TypeScript runtime is loaded using `importFromCdnWithFallback`.
- The selected provider is remembered for downstream declaration URL generation.

### Standard Library Hydration

- `getTypeScriptLibUrls(...)` provides provider-prioritized URLs for TS lib declarations.
- Triple-slash `reference lib` and `reference path` directives are followed recursively.
- Loaded files are cached in memory so repeated checks do not re-fetch.

### Program Options (Generic)

- `jsx: Preserve`
- `target: ES2022`
- `module: ESNext`
- `moduleResolution: Bundler` (fallback NodeNext/NodeJs)
- `strict: true`
- `noEmit: true`
- `skipLibCheck: true`
- `types: []` to disable implicit ambient type package scanning in this virtual environment

The explicit `types: []` avoids TypeScript attempting implicit `@types/*` discovery that does not map to a real disk `node_modules` in browser.

## DOM Mode Behavior

DOM mode uses a lightweight ambient JSX definition that is injected into the virtual filesystem as a synthetic declaration file.

This keeps the baseline path minimal and avoids loading React type packages when they are not needed.

## React Mode Behavior: Lazy CDN Type Hydration

React mode enables an additional lazy type graph loader:

- Trigger condition: render mode is `react` and Typecheck is run.
- Root packages: `@types/react` and `@types/react-dom`.
- Transitive dependencies are discovered and loaded on demand.
- Everything is cached after first load.

### CDN Type Package URL Strategy

`getTypePackageFileUrls(...)` generates candidate URLs for type package files with a fallback order that favors raw package CDNs before esm-hosted variants.

Current priority for type package files:

1. jsDelivr
2. unpkg
3. active TypeScript provider (if present)
4. esm.sh

This ordering reduces issues from transformed declaration content.

### Declaration Graph Discovery

For each loaded declaration file:

1. Parse references with `ts.preProcessFile` when available.
2. Fallback to a minimal regex parser only if preprocessor is unavailable.
3. Follow imports/references/type directives recursively.

Guardrails:

- Relative declaration references are treated as paths.
- Extensionless references try `.d.ts` candidates first.
- Absolute URL specifiers are ignored.
- Commented example imports are not treated as real dependencies.

### Candidate File Resolution

When a declaration path is ambiguous, candidates are tried in ordered fallback:

1. `<path>.d.ts`
2. script-extension-normalized `.d.ts`
3. `<path>/index.d.ts`
4. raw `<path>`

This reduces noisy failed requests and improves compatibility with DefinitelyTyped layouts.

## Virtual Filesystem Design

The virtual filesystem is a `Map<string, string>` where keys are normalized virtual paths.

Typical entries include:

- `component.tsx`
- `lib.esnext.full.d.ts` and referenced TS lib files
- `knighted-jsx-runtime.d.ts` (DOM mode only)
- `node_modules/@types/react/...`
- `node_modules/@types/react-dom/...`
- transitive type deps like `node_modules/csstype/...`

The loader maintains:

- loaded file content cache
- package manifest cache
- package entrypoint cache
- in-flight promise dedupe for concurrent requests

## TypeScript Host + Resolver Bridge

A custom host is supplied to TypeScript `createProgram(...)` and reads from the virtual map:

- `fileExists`
- `readFile`
- `directoryExists`
- `getDirectories`
- `getSourceFile`
- `resolveModuleNames`

Resolver strategy:

1. Ask TypeScript `resolveModuleName(...)` first.
2. If unresolved and React type graph is active, resolve via virtual `node_modules` candidates.

This allows TypeScript diagnostics to behave like a project-backed environment while operating purely in browser memory.

## Diagnostics UI Integration

- Typecheck state is surfaced via loading/neutral/ok/error states.
- Results are formatted with line/column when available.
- Existing render status is preserved and adjusted when type errors are present.
- Re-check scheduling is supported when unresolved type errors already exist.

## Known Constraints

- This is intentionally diagnostics-only (`noEmit`).
- Type package compatibility still depends on CDN availability.
- Browser security and CDN headers may surface noisy network failures on provider fallback paths.
- Complex package resolution edge cases may still require targeted guardrails.

## Why This Approach

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

Compared to a purely regex-driven declaration walker, TypeScript preprocessor parsing gives a more robust dependency graph with fewer false positives.

## Validation And Regression Coverage

Recent changes are protected with Playwright coverage that checks:

- React-mode Typecheck succeeds.
- Expected `@types/react` loading occurs.
- Malformed type fetch URL patterns do not occur.

Recommended local validation when changing this system:

```bash
npm run lint
npm run build:esm
npm run test:e2e -- --grep "react mode typecheck"
```

## Future Improvements

- Add explicit lazy-loading assertions (no `@types/*` requests before first React-mode Typecheck).
- Expand diagnostics UI with jump-to-line navigation and richer context.
- Consider optional user-configurable extra type roots after baseline stability is proven.
66 changes: 66 additions & 0 deletions playwright/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,72 @@ test('transpiles TypeScript annotations in component source', async ({ page }) =
await expect(page.locator('#preview-host button')).toContainText('typed')
})

test('react mode typecheck loads types without malformed URL fetches', async ({
page,
}) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'component')

const typeRequestUrls: string[] = []
page.on('request', request => {
const url = request.url()
if (url.includes('@types/')) {
typeRequestUrls.push(url)
}
})

await page.locator('#render-mode').selectOption('react')
await page.getByRole('button', { name: 'Typecheck' }).click()

await page.locator('#diagnostics-toggle').click()
await expect(page.locator('#diagnostics-component')).toContainText(
'No TypeScript errors found.',
)

const diagnosticsText = await page.locator('#diagnostics-component').innerText()
expect(diagnosticsText).not.toContain("Cannot find type definition file for 'react'")
expect(diagnosticsText).not.toContain(
"Cannot find type definition file for 'react-dom'",
)

expect(typeRequestUrls.some(url => url.includes('@types/react'))).toBeTruthy()

const malformedTypeRequestPatterns = [
'/@types/global.d.ts/package.json',
'/user-context',
'/https:/',
]

for (const pattern of malformedTypeRequestPatterns) {
expect(typeRequestUrls.some(url => url.includes(pattern))).toBeFalsy()
}
})

test('react mode executes default React import without TDZ runtime failure', async ({
page,
}) => {
await waitForInitialRender(page)

await ensurePanelToolsVisible(page, 'component')

await page.getByLabel('ShadowRoot (open)').uncheck()
await page.locator('#render-mode').selectOption('react')
await setComponentEditorSource(
page,
[
"import React from 'react'",
'const App = () => <button>react default import works</button>',
].join('\n'),
)

await expect(page.locator('#status')).toHaveText('Rendered')
await expect(page.locator('#preview-host button')).toContainText(
'react default import works',
)
await expect(page.locator('#preview-host pre')).toHaveCount(0)
})

test('clearing component source reports clear action without error status', async ({
page,
}) => {
Expand Down
3 changes: 3 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
cdnImports,
getTypePackageFileUrls,
getTypeScriptLibUrls,
importFromCdnWithFallback,
} from './modules/cdn.js'
Expand Down Expand Up @@ -391,7 +392,9 @@ const typeDiagnostics = createTypeDiagnosticsController({
cdnImports,
importFromCdnWithFallback,
getTypeScriptLibUrls,
getTypePackageFileUrls,
getJsxSource: () => getJsxSource(),
getRenderMode: () => renderMode.value,
setTypecheckButtonLoading,
setTypeDiagnosticsDetails,
setStatus,
Expand Down
2 changes: 1 addition & 1 deletion src/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getPrimaryCdnImportUrls } from './modules/cdn.js'
const preloadImportKeys = [
'cssBrowser',
'jsxDom',
'jsxTranspile',
'jsxTransform',
'jsxReact',
'react',
'reactDomClient',
Expand Down
59 changes: 55 additions & 4 deletions src/modules/cdn.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ export const cdnImportSpecs = {
esm: '@knighted/jsx',
jspmGa: 'npm:@knighted/jsx',
},
jsxTranspile: {
importMap: '@knighted/jsx/transpile',
esm: '@knighted/jsx/transpile',
jspmGa: 'npm:@knighted/jsx/transpile',
jsxTransform: {
importMap: '@knighted/jsx/transform',
esm: '@knighted/jsx/transform',
jspmGa: 'npm:@knighted/jsx/transform',
},
jsxReact: {
importMap: '@knighted/jsx/react',
Expand Down Expand Up @@ -234,6 +234,16 @@ const typeScriptLibBaseByProvider = {
jsdelivr: `https://cdn.jsdelivr.net/npm/typescript@${typeScriptVersion}/lib`,
}

const typePackageVersionByName = {
'@types/react': '19.2.2',
'@types/react-dom': '19.2.1',
'@types/prop-types': '15.7.15',
'@types/scheduler': '0.26.0',
csstype: '3.1.3',
}

const getTypePackageVersion = packageName => typePackageVersionByName[packageName]

/*
* Keep a reliable fallback order for .d.ts files when the active module provider
* does not host TypeScript lib declarations consistently (e.g. import maps/jspmGa).
Expand All @@ -257,6 +267,47 @@ const getTypeScriptLibProviderPriority = typeScriptProvider => {
return [...new Set(ordered)]
}

const typePackageBaseByProvider = {
esm: packageName => {
const version = getTypePackageVersion(packageName)
const versionSegment = version ? `@${version}` : ''
return `https://esm.sh/${packageName}${versionSegment}`
},
unpkg: packageName => {
const version = getTypePackageVersion(packageName)
const versionSegment = version ? `@${version}` : ''
return `https://unpkg.com/${packageName}${versionSegment}`
},
jsdelivr: packageName => {
const version = getTypePackageVersion(packageName)
const versionSegment = version ? `@${version}` : ''
return `https://cdn.jsdelivr.net/npm/${packageName}${versionSegment}`
},
}

export const getTypePackageFileUrls = (
packageName,
fileName,
{ typeScriptProvider } = {},
) => {
const normalizedFileName =
typeof fileName === 'string' && fileName.length > 0 ? fileName : 'package.json'
const typePackageProviderPriority = [
'jsdelivr',
'unpkg',
...(typeof typeScriptProvider === 'string' ? [typeScriptProvider] : []),
'esm',
]
const providerOrderedBases = [...new Set(typePackageProviderPriority)]
.map(provider => {
const createBase = typePackageBaseByProvider[provider]
return typeof createBase === 'function' ? createBase(packageName) : null
})
.filter(Boolean)

return providerOrderedBases.map(baseUrl => `${baseUrl}/${normalizedFileName}`)
}

export const getTypeScriptLibUrls = (fileName, { typeScriptProvider } = {}) => {
const providerOrderedBases = getTypeScriptLibProviderPriority(typeScriptProvider)
.map(provider => typeScriptLibBaseByProvider[provider])
Expand Down
Loading