Skip to content

Commit 551ceb4

Browse files
authored
feat(operators): add migration for deprecated tapResponse signature (#4858)
1 parent 3981df9 commit 551ceb4

File tree

3 files changed

+320
-1
lines changed

3 files changed

+320
-1
lines changed
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import * as path from 'path';
2+
import {
3+
SchematicTestRunner,
4+
UnitTestTree,
5+
} from '@angular-devkit/schematics/testing';
6+
import { createWorkspace } from '@ngrx/schematics-core/testing';
7+
8+
describe('migrate tapResponse', () => {
9+
const collectionPath = path.join(__dirname, '../migration.json');
10+
const schematicRunner = new SchematicTestRunner('schematics', collectionPath);
11+
let appTree: UnitTestTree;
12+
13+
beforeEach(async () => {
14+
appTree = await createWorkspace(schematicRunner, appTree);
15+
});
16+
17+
const verifySchematic = async (input: string, output: string) => {
18+
appTree.create('main.ts', input);
19+
20+
const tree = await schematicRunner.runSchematic(
21+
'20.0.0-rc_0-tap-response',
22+
{},
23+
appTree
24+
);
25+
26+
const actual = tree.readContent('main.ts');
27+
28+
const normalize = (s: string) => s.replace(/\s+/g, '').replace(/;/g, '');
29+
expect(normalize(actual)).toBe(normalize(output));
30+
};
31+
32+
it('migrates basic tapResponse signature', async () => {
33+
const input = `import { tapResponse } from '@ngrx/operators';
34+
tapResponse(() => {}, () => {});
35+
`;
36+
37+
const output = `import { tapResponse } from '@ngrx/operators';
38+
tapResponse({
39+
next: () => { },
40+
error: () => { }
41+
});
42+
`;
43+
44+
await verifySchematic(input, output);
45+
});
46+
47+
it('migrates tapResponse with complete callback', async () => {
48+
const input = `
49+
import { tapResponse } from '@ngrx/operators';
50+
tapResponse(
51+
() => next,
52+
() => error,
53+
() => complete
54+
);
55+
`;
56+
57+
const output = `
58+
import { tapResponse } from '@ngrx/operators';
59+
tapResponse({
60+
next: () => next,
61+
error: () => error,
62+
complete: () => complete
63+
});
64+
`;
65+
66+
await verifySchematic(input, output);
67+
});
68+
69+
it('migrates aliased tapResponse calls', async () => {
70+
const input = `
71+
import { tapResponse } from '@ngrx/operators';
72+
const myTapResponse = tapResponse;
73+
myTapResponse(
74+
() => next,
75+
() => error
76+
);
77+
`;
78+
79+
const output = `
80+
import { tapResponse } from '@ngrx/operators';
81+
const myTapResponse = tapResponse;
82+
myTapResponse({
83+
next: () => next,
84+
error: () => error
85+
});
86+
`;
87+
88+
await verifySchematic(input, output);
89+
});
90+
91+
it('migrates namespaced tapResponse calls', async () => {
92+
const input = `import * as operators from '@ngrx/operators';
93+
operators.tapResponse(() => next, () => error, () => complete);
94+
`;
95+
96+
const output = `import * as operators from '@ngrx/operators';
97+
operators.tapResponse({
98+
next: () => next,
99+
error: () => error,
100+
complete: () => complete
101+
});
102+
`;
103+
104+
await verifySchematic(input, output);
105+
});
106+
107+
it('skips tapResponse if not imported from @ngrx/operators', async () => {
108+
const input = `import { tapResponse } from '@ngrx/component';
109+
tapResponse(() => {}, () => {});
110+
`;
111+
112+
await verifySchematic(input, input);
113+
});
114+
115+
it('skips correct tapResponse signature', async () => {
116+
const input = `import { tapResponse } from '@ngrx/operators';
117+
tapResponse({
118+
next: () => { },
119+
error: () => { }
120+
});
121+
`;
122+
123+
await verifySchematic(input, input);
124+
});
125+
126+
it('migrates tapResponse inside a full component-like body', async () => {
127+
const input = `import { tapResponse } from '@ngrx/operators';
128+
function handle() {
129+
return tapResponse(() => next(), () => error(), () => complete());
130+
}
131+
`;
132+
133+
const output = `import { tapResponse } from '@ngrx/operators';
134+
function handle() {
135+
return tapResponse({
136+
next: () => next(),
137+
error: () => error(),
138+
complete: () => complete()
139+
});
140+
}
141+
`;
142+
143+
await verifySchematic(input, output);
144+
});
145+
146+
it('migrates tapResponse(onNext, onError) to object form', async () => {
147+
const input = `
148+
import { tapResponse } from '@ngrx/operators';
149+
const obs = tapResponse(
150+
(value) => console.log(value),
151+
(error) => console.error(error)
152+
);
153+
`;
154+
155+
const output = `
156+
import { tapResponse } from '@ngrx/operators';
157+
const obs = tapResponse({
158+
next: (value) => console.log(value),
159+
error: (error) => console.error(error)
160+
});
161+
`;
162+
await verifySchematic(input, output);
163+
});
164+
165+
it('skips migrating tapResponse imported from another module', async () => {
166+
const input = `
167+
import { tapResponse } from 'some-other-lib';
168+
const obs = tapResponse(
169+
(value) => console.log(value),
170+
(error) => console.error(error)
171+
);
172+
`;
173+
174+
await verifySchematic(input, input);
175+
});
176+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Rule, Tree, SchematicContext } from '@angular-devkit/schematics';
2+
import {
3+
visitTSSourceFiles,
4+
createReplaceChange,
5+
commitChanges,
6+
Change,
7+
} from '../../schematics-core/index';
8+
import { visitCallExpression } from '../../schematics-core/utility/visitors';
9+
import * as ts from 'typescript';
10+
11+
export default function migrateTapResponse(): Rule {
12+
return (tree: Tree, context: SchematicContext) => {
13+
visitTSSourceFiles(tree, (sourceFile: ts.SourceFile) => {
14+
const changes: Change[] = [];
15+
const printer = ts.createPrinter();
16+
17+
const tapResponseIdentifiers = new Set<string>();
18+
const namespaceImportsFromOperators = new Set<string>();
19+
const aliasedTapResponseVariables = new Set<string>();
20+
const importOriginMap = new Map<string, string>();
21+
22+
// Collect import origins and aliases
23+
ts.forEachChild(sourceFile, (node: ts.Node) => {
24+
if (
25+
ts.isImportDeclaration(node) &&
26+
ts.isStringLiteral(node.moduleSpecifier) &&
27+
node.importClause?.namedBindings
28+
) {
29+
const moduleName = node.moduleSpecifier.text;
30+
const bindings = node.importClause.namedBindings;
31+
32+
if (ts.isNamedImports(bindings)) {
33+
for (const element of bindings.elements) {
34+
const importedName = element.name.text;
35+
importOriginMap.set(importedName, moduleName);
36+
if (moduleName === '@ngrx/operators') {
37+
tapResponseIdentifiers.add(importedName);
38+
}
39+
}
40+
} else if (ts.isNamespaceImport(bindings)) {
41+
if (moduleName === '@ngrx/operators') {
42+
namespaceImportsFromOperators.add(bindings.name.text);
43+
}
44+
}
45+
}
46+
47+
// Track variables assigned to known tapResponse identifiers from @ngrx/operators
48+
if (ts.isVariableStatement(node)) {
49+
for (const decl of node.declarationList.declarations) {
50+
if (
51+
ts.isIdentifier(decl.name) &&
52+
decl.initializer &&
53+
ts.isIdentifier(decl.initializer)
54+
) {
55+
const original = decl.initializer.text;
56+
if (
57+
tapResponseIdentifiers.has(original) &&
58+
importOriginMap.get(original) === '@ngrx/operators'
59+
) {
60+
aliasedTapResponseVariables.add(decl.name.text);
61+
}
62+
}
63+
}
64+
}
65+
});
66+
67+
// Combine aliases into the main set
68+
for (const alias of aliasedTapResponseVariables) {
69+
tapResponseIdentifiers.add(alias);
70+
}
71+
72+
visitCallExpression(sourceFile, (node: ts.CallExpression) => {
73+
const { expression, arguments: args } = node;
74+
75+
let isTapResponseCall = false;
76+
77+
if (ts.isIdentifier(expression)) {
78+
if (tapResponseIdentifiers.has(expression.text)) {
79+
isTapResponseCall = true;
80+
}
81+
} else if (ts.isPropertyAccessExpression(expression)) {
82+
const namespace = expression.expression.getText();
83+
const fnName = expression.name.text;
84+
if (
85+
fnName === 'tapResponse' &&
86+
namespaceImportsFromOperators.has(namespace)
87+
) {
88+
isTapResponseCall = true;
89+
}
90+
}
91+
92+
if (
93+
isTapResponseCall &&
94+
(args.length === 2 || args.length === 3) &&
95+
args.every(
96+
(arg) => ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)
97+
)
98+
) {
99+
const props: ts.PropertyAssignment[] = [
100+
ts.factory.createPropertyAssignment('next', args[0]),
101+
ts.factory.createPropertyAssignment('error', args[1]),
102+
];
103+
104+
if (args[2]) {
105+
props.push(
106+
ts.factory.createPropertyAssignment('complete', args[2])
107+
);
108+
}
109+
110+
const newCall = ts.factory.updateCallExpression(
111+
node,
112+
expression,
113+
node.typeArguments,
114+
[ts.factory.createObjectLiteralExpression(props, true)]
115+
);
116+
117+
const newText = printer.printNode(
118+
ts.EmitHint.Expression,
119+
newCall,
120+
sourceFile
121+
);
122+
123+
changes.push(
124+
createReplaceChange(sourceFile, node, node.getText(), newText)
125+
);
126+
}
127+
});
128+
129+
if (changes.length) {
130+
commitChanges(tree, sourceFile.fileName, changes);
131+
context.logger.info(
132+
`[ngrx/operators] Migrated deprecated tapResponse in ${sourceFile.fileName}`
133+
);
134+
}
135+
});
136+
};
137+
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
{
22
"$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json",
3-
"schematics": {}
3+
"schematics": {
4+
"20.0.0-rc_0-tap-response": {
5+
"description": "Replace deprecated tapResponse signature",
6+
"version": "20.0.0-rc.0",
7+
"factory": "./20_0_0-rc_0-tap-response/index"
8+
}
9+
}
410
}

0 commit comments

Comments
 (0)