kea-typegen 3.7.1 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +33 -0
  3. package/dist/package.json +3 -1
  4. package/dist/src/__tests__/inline.d.ts +1 -0
  5. package/dist/src/__tests__/inline.js +606 -0
  6. package/dist/src/__tests__/inline.js.map +1 -0
  7. package/dist/src/cache.d.ts +1 -0
  8. package/dist/src/cache.js +19 -0
  9. package/dist/src/cache.js.map +1 -1
  10. package/dist/src/cli/typegen.js +9 -0
  11. package/dist/src/cli/typegen.js.map +1 -1
  12. package/dist/src/print/print.d.ts +6 -0
  13. package/dist/src/print/print.js +61 -42
  14. package/dist/src/print/print.js.map +1 -1
  15. package/dist/src/types.d.ts +9 -0
  16. package/dist/src/utils.d.ts +6 -0
  17. package/dist/src/utils.js +82 -1
  18. package/dist/src/utils.js.map +1 -1
  19. package/dist/src/visit/visit.js +7 -0
  20. package/dist/src/visit/visit.js.map +1 -1
  21. package/dist/src/visit/visitConnect.js +40 -0
  22. package/dist/src/visit/visitConnect.js.map +1 -1
  23. package/dist/src/visit/visitSelectors.js +14 -1
  24. package/dist/src/visit/visitSelectors.js.map +1 -1
  25. package/dist/src/write/writeInlineLogicTypes.d.ts +8 -0
  26. package/dist/src/write/writeInlineLogicTypes.js +259 -0
  27. package/dist/src/write/writeInlineLogicTypes.js.map +1 -0
  28. package/dist/src/write/writeTypeImports.d.ts +8 -0
  29. package/dist/src/write/writeTypeImports.js +3 -0
  30. package/dist/src/write/writeTypeImports.js.map +1 -1
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +3 -1
  33. package/samples/manualTypedLogic.ts +20 -0
  34. package/samples-inline/counterLogic.ts +37 -0
  35. package/samples-inline/manualLogic.ts +20 -0
  36. package/samples-inline/profileLogic.ts +31 -0
  37. package/samples-inline/searchLogic.ts +32 -0
  38. package/samples-inline/tsconfig.json +17 -0
  39. package/samples-inline/types.ts +5 -0
  40. package/src/__tests__/inline.ts +747 -0
  41. package/src/cache.ts +19 -0
  42. package/src/cli/typegen.ts +10 -0
  43. package/src/print/print.ts +106 -59
  44. package/src/types.ts +15 -0
  45. package/src/utils.ts +124 -1
  46. package/src/visit/visit.ts +15 -0
  47. package/src/visit/visitConnect.ts +50 -0
  48. package/src/visit/visitSelectors.ts +26 -1
  49. package/src/write/writeInlineLogicTypes.ts +354 -0
  50. package/src/write/writeTypeImports.ts +4 -4
  51. package/tsconfig.json +1 -0
package/src/cache.ts CHANGED
@@ -3,6 +3,7 @@ import * as path from 'path'
3
3
  import { AppOptions, ParsedLogic } from './types'
4
4
  import { Program } from 'typescript'
5
5
  import { visitProgram } from './visit/visit'
6
+ import { isInlineFile } from './utils'
6
7
 
