Skip to content
15 changes: 15 additions & 0 deletions bin/react_18_upgrade.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2025 Workiva Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

export 'package:over_react_codemod/src/executables/react_18_upgrade.dart';
91 changes: 91 additions & 0 deletions lib/src/executables/react_18_upgrade.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright 2025 Workiva Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'dart:io';

import 'package:args/args.dart';
import 'package:codemod/codemod.dart';
import 'package:over_react_codemod/src/rmui_bundle_update_suggestors/dart_script_updater.dart';
import 'package:over_react_codemod/src/rmui_bundle_update_suggestors/html_script_updater.dart';
import 'package:over_react_codemod/src/util.dart';

const _changesRequiredOutput = """
To update your code, run the following commands in your repository:
dart pub global activate over_react_codemod
dart pub global run over_react_codemod:react_18_upgrade
""";

/// Updates React JS paths in HTML and Dart files from the React 17 versions to the React 18 versions.
void main(List<String> args) async {
final parser = ArgParser.allowAnything();

final parsedArgs = parser.parse(args);

// Work around allowAnything not allowing you to pass flags.
if (parsedArgs.arguments.contains('--help')) {
// Print command description; flags and other output will get printed via runInteractiveCodemodSequence.
print(
'Updates React JS paths in HTML and Dart files from the React 17 versions to the React 18 versions.\n');
}

exitCode = await runInteractiveCodemodSequence(
allHtmlPathsIncludingTemplates(),
[
// Update react.js bundle files to React 18 versions in html files
...react17to18ReactJsScriptNames.keys.map((key) => HtmlScriptUpdater(
key, react17to18ReactJsScriptNames[key]!,
updateAttributes: false)),
// Remove React 17 react_dom bundle files in html files
...react17ReactDomJsOnlyScriptNames
.map((name) => HtmlScriptUpdater.remove(name)),
],
defaultYes: true,
args: parsedArgs.rest,
additionalHelpOutput: parser.usage,
changesRequiredOutput: _changesRequiredOutput,
);

if (exitCode != 0) return;

exitCode = await runInteractiveCodemodSequence(
allDartPathsExceptHidden(),
[
// Update react.js bundle files to React 18 versions in Dart files
...react17to18ReactJsScriptNames.keys.map((key) => DartScriptUpdater(
key, react17to18ReactJsScriptNames[key]!,
updateAttributes: false)),
// Remove React 17 react_dom bundle files in Dart files
...react17ReactDomJsOnlyScriptNames
.map((name) => DartScriptUpdater.remove(name)),
],
defaultYes: true,
args: parsedArgs.rest,
additionalHelpOutput: parser.usage,
changesRequiredOutput: _changesRequiredOutput,
);
}

const reactPath = 'packages/react/';

const react17to18ReactJsScriptNames = {
'${reactPath}react.js': '${reactPath}js/react.dev.js',
'${reactPath}react_with_addons.js': '${reactPath}js/react.dev.js',
'${reactPath}react_prod.js': '${reactPath}js/react.min.js',
'${reactPath}react_with_react_dom_prod.js': '${reactPath}js/react.min.js',
};

const react17ReactDomJsOnlyScriptNames = [
'${reactPath}react_dom.js',
'${reactPath}react_dom_prod.js',
];
4 changes: 3 additions & 1 deletion lib/src/rmui_bundle_update_suggestors/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ class Link {

/// A pattern for finding a link tag with a matching path.
RegExp get pattern => RegExp(
r'<link[^>]*href="(?<path_prefix>[^"]*)' + pathSubpattern + r'"[^>]*>');
r'(?<preceding_whitespace>[^\S\r\n]*)<link[^>]*href="(?<path_prefix>[^"]*)' +
pathSubpattern +
r'"[^>]*>(?<trailing_new_line>\n?)');

@override
String toString() =>
Expand Down
144 changes: 95 additions & 49 deletions lib/src/rmui_bundle_update_suggestors/dart_script_updater.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,20 @@ class DartScriptUpdater extends RecursiveAstVisitor<void>
final String existingScriptPath;
final String newScriptPath;

DartScriptUpdater(this.existingScriptPath, this.newScriptPath);
/// Whether or not to update attributes on script/link tags (like type/crossorigin)
/// while also updating the script path.
final bool updateAttributes;
final bool removeTag;

DartScriptUpdater(this.existingScriptPath, this.newScriptPath,
{this.updateAttributes = true})
: removeTag = false;

/// Use this constructor to remove the whole tag instead of updating it.
DartScriptUpdater.remove(this.existingScriptPath)
: removeTag = true,
updateAttributes = false,
newScriptPath = 'will be ignored';

@override
void visitSimpleStringLiteral(SimpleStringLiteral node) {
Expand All @@ -39,74 +52,107 @@ class DartScriptUpdater extends RecursiveAstVisitor<void>
...Script(pathSubpattern: existingScriptPath)
.pattern
.allMatches(stringValue),
...Script(pathSubpattern: newScriptPath).pattern.allMatches(stringValue)
...?(!removeTag
? Script(pathSubpattern: newScriptPath)
.pattern
.allMatches(stringValue)
: null)
];
final relevantLinkTags = [
...Link(pathSubpattern: existingScriptPath)
.pattern
.allMatches(stringValue),
...Link(pathSubpattern: newScriptPath).pattern.allMatches(stringValue)
...?(!removeTag
? Link(pathSubpattern: newScriptPath).pattern.allMatches(stringValue)
: null)
];

