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
5 changes: 5 additions & 0 deletions .changeset/fix-generated-client-types-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tinacms/cli": patch
---

Fix the generated `client.ts` / `databaseClient.ts` `./types` import so it satisfies both TypeScript strict mode and Node native ESM. The generated import is now `import { queries } from "./types.js"` unconditionally, and the CLI emits a co-resident `types.js` alongside `types.ts` for TypeScript projects. Modern TS module resolution (`bundler` / `node16` / `nodenext`) rewrites the `.js` import back to `types.ts` at compile time, so type checking still sees the `.ts` source and `allowImportingTsExtensions` is not required, while Node ESM consumers resolve the on-disk `.js` file at runtime.
5 changes: 5 additions & 0 deletions .changeset/fresh-tomatoes-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tinacms/graphql": patch
---

fix(graphql): preserve absolute external URLs in image-type resolvers
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { queries } from './types.js';

export const client = {
queries: queries({ request: (q: string) => null }),
};
export default client;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["**/*.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["**/*.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const queries = (_client: { request: (q: string) => unknown }) => ({
hello: (): string => 'hello',
});
108 changes: 108 additions & 0 deletions packages/@tinacms/cli/src/next/codegen/consumer-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Consumer-side regression test for the generated `./types.js` import in
// `client.ts` / `databaseClient.ts`. The unit tests in index.test.ts only
// string-match the generator output; this suite actually runs the two
// real consumers (the TypeScript compiler and Node native ESM) against a
// fixture that mirrors what the generator emits.
//
// This is the test #6739 needed: it spawns `tsc --noEmit` over a fixture
// with a Next.js-style tsconfig (moduleResolution: bundler, no
// allowImportingTsExtensions) and asserts the import resolves cleanly.
// It also esbuild-transforms the fixture to JS and asserts Node ESM can
// load it — the failure mode that originally produced #6062.

import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { transform } from 'esbuild';

const FIXTURE_DIR = path.join(__dirname, '__fixtures__', 'generated-consumer');

function runTsc(tsconfigFile: string) {
const tscPath = require.resolve('typescript/bin/tsc');
return spawnSync(
process.execPath,
[tscPath, '--noEmit', '-p', path.join(FIXTURE_DIR, tsconfigFile)],
{ encoding: 'utf-8' }
);
}

describe('generated client consumer resolution', () => {
// The issue table lists three import-string forms that real-world consumers
// require: `./types` (TS default), `./types.ts` (allowImportingTsExtensions),
// and `./types.js` (Node ESM). The CLI emits the `.js` form unconditionally;
// these tests cover the two TypeScript moduleResolution modes our users hit
// (`bundler` and legacy `node`) and the Node ESM runtime.
it('tsc resolves "./types.js" under moduleResolution: bundler (Next.js 13.2+ default)', () => {
const result = runTsc('tsconfig.json');
if (result.status !== 0) {
// eslint-disable-next-line no-console
console.error('tsc stdout:', result.stdout);
// eslint-disable-next-line no-console
console.error('tsc stderr:', result.stderr);
}
expect(result.status).toBe(0);
});

it('tsc resolves "./types.js" under moduleResolution: node (legacy) with strict on', () => {
// Pins the coverage gap the user surfaced: even classic node resolution
// (no `.js` → `.ts` rewrite) must be satisfied. It is, because we ship a
// co-resident `types.ts` next to `types.js` — TypeScript 4.7+ finds the
// sibling .ts source for the .js import. If a future change ever drops
// `types.ts` from the TS branch, strict-mode users on legacy resolution
// would regress; this test catches that.
const result = runTsc('tsconfig.node.json');
if (result.status !== 0) {
// eslint-disable-next-line no-console
console.error('tsc stdout:', result.stdout);
// eslint-disable-next-line no-console
console.error('tsc stderr:', result.stderr);
}
expect(result.status).toBe(0);
});

it('node native ESM resolves the generated client when types.js is co-resident on disk', async () => {
// Mirrors what the CLI now produces for TS projects: types.ts plus a
// co-resident types.js. We only need the .js files for the runtime
// check, so transform the TS sources straight into the temp dir.
const tmp = mkdtempSync(path.join(tmpdir(), 'tina-types-js-'));
try {
const clientTs = readFileSync(
path.join(FIXTURE_DIR, 'client.ts'),
'utf-8'
);
const typesTs = readFileSync(path.join(FIXTURE_DIR, 'types.ts'), 'utf-8');

const clientJs = await transform(clientTs, { loader: 'ts' });
const typesJs = await transform(typesTs, { loader: 'ts' });

writeFileSync(path.join(tmp, 'client.js'), clientJs.code);
writeFileSync(path.join(tmp, 'types.js'), typesJs.code);
// Force ESM semantics for the .js files in this directory.
writeFileSync(
path.join(tmp, 'package.json'),
JSON.stringify({ type: 'module' })
);
writeFileSync(
path.join(tmp, 'entry.mjs'),
"import('./client.js').then(() => process.exit(0)).catch(err => { console.error(err); process.exit(1); });\n"
);

const result = spawnSync(
process.execPath,
[path.join(tmp, 'entry.mjs')],
{ encoding: 'utf-8' }
);

if (result.status !== 0) {
// eslint-disable-next-line no-console
console.error('node stdout:', result.stdout);
// eslint-disable-next-line no-console
console.error('node stderr:', result.stderr);
}
expect(result.status).toBe(0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
81 changes: 71 additions & 10 deletions packages/@tinacms/cli/src/next/codegen/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ jest.mock('fs-extra', () => ({
ensureFile: jest.fn().mockResolvedValue(undefined),
outputFile: jest.fn().mockResolvedValue(undefined),
existsSync: jest.fn().mockReturnValue(false),
unlinkSync: jest.fn(),
stat: jest.fn().mockResolvedValue({ size: 0 }),
}));
jest.mock(
Expand Down Expand Up @@ -34,15 +35,15 @@ describe('Codegen.genClient', () => {
return instance;
}

it('emits extensionless ./types import for TypeScript projects', async () => {
// Avoids requiring `allowImportingTsExtensions: true` in consumer
// tsconfigs. Modern TS module resolution (Bundler / NodeNext) resolves
// `./types` to `./types.ts` cleanly. The previous `./types.ts` import
// broke under stricter Next.js / TS defaults (see #6062 follow-up).
it('emits ./types.js import for TypeScript projects', async () => {
// The .js extension satisfies Node native ESM at runtime. Modern TS
// module resolution (bundler / node16 / nodenext) rewrites `./types.js`
// back to `./types.ts` at compile time, so type checking still sees
// the .ts source and `allowImportingTsExtensions` is not required.
const { clientString } = await makeInstance(true).genClient();
expect(clientString).toContain('from "./types"');
expect(clientString).toContain('from "./types.js"');
expect(clientString).not.toMatch(/from ["']\.\/types\.ts["']/);
expect(clientString).not.toMatch(/from ["']\.\/types\.js["']/);
expect(clientString).not.toMatch(/from ["']\.\/types["']/);
});

it('emits ./types.js import for non-TypeScript projects', async () => {
Expand All @@ -65,11 +66,11 @@ describe('Codegen.genDatabaseClient', () => {
return instance;
}

it('emits extensionless ./types import for TypeScript projects', async () => {
it('emits ./types.js import for TypeScript projects', async () => {
const result = await makeInstance(true).genDatabaseClient();
expect(result).toContain('from "./types"');
expect(result).toContain('from "./types.js"');
expect(result).not.toMatch(/from ["']\.\/types\.ts["']/);
expect(result).not.toMatch(/from ["']\.\/types\.js["']/);
expect(result).not.toMatch(/from ["']\.\/types["']/);
});

it('emits ./types.js import for non-TypeScript projects', async () => {
Expand Down Expand Up @@ -197,6 +198,66 @@ describe('Codegen.execute integration', () => {
}
});

it('emits types.js alongside types.ts in TS projects and does not unlink it', async () => {
// Regression guard for #6829. Before this fix, the TS branch wrote
// types.ts and then `unlinkIfExists(generatedTypesJSFilePath)`, leaving
// Node native ESM users (#6062) with no `./types.js` to resolve. The
// generated client now imports `"./types.js"` unconditionally, so
// `types.js` must be co-resident with `types.ts` on disk in TS mode.
const fs = jest.requireMock('fs-extra');
const esbuild = jest.requireMock('esbuild');
(fs.outputFile as jest.Mock).mockClear();
(fs.unlinkSync as jest.Mock).mockClear();
(fs.existsSync as jest.Mock).mockReturnValue(true);
(esbuild.transform as jest.Mock).mockClear();
(esbuild.transform as jest.Mock).mockResolvedValue({ code: '/*js*/' });

const genTypesSpy = jest
.spyOn(Codegen.prototype, 'genTypes')
.mockResolvedValue({
codeString: '/*ts source*/',
schemaString: '/*gql schema*/',
});

try {
const codegen = stubCodegen();
const cm = codegen.configManager as any;
cm.shouldSkipSDK = () => false;
cm.isUsingTs = () => true;
cm.hasSelfHostedConfig = () => false;
cm.generatedGraphQLGQLPath = '/fake/tina/__generated__/schema.gql';
cm.generatedTypesTSFilePath = '/fake/tina/__generated__/types.ts';
cm.generatedTypesJSFilePath = '/fake/tina/__generated__/types.js';
cm.generatedTypesDFilePath = '/fake/tina/__generated__/types.d.ts';
cm.generatedClientTSFilePath = '/fake/tina/__generated__/client.ts';
cm.generatedClientJSFilePath = '/fake/tina/__generated__/client.js';
cm.generatedCachePath = '/fake/cache';

await codegen.execute();

const writtenPaths = (fs.outputFile as jest.Mock).mock.calls.map(
(c: any[]) => c[0]
);
expect(writtenPaths).toContain(cm.generatedTypesTSFilePath);
expect(writtenPaths).toContain(cm.generatedTypesJSFilePath);
expect(writtenPaths).toContain(cm.generatedClientTSFilePath);

expect(esbuild.transform).toHaveBeenCalledWith('/*ts source*/', {
loader: 'ts',
});

const unlinkedPaths = (fs.unlinkSync as jest.Mock).mock.calls.map(
(c: any[]) => c[0]
);
expect(unlinkedPaths).not.toContain(cm.generatedTypesJSFilePath);
expect(unlinkedPaths).toContain(cm.generatedClientJSFilePath);
expect(unlinkedPaths).toContain(cm.generatedTypesDFilePath);
} finally {
genTypesSpy.mockRestore();
(fs.existsSync as jest.Mock).mockReturnValue(false);
}
});

