diff --git a/.changeset/react-dist-hotserve.md b/.changeset/react-dist-hotserve.md new file mode 100644 index 000000000..235fa838f --- /dev/null +++ b/.changeset/react-dist-hotserve.md @@ -0,0 +1,7 @@ +--- +'@conciv/extension-compiler': patch +--- + +The vite dev hot-serve remaps a workspace `dist` entry to its `src` sibling and Solid-compiles the TSX it finds there. That remap used to key off a `react/` folder-name convention, which turned `@conciv/mascot/react` into Solid output inside a React host and crashed server rendering with `Comp is not a function`. The decision is now derived from the found source file's nearest `tsconfig.json` (following its `extends` chain): a subtree whose effective `compilerOptions.jsxImportSource` is set to something other than `solid-js`, or whose `compilerOptions.jsx` is `react-jsx`/`react-jsxdev` without a `solid-js` `jsxImportSource`, is classified non-Solid and stays on `dist`. Everything else — an explicit `jsxImportSource: "solid-js"`, or no JSX config in the chain at all (pure-TS subtrees) — keeps the existing remap-to-`src` behavior. + +`@conciv/mascot`'s React wrapper subtree carried its JSX config in a sibling file (`tsconfig.react.json` at the package root) rather than in a tsconfig local to `src/react/`, which the nearest-tsconfig directory walk can't discover. It has been relocated to `src/react/tsconfig.json` so the declaration lives with the code it governs; `tsdown.react.config.ts` and the package's `typecheck` script now point at the new path. diff --git a/packages/extension-compiler/src/conciv-src.ts b/packages/extension-compiler/src/conciv-src.ts index bd602fda6..15801eb71 100644 --- a/packages/extension-compiler/src/conciv-src.ts +++ b/packages/extension-compiler/src/conciv-src.ts @@ -27,6 +27,80 @@ function packageNameFor(dir: string): string | null { const isConcivName = (name: string) => name.startsWith('@conciv/') +type JsxConfig = { + jsx: string | null + jsxImportSource: string | null +} + +type RawTsconfig = { + jsx: unknown + jsxImportSource: unknown + extends: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseTsconfig(path: string): RawTsconfig | null { + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (!isRecord(parsed)) return null + const compilerOptions = isRecord(parsed.compilerOptions) ? parsed.compilerOptions : {} + return {jsx: compilerOptions.jsx, jsxImportSource: compilerOptions.jsxImportSource, extends: parsed.extends} + } catch { + return null + } +} + +function resolveExtendsPath(fromPath: string, extendsValue: string): string | null { + if (!extendsValue.startsWith('.')) return null + const joined = join(dirname(fromPath), extendsValue) + return joined.endsWith('.json') ? joined : `${joined}.json` +} + +function resolveJsxConfig(path: string, visited: Set): JsxConfig { + if (visited.has(path)) return {jsx: null, jsxImportSource: null} + visited.add(path) + const raw = parseTsconfig(path) + if (raw === null) return {jsx: null, jsxImportSource: null} + const ownJsx = typeof raw.jsx === 'string' ? raw.jsx : null + const ownJsxImportSource = typeof raw.jsxImportSource === 'string' ? raw.jsxImportSource : null + if (typeof raw.extends !== 'string') return {jsx: ownJsx, jsxImportSource: ownJsxImportSource} + const extendsPath = resolveExtendsPath(path, raw.extends) + if (extendsPath === null) return {jsx: ownJsx, jsxImportSource: ownJsxImportSource} + const parentConfig = resolveJsxConfig(extendsPath, visited) + return { + jsx: ownJsx ?? parentConfig.jsx, + jsxImportSource: ownJsxImportSource ?? parentConfig.jsxImportSource, + } +} + +const tsconfigCache = new Map() + +function jsxConfigFor(dir: string): JsxConfig | null { + const cached = tsconfigCache.get(dir) + if (cached !== undefined) return cached + const ownTsconfig = join(dir, 'tsconfig.json') + const parent = dirname(dir) + const resolved = existsSync(ownTsconfig) + ? resolveJsxConfig(ownTsconfig, new Set()) + : parent === dir + ? null + : jsxConfigFor(parent) + tsconfigCache.set(dir, resolved) + return resolved +} + +function isNonSolidJsx(config: JsxConfig | null): boolean { + if (config === null) return false + if (config.jsxImportSource !== null && config.jsxImportSource !== 'solid-js') return true + if ((config.jsx === 'react-jsx' || config.jsx === 'react-jsxdev') && config.jsxImportSource !== 'solid-js') { + return true + } + return false +} + export function concivSrcEntry(resolvedPath: string): string | null { if (resolvedPath.includes('node_modules')) return null const extension = ['.jsx', '.js'].find((candidate) => resolvedPath.endsWith(candidate)) @@ -35,7 +109,10 @@ export function concivSrcEntry(resolvedPath: string): string | null { if (marker === -1) return null const stem = resolvedPath.slice(marker + '/dist/'.length, -extension.length) const srcStem = `${resolvedPath.slice(0, marker)}/src/${stem}` - return [`${srcStem}.tsx`, `${srcStem}.ts`].find((candidate) => existsSync(candidate)) ?? null + const srcCandidate = [`${srcStem}.tsx`, `${srcStem}.ts`].find((candidate) => existsSync(candidate)) ?? null + if (srcCandidate === null) return null + if (isNonSolidJsx(jsxConfigFor(dirname(srcCandidate)))) return null + return srcCandidate } export function isConcivSrcTsx(id: string): boolean { diff --git a/packages/extension-compiler/test/conciv-src.it.test.ts b/packages/extension-compiler/test/conciv-src.it.test.ts index 0ff6f0944..c3578e98b 100644 --- a/packages/extension-compiler/test/conciv-src.it.test.ts +++ b/packages/extension-compiler/test/conciv-src.it.test.ts @@ -45,6 +45,30 @@ describe('concivSrcEntry', () => { expect(concivSrcEntry(fixture('scoped/dist/solid/index.jsx'))).toBe(fixture('scoped/src/solid/index.ts')) }) + it('keeps a react-jsx subtree on dist even though a ts source sibling exists, regardless of folder name', () => { + expect(concivSrcEntry(fixture('scoped/dist/wrapper/index.js'))).toBeNull() + }) + + it('keeps a react-jsx subtree on dist even though a tsx source sibling exists, regardless of folder name', () => { + expect(concivSrcEntry(fixture('scoped/dist/wrapper/mascot-root.js'))).toBeNull() + }) + + it('remaps the package root to src even when a sibling subtree carries its own react-jsx tsconfig', () => { + expect(concivSrcEntry(fixture('scoped/dist/index.js'))).toBe(fixture('scoped/src/index.tsx')) + }) + + it('keeps a dist entry on dist when its own tsconfig sets an explicit non-solid jsxImportSource', () => { + expect(concivSrcEntry(fixture('scoped/dist/explicit-react/index.js'))).toBeNull() + }) + + it('remaps a dist entry with no tsconfig of its own and no jsx anywhere in the chain (ts-only package)', () => { + expect(concivSrcEntry(fixture('plain/dist/index.js'))).toBe(fixture('plain/src/index.ts')) + }) + + it('remaps a dist entry whose own tsconfig is empty and inherits jsxImportSource solid-js via extends', () => { + expect(concivSrcEntry(fixture('scoped/dist/inherited/index.js'))).toBe(fixture('scoped/src/inherited/index.tsx')) + }) + it('returns null when no source sibling exists', () => { expect(concivSrcEntry(fixture('scoped/dist/nope.js'))).toBeNull() }) diff --git a/packages/extension-compiler/test/fixtures/conciv-src/plain/dist/index.js b/packages/extension-compiler/test/fixtures/conciv-src/plain/dist/index.js new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/plain/dist/index.js @@ -0,0 +1 @@ +export {} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/plain/package.json b/packages/extension-compiler/test/fixtures/conciv-src/plain/package.json new file mode 100644 index 000000000..ba48380fd --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/plain/package.json @@ -0,0 +1,4 @@ +{ + "name": "@conciv/fixture-plain", + "type": "module" +} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/plain/src/index.ts b/packages/extension-compiler/test/fixtures/conciv-src/plain/src/index.ts new file mode 100644 index 000000000..28b31c7e1 --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/plain/src/index.ts @@ -0,0 +1 @@ +export const plain = {} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/explicit-react/index.js b/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/explicit-react/index.js new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/explicit-react/index.js @@ -0,0 +1 @@ +export {} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/inherited/index.js b/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/inherited/index.js new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/inherited/index.js @@ -0,0 +1 @@ +export {} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/wrapper/index.js b/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/wrapper/index.js new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/wrapper/index.js @@ -0,0 +1 @@ +export {} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/wrapper/mascot-root.js b/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/wrapper/mascot-root.js new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/dist/wrapper/mascot-root.js @@ -0,0 +1 @@ +export {} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/explicit-react/index.tsx b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/explicit-react/index.tsx new file mode 100644 index 000000000..46ba2b4c8 --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/explicit-react/index.tsx @@ -0,0 +1 @@ +export const ExplicitReact = () => null diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/explicit-react/tsconfig.json b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/explicit-react/tsconfig.json new file mode 100644 index 000000000..4f9190dd9 --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/explicit-react/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "jsxImportSource": "react" + } +} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/inherited/index.tsx b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/inherited/index.tsx new file mode 100644 index 000000000..53e9efd9a --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/inherited/index.tsx @@ -0,0 +1 @@ +export const Inherited = () =>
ok
diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/inherited/tsconfig.json b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/inherited/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/inherited/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/wrapper/index.ts b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/wrapper/index.ts new file mode 100644 index 000000000..5903dc5ea --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/wrapper/index.ts @@ -0,0 +1 @@ +export const Mascot = () => null diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/wrapper/mascot-root.tsx b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/wrapper/mascot-root.tsx new file mode 100644 index 000000000..310bde074 --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/wrapper/mascot-root.tsx @@ -0,0 +1 @@ +export const MascotRoot = () => null diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/wrapper/tsconfig.json b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/wrapper/tsconfig.json new file mode 100644 index 000000000..a224293f4 --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/src/wrapper/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/packages/extension-compiler/test/fixtures/conciv-src/scoped/tsconfig.json b/packages/extension-compiler/test/fixtures/conciv-src/scoped/tsconfig.json new file mode 100644 index 000000000..f33507ab2 --- /dev/null +++ b/packages/extension-compiler/test/fixtures/conciv-src/scoped/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js" + } +} diff --git a/packages/mascot/package.json b/packages/mascot/package.json index b1e2b05e9..3e19b61a5 100644 --- a/packages/mascot/package.json +++ b/packages/mascot/package.json @@ -105,7 +105,7 @@ }, "scripts": { "build": "tsdown && tsdown --config tsdown.solid-source.config.ts && tsdown --config tsdown.react.config.ts", - "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.react.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p src/react/tsconfig.json --noEmit", "lint": "oxlint", "test": "vitest run --passWithNoTests && playwright test", "publint": "publint", diff --git a/packages/mascot/src/react/tsconfig.json b/packages/mascot/src/react/tsconfig.json new file mode 100644 index 000000000..b57d3c459 --- /dev/null +++ b/packages/mascot/src/react/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "../..", + "noEmit": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.tsx", "../../tests/browser/react-wrapper.browser.test.tsx"] +} diff --git a/packages/mascot/tsconfig.react.json b/packages/mascot/tsconfig.react.json deleted file mode 100644 index 007692810..000000000 --- a/packages/mascot/tsconfig.react.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": ".", - "noEmit": true, - "lib": ["ES2024", "DOM", "DOM.Iterable"], - "jsx": "react-jsx", - "types": ["node"] - }, - "include": ["src/react/**/*.ts", "src/react/**/*.tsx", "tests/browser/react-wrapper.browser.test.tsx"] -} diff --git a/packages/mascot/tsdown.react.config.ts b/packages/mascot/tsdown.react.config.ts index 585c68d58..e93b7bd6e 100644 --- a/packages/mascot/tsdown.react.config.ts +++ b/packages/mascot/tsdown.react.config.ts @@ -8,6 +8,6 @@ export default defineConfig({ unbundle: true, dts: true, clean: false, - tsconfig: 'tsconfig.react.json', + tsconfig: 'src/react/tsconfig.json', external: ['react', /^react\//, /^react-dom/, /^\.\.\//], })