// Do not update if neither the existingScriptPath nor newScriptPath are in the file.
if (relevantScriptTags.isEmpty && relevantLinkTags.isEmpty) return;

// Add type="module" attribute to script tag.
for (final scriptTagMatch in relevantScriptTags) {
final scriptTag = scriptTagMatch.group(0);
if (scriptTag == null) continue;
final typeAttributes = getAttributePattern('type').allMatches(scriptTag);
if (typeAttributes.isNotEmpty) {
final attribute = typeAttributes.first;
final value = attribute.group(1);
if (value == 'module') {
continue;
if (removeTag) {
for (final tag in [...relevantScriptTags, ...relevantLinkTags]) {
final tagEnd = node.offset + tag.end;
final tagStart = node.offset + tag.start;
final possibleCommaEnd = node.literal.next.toString() == ',' ? 1 : 0;
final isTagSameAsNode =
// Check if the only difference between [tag] and [node] is the quotes around [node].
tagStart - node.offset <= 1 && node.end - tagEnd <= 1;

yieldPatch(
'',
// If [tag] spans the whole string literal in [node], then also include
// the quotes and comma in the removal.
isTagSameAsNode
// Remove from the end of the previous token to take any preceding newline with it,
// so that we don't leave behind an empty line.
? node.beginToken.previous?.end ?? node.offset
: tagStart,
isTagSameAsNode ? (node.end + possibleCommaEnd) : tagEnd,
);
}
return;
}

if (updateAttributes) {
// Add type="module" attribute to script tag.
for (final scriptTagMatch in relevantScriptTags) {
final scriptTag = scriptTagMatch.group(0);
if (scriptTag == null) continue;
final typeAttributes =
getAttributePattern('type').allMatches(scriptTag);
if (typeAttributes.isNotEmpty) {
final attribute = typeAttributes.first;
final value = attribute.group(1);
if (value == 'module') {
continue;
} else {
// If the value of the type attribute is not "module", overwrite it.
yieldPatch(
typeModuleAttribute,
node.offset + scriptTagMatch.start + attribute.start,
node.offset + scriptTagMatch.start + attribute.end,
);
}
} else {
// If the value of the type attribute is not "module", overwrite it.
// If the type attribute does not exist, add it.
final srcAttribute = getAttributePattern('src').allMatches(scriptTag);
yieldPatch(
typeModuleAttribute,
node.offset + scriptTagMatch.start + attribute.start,
node.offset + scriptTagMatch.start + attribute.end,
' ${typeModuleAttribute}',
node.offset + scriptTagMatch.start + srcAttribute.first.end,
node.offset + scriptTagMatch.start + srcAttribute.first.end,
);
}
} else {
// If the type attribute does not exist, add it.
final srcAttribute = getAttributePattern('src').allMatches(scriptTag);
yieldPatch(
' ${typeModuleAttribute}',
node.offset + scriptTagMatch.start + srcAttribute.first.end,
node.offset + scriptTagMatch.start + srcAttribute.first.end,
);
}
}

// Add crossorigin="" attribute to link tag.
for (final linkTagMatch in relevantLinkTags) {
final linkTag = linkTagMatch.group(0);
if (linkTag == null) continue;
final crossOriginAttributes =
getAttributePattern('crossorigin').allMatches(linkTag);
if (crossOriginAttributes.isNotEmpty) {
final attribute = crossOriginAttributes.first;
final value = attribute.group(1);
if (value == '') {
continue;
// Add crossorigin="" attribute to link tag.
for (final linkTagMatch in relevantLinkTags) {
final linkTag = linkTagMatch.group(0);
if (linkTag == null) continue;
final crossOriginAttributes =
getAttributePattern('crossorigin').allMatches(linkTag);
if (crossOriginAttributes.isNotEmpty) {
final attribute = crossOriginAttributes.first;
final value = attribute.group(1);
if (value == '') {
continue;
} else {
// If the value of the crossorigin attribute is not "", overwrite it.
yieldPatch(
crossOriginAttribute,
node.offset + linkTagMatch.start + attribute.start,
node.offset + linkTagMatch.start + attribute.end,
);
}
} else {
// If the value of the crossorigin attribute is not "", overwrite it.
// If the crossorigin attribute does not exist, add it.
final hrefAttribute = getAttributePattern('href').allMatches(linkTag);
yieldPatch(
crossOriginAttribute,
node.offset + linkTagMatch.start + attribute.start,
node.offset + linkTagMatch.start + attribute.end,
' ${crossOriginAttribute}',
node.offset + linkTagMatch.start + hrefAttribute.first.end,
node.offset + linkTagMatch.start + hrefAttribute.first.end,
);
}
} else {
// If the crossorigin attribute does not exist, add it.
final hrefAttribute = getAttributePattern('href').allMatches(linkTag);
yieldPatch(
' ${crossOriginAttribute}',
node.offset + linkTagMatch.start + hrefAttribute.first.end,
node.offset + linkTagMatch.start + hrefAttribute.first.end,
);
}
}

Expand Down
Loading