it('still writes exactly once when the project has a separate content root', async () => {
// Strengthens the previous test: even when hasSeparateContentRoot returns
// true (the multi-repo flag the duplicate-write block used to gate on),
Expand Down
14 changes: 11 additions & 3 deletions packages/@tinacms/cli/src/next/codegen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ export class Codegen {
this.configManager.generatedTypesTSFilePath,
codeString
);
// Co-resident types.js so the generated `import { queries } from "./types.js"`
// works under Node native ESM at runtime. Modern TS module resolution
// (bundler / node16 / nodenext) rewrites the .js import to types.ts at
// compile time, so type checking still sees the .ts source.
const jsTypes = await transform(codeString, { loader: 'ts' });
await fs.outputFile(
this.configManager.generatedTypesJSFilePath,
jsTypes.code
);
await fs.outputFile(
this.configManager.generatedClientTSFilePath,
clientString
Expand All @@ -159,7 +168,6 @@ export class Codegen {
}
await unlinkIfExists(this.configManager.generatedClientJSFilePath);
await unlinkIfExists(this.configManager.generatedTypesDFilePath);
await unlinkIfExists(this.configManager.generatedTypesJSFilePath);
} else {
// Write out the generated types.
// write types.js and types.d.ts
Expand Down Expand Up @@ -289,7 +297,7 @@ export class Codegen {
import { resolve } from "@tinacms/datalayer";
import type { TinaClient } from "tinacms/dist/client";

import { queries } from "${this.configManager.isUsingTs() ? './types' : './types.js'}";
import { queries } from "./types.js";
import database from "../database";

export async function databaseRequest({ query, variables, user }) {
Expand Down Expand Up @@ -357,7 +365,7 @@ export default databaseClient;
const apiURL = this.getApiURL();

const clientString = `import { createClient } from "tinacms/dist/client";
import { queries } from "${this.configManager.isUsingTs() ? './types' : './types.js'}";
import { queries } from "./types.js";
export const client = createClient({ ${
this.noClientBuildCache === false
? `cacheDir: '${normalizePath(
Expand Down
3 changes: 2 additions & 1 deletion packages/@tinacms/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"src/**/*.test.tsx",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
"src/**/*.spec.tsx",
"src/**/__fixtures__/**"
],
"include": [
"src"
Expand Down
Loading
Loading