7
8
  export function cachePath(appOptions: AppOptions, fileName: string): string {
8
9
  return path.join(process.cwd(), '.typegen', path.relative(process.cwd(), fileName))
@@ -13,8 +14,16 @@ export function restoreCachedTypes(program: Program, appOptions: AppOptions, log
13
14
  return false
14
15
  }
15
16
  const parsedLogics = visitProgram(program, appOptions)
17
+ const parsedLogicsByFile: Record<string, ParsedLogic[]> = {}
18
+ for (const pl of parsedLogics) {
19
+ parsedLogicsByFile[pl.fileName] = [...(parsedLogicsByFile[pl.fileName] ?? []), pl]
20
+ }
16
21
  let restored = false
17
22
  for (const pl of parsedLogics) {
23
+ if (isInlineFile(appOptions, program.getSourceFile(pl.fileName), parsedLogicsByFile[pl.fileName])) {
24
+ // inline files have their types in the logic file itself, there is no logicType.ts to restore
25
+ continue
26
+ }
18
27
  if (!fs.existsSync(pl.typeFileName)) {
19
28
  const from = cachePath(appOptions, pl.typeFileName)
20
29
  if (fs.existsSync(from)) {
@@ -36,3 +45,13 @@ export function cacheWrittenFile(fileName: string, appOptions: AppOptions) {
36
45
  fs.mkdirSync(path.dirname(dest), { recursive: true })
37
46
  fs.copyFileSync(fileName, dest)
38
47
  }
48
+
49
+ export function deleteCachedFile(fileName: string, appOptions: AppOptions) {
50
+ if (!appOptions.useCache) {
51
+ return
52
+ }
53
+ const dest = cachePath(appOptions, fileName)
54
+ if (fs.existsSync(dest)) {
55
+ fs.unlinkSync(dest)
56
+ }
57
+ }
@@ -39,6 +39,16 @@ yargs
39
39
  .option('delete', { describe: 'Delete logicType.ts files without a corresponding logic.ts', type: 'boolean' })
40
40
  .option('add-ts-nocheck', { describe: 'Add @ts-nocheck to top of logicType.ts files', type: 'boolean' })
41
41
  .option('convert-to-builders', { describe: 'Convert Kea 2.0 inputs to Kea 3.0 logic builders', type: 'boolean' })
42
+ .option('inline', {
43
+ describe: 'Write logic types as MakeLogicType blocks above each kea() call, instead of logicType.ts files',
44
+ type: 'boolean',
45
+ })
46
+ .option('inline-paths', {
47
+ describe:
48
+ 'Like --inline, but only for logic files under these paths (relative to --root). ' +
49
+ 'Everything else keeps its logicType.ts file.',
50
+ type: 'array',
51
+ })
42
52
  .option('import-global-types', {
43
53
  describe: 'Add import statements in logicType.ts files for global types (e.g. @types/node)',
44
54
  type: 'boolean',
@@ -36,9 +36,11 @@ import { printSharedListeners } from './printSharedListeners'
36
36
  import { printListeners } from './printListeners'
37
37
  import { writePaths } from '../write/writePaths'
38
38
  import { writeTypeImports } from '../write/writeTypeImports'
39
+ import { writeInlineLogicTypes } from '../write/writeInlineLogicTypes'
39
40
  import { printInternalExtraInput } from './printInternalExtraInput'
40
41
  import { convertToBuilders } from '../write/convertToBuilders'
41
- import { cacheWrittenFile } from '../cache'
42
+ import { cacheWrittenFile, deleteCachedFile } from '../cache'
43
+ import { isInlineFile } from '../utils'
42
44
 
43
45
  const prettierConfigCache = new Map<string, Promise<prettier.Options | null>>()
44
46
 
@@ -77,9 +79,6 @@ export async function printToFiles(
77
79
  groupedByFile[parsedLogic.fileName] = []
78
80
  }
79
81
  groupedByFile[parsedLogic.fileName].push(parsedLogic)
80
-
81
- // create the Nodes and gather referenced types
82
- printLogicType(parsedLogic, appOptions)
83
82
  }
84
83
 
85
84
  // Automatically ignore imports from "node_modules/@types/node", if {types: ["node"]} in tsconfig.json
@@ -105,6 +104,11 @@ export async function printToFiles(
105
104
  const shouldIgnore = (absolutePath: string) =>
106
105
  !!doNotImportFromPaths.find((badPath) => absolutePath.startsWith(badPath))
107
106
 
107
+ const nodeModulesPath = path.join(
108
+ appOptions.packageJsonPath ? path.dirname(appOptions.packageJsonPath) : appOptions.rootPath,
109
+ 'node_modules',
110
+ )
111
+
108
112
  let writtenFiles = 0
109
113
  let filesToWrite = 0
110
114
  let filesToModify = 0
@@ -112,6 +116,33 @@ export async function printToFiles(
112
116
  for (const [fileName, parsedLogics] of Object.entries(groupedByFile)) {
113
117
  const typeFileName = parsedLogics[0].typeFileName
114
118
 
119
+ if (isInlineFile(appOptions, program.getSourceFile(fileName), parsedLogics)) {
120
+ // write/update MakeLogicType blocks above the logics instead of writing a logicType.ts file
121
+ const inlineResponse = await writeInlineLogicTypes(
122
+ program,
123
+ appOptions,
124
+ fileName,
125
+ parsedLogics,
126
+ nodeModulesPath,
127
+ shouldIgnore,
128
+ )
129
+ filesToWrite += inlineResponse.filesToWrite
130
+ writtenFiles += inlineResponse.writtenFiles
131
+
132
+ // the logicType.ts file is superseded by the inline block
133
+ if (appOptions.delete && fs.existsSync(typeFileName)) {
134
+ log(`🗑️ Deleting: ${path.relative(process.cwd(), typeFileName)}`)
135
+ fs.unlinkSync(typeFileName)
136
+ deleteCachedFile(typeFileName, appOptions)
137
+ }
138
+ continue
139
+ }
140
+
141
+ // create the Nodes and gather referenced types
142
+ for (const parsedLogic of parsedLogics) {
143
+ printLogicType(parsedLogic, appOptions)
144
+ }
145
+
115
146
  const logicStrings = []
116
147
  const requiredKeys = new Set(['Logic'])
117
148
  for (const parsedLogic of parsedLogics) {
@@ -131,62 +162,12 @@ export async function printToFiles(
131
162
 
132
163
  const output = logicStrings.join('\n\n')
133
164
 
134
- const nodeModulesPath = path.join(
135
- appOptions.packageJsonPath ? path.dirname(appOptions.packageJsonPath) : appOptions.rootPath,
136
- 'node_modules',
165
+ const otherimports = buildTypeImportEntries(
166
+ parsedLogics[0].typeReferencesToImportFromFiles,
167
+ parsedLogics[0].typeFileName,
168
+ nodeModulesPath,
169
+ shouldIgnore,
137
170
  )
138
-
139
- const otherimports = Object.entries(parsedLogics[0].typeReferencesToImportFromFiles)
140
- .filter(([_, list]) => list.size > 0)
141
- .map(([file, list]) => {
142
- let finalPath = file
143
-
144
- // Relative path? Get the absolute.
145
- if (finalPath.startsWith('.')) {
146
- finalPath = path.resolve(path.dirname(parsedLogics[0].typeFileName), finalPath)
147
- }
148
- if (finalPath.startsWith('node_modules/')) {
149
- finalPath = path.resolve(path.dirname(nodeModulesPath), finalPath)
150
- }
151
- // clean up '../../node_modules/...'
152
- if (finalPath.startsWith(nodeModulesPath)) {
153
- finalPath = finalPath.substring(nodeModulesPath.length + 1)
154
- if (finalPath.startsWith('.pnpm/')) {
155
- // node_modules/.pnpm/pkg@version/node_modules/* --> *
156
- const regex = /\.pnpm\/[^/]+@[^\/]+\/node_modules\/(.*)/
157
- const result = finalPath.match(regex)
158
- if (result && result.length > 1) {
159
- finalPath = result[1]
160
- }
161
- }
162
- if (finalPath.startsWith('@types/')) {
163
- finalPath = finalPath.substring(7)
164
- }
165
- }
166
-
167
- // Resolve absolute urls
168
- if (finalPath.startsWith('/')) {
169
- finalPath = path.relative(path.dirname(parsedLogics[0].typeFileName), finalPath)
170
- if (!finalPath.startsWith('.')) {
171
- finalPath = `./${finalPath}`
172
- }
173
- }
174
-
175
- // Remove extension
176
- finalPath = finalPath.replace(/(\.d|)\.tsx?$/, '')
177
-
178
- // Remove "/index"
179
- if (finalPath.split('/').length === 2 && finalPath.endsWith('/index')) {
180
- finalPath = finalPath.substring(0, finalPath.length - 6)
181
- }
182
-
183
- return {
184
- list: [...list].sort(),
185
- fullPath: file,
186
- finalPath,
187
- }
188
- })
189
- .filter((entry) => !shouldIgnore(entry.fullPath))
190
171
  .map(({ list, finalPath }) => `import type { ${list.join(', ')} } from '${finalPath}'`)
191
172
  .join('\n')
192
173
 
@@ -306,6 +287,72 @@ export async function printToFiles(
306
287
  return { filesToWrite, writtenFiles, filesToModify }
307
288
  }
308
289
 
290
+ export interface TypeImportEntry {
291
+ list: string[]
292
+ fullPath: string
293
+ finalPath: string
294
+ }
295
+
296
+ /** Resolve gathered type references into import specifiers, relative to the file they'll be imported into */
297
+ export function buildTypeImportEntries(
298
+ typeReferencesToImportFromFiles: Record<string, Set<string>>,
299
+ importIntoFile: string,
300
+ nodeModulesPath: string,
301
+ shouldIgnore: (absolutePath: string) => boolean,
302
+ ): TypeImportEntry[] {
303
+ return Object.entries(typeReferencesToImportFromFiles)
304
+ .filter(([_, list]) => list.size > 0)
305
+ .map(([file, list]) => {
306
+ let finalPath = file
307
+
308
+ // Relative path? Get the absolute.
309
+ if (finalPath.startsWith('.')) {
310
+ finalPath = path.resolve(path.dirname(importIntoFile), finalPath)
311
+ }
312
+ if (finalPath.startsWith('node_modules/')) {
313
+ finalPath = path.resolve(path.dirname(nodeModulesPath), finalPath)
314
+ }
315
+ // clean up '../../node_modules/...'
316
+ if (finalPath.startsWith(nodeModulesPath)) {
317
+ finalPath = finalPath.substring(nodeModulesPath.length + 1)
318
+ if (finalPath.startsWith('.pnpm/')) {
319
+ // node_modules/.pnpm/pkg@version/node_modules/* --> *
320
+ const regex = /\.pnpm\/[^/]+@[^\/]+\/node_modules\/(.*)/
321
+ const result = finalPath.match(regex)
322
+ if (result && result.length > 1) {
323
+ finalPath = result[1]
324
+ }
325
+ }
326
+ if (finalPath.startsWith('@types/')) {
327
+ finalPath = finalPath.substring(7)
328
+ }
329
+ }
330
+
331
+ // Resolve absolute urls
332
+ if (finalPath.startsWith('/')) {
333
+ finalPath = path.relative(path.dirname(importIntoFile), finalPath)
334
+ if (!finalPath.startsWith('.')) {
335
+ finalPath = `./${finalPath}`
336
+ }
337
+ }
338
+
339
+ // Remove extension
340
+ finalPath = finalPath.replace(/(\.d|)\.tsx?$/, '')
341
+
342
+ // Remove "/index"
343
+ if (finalPath.split('/').length === 2 && finalPath.endsWith('/index')) {
344
+ finalPath = finalPath.substring(0, finalPath.length - 6)
345
+ }
346
+
347
+ return {
348
+ list: [...list].sort(),
349
+ fullPath: file,
350
+ finalPath,
351
+ }
352
+ })
353
+ .filter((entry) => !shouldIgnore(entry.fullPath))
354
+ }
355
+
309
356
  export function nodeToString(node: Node): string {
310
357
  const printer = createPrinter({ newLine: NewLineKind.LineFeed })
311
358
  const sourceFile = createSourceFile('logic.ts', '', ScriptTarget.Latest, false, ScriptKind.TS)
package/src/types.ts CHANGED
@@ -5,11 +5,15 @@ export interface ActionTransform {
5
5
  name: string
6
6
  parameters: ts.ParameterDeclaration[]
7
7
  returnTypeNode: ts.TypeNode
8
+ /** Name of the logic this action was connected from, e.g. "userLogic" */
9
+ sourceLogic?: string
8
10
  }
9
11
 
10
12
  export interface NameType {
11
13
  name: string
12
14
  typeNode: ts.TypeNode | ts.KeywordTypeNode | ts.ParenthesizedTypeNode
15
+ /** Name of the logic this value was connected from, e.g. "userLogic" */
16
+ sourceLogic?: string
13
17
  }
14
18
 
15
19
  export interface ReducerTransform extends NameType {}
@@ -18,6 +22,12 @@ export interface SelectorTransform extends NameType {
18
22
  functionTypes?: { name: string; type: ts.TypeNode }[]
19
23
  }
20
24
 
25
+ /** A selector combiner parameter without a type annotation, plus the inferred type to write into the source */
26
+ export interface SelectorParamAnnotation {
27
+ parameter: ts.ParameterDeclaration
28
+ typeNode: ts.TypeNode
29
+ }
30
+
21
31
  export interface ListenerTransform {
22
32
  name: string
23
33
  action: ts.TypeNode | ts.KeywordTypeNode | ts.ParenthesizedTypeNode
@@ -46,6 +56,7 @@ export interface ParsedLogic {
46
56
  propsType?: ts.TypeNode
47
57
  keyType?: ts.TypeNode
48
58
  typeReferencesToImportFromFiles: Record<string, Set<string>>
59
+ selectorParamAnnotations: SelectorParamAnnotation[]
49
60
  interfaceDeclaration?: ts.InterfaceDeclaration
50
61
  extraActions: Record<string, ts.TypeNode>
51
62
  extraInput: Record<string, { typeNode: ts.TypeNode; withLogicFunction: boolean }>
@@ -78,6 +89,10 @@ export interface AppOptions {
78
89
  addTsNocheck?: boolean
79
90
  /** Convert kea 2.0 logic input to kea 3.0 builders */
80
91
  convertToBuilders?: boolean
92
+ /** Write logic types as MakeLogicType blocks above each kea() call, instead of into logicType.ts files */
93
+ inline?: boolean
94
+ /** Like `inline`, but only for logic files under these paths. Everything else keeps its logicType.ts file. */
95
+ inlinePaths?: string[]
81
96
  /** Show TypeScript errors */
82
97
  showTsErrors?: boolean
83
98
  /** Cache generated logic files into .typegen, use them if generating a logic type for the first time */
package/src/utils.ts CHANGED
@@ -53,6 +53,128 @@ export function isKeaCall(node: ts.Node, checker: ts.TypeChecker) {
53
53
  return ts.isObjectLiteralExpression(input) || ts.isArrayLiteralExpression(input)
54
54
  }
55
55
 
56
+ /** True if the node has a leading "kea-typegen" comment, marking it as generated by us */
57
+ export function hasKeaTypegenMarker(node: ts.Node): boolean {
58
+ const sourceFile = node.getSourceFile()
59
+ const ranges = ts.getLeadingCommentRanges(sourceFile.text, node.pos) ?? []
60
+ return ranges.some(({ pos, end }) => sourceFile.text.slice(pos, end).includes('kea-typegen'))
61
+ }
62
+
63
+ const INLINE_BLOCK_SUFFIXES = ['Values', 'Actions', 'Props']
64
+
65
+ /**
66
+ * The statements making up an inline logic type block: the `logicType` alias itself, plus any
67
+ * `logicValues` / `logicActions` / `logicProps` interfaces directly above it.
68
+ */
69
+ export function getInlineBlockStatements(
70
+ declaration: ts.TypeAliasDeclaration | ts.InterfaceDeclaration,
71
+ logicName: string,
72
+ ): ts.Statement[] {
73
+ const statements = declaration.getSourceFile().statements
74
+ const index = statements.indexOf(declaration)
75
+ if (index === -1) {
76
+ return [declaration]
77
+ }
78
+ const generatedNames = new Set(INLINE_BLOCK_SUFFIXES.map((suffix) => `${logicName}${suffix}`))
79
+ const block: ts.Statement[] = [declaration]
80
+ for (let i = index - 1; i >= 0; i--) {
81
+ const statement = statements[i]
82
+ if (
83
+ (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) &&
84
+ generatedNames.has(statement.name.text)
85
+ ) {
86
+ block.unshift(statement)
87
+ } else {
88
+ break
89
+ }
90
+ }
91
+ return block
92
+ }
93
+
94
+ /** True if this local `logicType` declaration is part of a kea-typegen generated inline block */
95
+ export function isGeneratedInlineBlock(
96
+ declaration: ts.TypeAliasDeclaration | ts.InterfaceDeclaration,
97
+ logicName: string,
98
+ ): boolean {
99
+ return getInlineBlockStatements(declaration, logicName).some(hasKeaTypegenMarker)
100
+ }
101
+
102
+ /** Find a top-level `type logicType = ...` or `interface logicType {...}` declaration in the file */
103
+ export function findLogicTypeDeclaration(
104
+ sourceFile: ts.SourceFile,
105
+ logicTypeName: string,
106
+ ): ts.TypeAliasDeclaration | ts.InterfaceDeclaration | undefined {
107
+ return sourceFile.statements.find(
108
+ (statement): statement is ts.TypeAliasDeclaration | ts.InterfaceDeclaration =>
109
+ (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) &&
110
+ statement.name.text === logicTypeName,
111
+ )
112
+ }
113
+
114
+ /**
115
+ * Should this logic file get inline MakeLogicType blocks instead of a logicType.ts file?
116
+ * True when running with --inline, when the file sits under one of `inlinePaths`, or when the
117
+ * file already carries a generated inline block (so converted files stay inline, whatever the config).
118
+ */
119
+ export function isInlineFile(
120
+ appOptions: AppOptions | undefined,
121
+ sourceFile: ts.SourceFile | undefined,
122
+ parsedLogics: ParsedLogic[],
123
+ ): boolean {
124
+ if (appOptions?.inline) {
125
+ return true
126
+ }
127
+ if (!sourceFile) {
128
+ return false
129
+ }
130
+ for (const inlinePath of appOptions?.inlinePaths ?? []) {
131
+ const resolvedPath = path.resolve(appOptions?.rootPath ?? '.', inlinePath)
132
+ const resolvedFileName = path.resolve(sourceFile.fileName)
133
+ if (resolvedFileName === resolvedPath || resolvedFileName.startsWith(resolvedPath + path.sep)) {
134
+ return true
135
+ }
136
+ }
137
+ return parsedLogics.some((parsedLogic) => {
138
+ const declaration = findLogicTypeDeclaration(sourceFile, parsedLogic.logicTypeName)
139
+ return declaration ? isGeneratedInlineBlock(declaration, parsedLogic.logicName) : false
140
+ })
141
+ }
142
+
143
+ /**
144
+ * True if the type argument in `kea<...>()` is a manually written type (e.g. `MakeLogicType<...>`
145
+ * or a custom interface), as opposed to a type that kea-typegen generates and keeps updated.
146
+ * Manually typed logics are skipped entirely.
147
+ */
148
+ export function isManuallyTypedLogic(
149
+ typeArgument: ts.TypeNode,
150
+ logicTypeName: string,
151
+ checker: ts.TypeChecker,
152
+ ): boolean {
153
+ if (!ts.isTypeReferenceNode(typeArgument)) {
154
+ // e.g. kea<{ actions: ... }>(...)
155
+ return true
156
+ }
157
+ if (!ts.isIdentifier(typeArgument.typeName) || typeArgument.typeName.escapedText !== logicTypeName) {
158
+ // e.g. kea<MakeLogicType<Values, Actions>>(...) or kea<MyCustomLogicType>(...)
159
+ return true
160
+ }
161
+ const symbol = checker.getSymbolAtLocation(typeArgument.typeName)
162
+ const declaration = symbol?.getDeclarations()?.[0]
163
+ if (!declaration) {
164
+ // "logicType" that doesn't resolve --> a generated type that doesn't exist yet
165
+ return false
166
+ }
167
+ if (ts.isImportSpecifier(declaration)) {
168
+ // imported logic types are managed by kea-typegen
169
+ return false
170
+ }
171
+ if (ts.isTypeAliasDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) {
172
+ // local declarations are ours only if their block carries the kea-typegen marker (written by --inline mode)
173
+ return !isGeneratedInlineBlock(declaration, logicTypeName.replace(/Type$/, ''))
174
+ }
175
+ return true
176
+ }
177
+
56
178
  export function getTypeNodeForNode(node: ts.Node, checker: ts.TypeChecker): ts.TypeNode {
57
179
  let typeNode
58
180
  if (node) {
@@ -92,7 +214,8 @@ export function getParameterDeclaration(param: ts.ParameterDeclaration) {
92
214
  return factory.createParameterDeclaration(
93
215
  undefined,
94
216
  undefined,
95
- factory.createIdentifier(param.name.getText()),
217
+ // .getText() throws on synthetic nodes (e.g. from signatureToSignatureDeclaration)
218
+ factory.createIdentifier(ts.isIdentifier(param.name) ? param.name.text : param.name.getText()),
96
219
  param.initializer || param.questionToken ? factory.createToken(SyntaxKind.QuestionToken) : undefined,
97
220
  cloneNodeSorted(param.type || factory.createKeywordTypeNode(SyntaxKind.AnyKeyword)),
98
221
  undefined,
@@ -10,6 +10,7 @@ import {
10
10
  getLogicPathString,
11
11
  getTypeNodeForNode,
12
12
  isKeaCall,
13
+ isManuallyTypedLogic,
13
14
  } from '../utils'
14
15
  import { visitActions } from './visitActions'
15
16
  import { visitReducers } from './visitReducers'
@@ -216,6 +217,19 @@ export function visitKeaCalls(
216
217
  const keaTypeArguments = ts.isCallExpression(node.parent) ? node.parent.typeArguments : []
217
218
  const keaTypeArgument = keaTypeArguments?.[0]
218
219
 
220
+ // if the logic is already manually typed (e.g. kea<MakeLogicType<...>>(...)), leave it alone
221
+ if (keaTypeArgument && isManuallyTypedLogic(keaTypeArgument, logicTypeName, checker)) {
222
+ if (appOptions?.verbose) {
223
+ appOptions.log(
224
+ `🩶 Skipping manually typed logic "${logicName}" in ${path.relative(
225
+ process.cwd(),
226
+ sourceFile.fileName,
227
+ )}`,
228
+ )
229
+ }
230
+ return
231
+ }
232
+
219
233
  const pathString = getLogicPathString(appOptions, sourceFile.fileName)
220
234
  let typeFileName = sourceFile.fileName.replace(/\.[tj]sx?$/, 'Type.ts')
221
235
 
@@ -267,6 +281,7 @@ export function visitKeaCalls(
267
281
  hasKeyInLogic: false,
268
282
  hasPathInLogic: false,
269
283
  typeReferencesToImportFromFiles: {},
284
+ selectorParamAnnotations: [],
270
285
  extraActions: {},
271
286
  extraInput: {},
272
287
  extraLogicFields: {},
@@ -1,5 +1,6 @@
1
1
  import { ParsedLogic } from '../types'
2
2
  import * as ts from 'typescript'
3
+ import { NodeBuilderFlags } from 'typescript'
3
4
  import { cloneNodeSorted, gatherImports, getParameterDeclaration } from '../utils'
4
5
  import { Expression, Type } from 'typescript'
5
6
 
@@ -47,6 +48,7 @@ export function visitConnect(parsedLogic: ParsedLogic, type: Type, expression: E
47
48
 
48
49
  const symbol = checker.getSymbolAtLocation(logicReference)
49
50
  const otherLogicType = checker.getTypeOfSymbolAtLocation(symbol, logicReference)
51
+ const sourceLogic = ts.isIdentifier(logicReference) ? logicReference.text : logicReference.getText()
50
52
 
51
53
  if (loaderName === 'actions') {
52
54
  const actionsForLogic = otherLogicType
@@ -85,12 +87,59 @@ export function visitConnect(parsedLogic: ParsedLogic, type: Type, expression: E
85
87
  name: lookup[name],
86
88
  returnTypeNode,
87
89
  parameters,
90
+ sourceLogic,
88
91
  })
89
92
  }
90
93
  }
91
94
  }
92
95
  }
93
96
  }
97
+ } else if (actionsForLogic) {
98
+ // `actionCreators` is not a literal type node we can walk syntactically - e.g. the other
99
+ // logic is typed with MakeLogicType, where actionCreators is a mapped type. Resolve the
100
+ // connected action types semantically instead.
101
+ const actionCreatorsType = checker.getTypeOfSymbolAtLocation(actionsForLogic, logicReference)
102
+ for (const actionProperty of actionCreatorsType.getProperties()) {
103
+ const name = actionProperty.getName()
104
+ if (!lookup[name]) {
105
+ continue
106
+ }
107
+ const actionCreatorType = checker.getTypeOfSymbolAtLocation(actionProperty, logicReference)
108
+ const signature = actionCreatorType.getCallSignatures()[0]
109
+ if (!signature) {
110
+ continue
111
+ }
112
+ const signatureNode = checker.signatureToSignatureDeclaration(
113
+ signature,
114
+ ts.SyntaxKind.FunctionType,
115
+ logicReference,
116
+ NodeBuilderFlags.NoTruncation | NodeBuilderFlags.IgnoreErrors,
117
+ ) as ts.FunctionTypeNode | undefined
118
+ if (!signatureNode) {
119
+ continue
120
+ }
121
+ const parameters = signatureNode.parameters.map((param) => getParameterDeclaration(param))
122
+ let returnType: ts.TypeNode = signatureNode.type
123
+ if (ts.isParenthesizedTypeNode(returnType)) {
124
+ returnType = returnType.type
125
+ }
126
+ if (ts.isTypeLiteralNode(returnType)) {
127
+ const payload = returnType.members.find(
128
+ (m) => m.name && ts.isIdentifier(m.name) && m.name.text === 'payload',
129
+ )
130
+ if (payload && ts.isPropertySignature(payload) && payload.type) {
131
+ const returnTypeNode = cloneNodeSorted(payload.type)
132
+ gatherImports(signatureNode, checker, parsedLogic)
133
+
134
+ parsedLogic.actions.push({
135
+ name: lookup[name],
136
+ returnTypeNode,
137
+ parameters,
138
+ sourceLogic,
139
+ })
140
+ }
141
+ }
142
+ }
94
143
  }
95
144
  }
96
145
 
@@ -109,6 +158,7 @@ export function visitConnect(parsedLogic: ParsedLogic, type: Type, expression: E
109
158
  name: lookup[name],
110
159
  typeNode,
111
160
  functionTypes: [],
161
+ sourceLogic,
112
162
  })
113
163
  }
114
164
  }
@@ -14,8 +14,14 @@ export function visitSelectors(parsedLogic: ParsedLogic, type: Type, expression:
14
14
  const inputFunctionTypeNode = checker.getTypeAtLocation(inputFunction)
15
15
 
16
16
  const selectorInputFunctionType = inputFunctionTypeNode.getCallSignatures()[0]?.getReturnType() as ts.Type
17
+ // IgnoreErrors: the tuple may mention types not referenceable from the logic file
18
+ // (e.g. kea's internal Props); we only consume the elements' return types anyway
17
19
  const selectorInputTypeNode = selectorInputFunctionType
18
- ? checker.typeToTypeNode(selectorInputFunctionType, inputFunction, NodeBuilderFlags.NoTruncation)
20
+ ? checker.typeToTypeNode(
21
+ selectorInputFunctionType,
22
+ inputFunction,
23
+ NodeBuilderFlags.NoTruncation | NodeBuilderFlags.IgnoreErrors,
24
+ )
19
25
  : null
20
26
 
21
27
  let functionNames = []
@@ -75,6 +81,25 @@ export function visitSelectors(parsedLogic: ParsedLogic, type: Type, expression:
75
81
  typeNode,
76
82
  functionTypes,
77
83
  })
84
+
85
+ // combiner parameters without a type annotation, e.g. `(counter) => counter * 2`.
86
+ // In inline mode these get their inferred types written into the source, since
87
+ // MakeLogicType's loose selector typing can no longer infer them contextually.
88
+ computedFunction.parameters.forEach((parameter, index) => {
89
+ const functionType = functionTypes[index]
90
+ if (
91
+ !parameter.type &&
92
+ !parameter.dotDotDotToken &&
93
+ ts.isIdentifier(parameter.name) &&
94
+ functionType &&
95
+ functionType.type.kind !== ts.SyntaxKind.AnyKeyword
96
+ ) {
97
+ parsedLogic.selectorParamAnnotations.push({
98
+ parameter,
99
+ typeNode: functionType.type,
100
+ })
101
+ }
102
+ })
78
103
  }
79
104
  }
80
105
  }