[ Widget Preview ] Don't try to instantiate invalid @Preview() applications (#173984)

Applying `@Preview()` to an invalid AST node shouldn't cause the preview
environment to throw an exception due to invalid generated code.

This change adds some additional checks to ensure that invalid
`@Preview()` applications are ignored.

Related issue: https://github.com/flutter/flutter/issues/173959
Related stable hotfix: https://github.com/flutter/flutter/pull/173979
This commit is contained in:
Ben Konyi 2025-08-18 15:49:29 -04:00 committed by GitHub
parent 461ce17941
commit 8b2b9d7c8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 222 additions and 23 deletions

View File

@ -76,6 +76,10 @@ class _PreviewVisitor extends RecursiveAstVisitor<void> {
_scopedVisitChildren(node, (MethodDeclaration? node) => _currentMethod = node);
}
bool hasRequiredParams(FormalParameterList? params) {
return params?.parameters.any((p) => p.isRequired) ?? false;
}
@override
void visitAnnotation(Annotation node) {
final previewsToProcess = <DartObject>[];
@ -88,18 +92,24 @@ class _PreviewVisitor extends RecursiveAstVisitor<void> {
}
for (final preview in previewsToProcess) {
assert(_currentFunction != null || _currentConstructor != null || _currentMethod != null);
if (_currentFunction != null) {
final returnType = _currentFunction!.returnType! as NamedType;
previewEntries.add(
PreviewDetails(
packageName: packageName,
functionName: _currentFunction!.name.toString(),
isBuilder: returnType.name.isWidgetBuilder,
previewAnnotation: preview,
),
);
} else if (_currentConstructor != null) {
if (_currentFunction != null &&
!hasRequiredParams(_currentFunction!.functionExpression.parameters)) {
final TypeAnnotation? returnTypeAnnotation = _currentFunction!.returnType;
if (returnTypeAnnotation is NamedType) {
final Token returnType = returnTypeAnnotation.name;
if (returnType.isWidget || returnType.isWidgetBuilder) {
previewEntries.add(
PreviewDetails(
packageName: packageName,
functionName: _currentFunction!.name.toString(),
isBuilder: returnType.isWidgetBuilder,
previewAnnotation: preview,
),
);
}
}
} else if (_currentConstructor != null &&
!hasRequiredParams(_currentConstructor!.parameters)) {
final returnType = _currentConstructor!.returnType as SimpleIdentifier;
final Token? name = _currentConstructor!.name;
previewEntries.add(
@ -110,17 +120,22 @@ class _PreviewVisitor extends RecursiveAstVisitor<void> {
previewAnnotation: preview,
),
);
} else if (_currentMethod != null) {
final returnType = _currentMethod!.returnType! as NamedType;
final parentClass = _currentMethod!.parent! as ClassDeclaration;
previewEntries.add(
PreviewDetails(
packageName: packageName,
functionName: '${parentClass.name}.${_currentMethod!.name}',
isBuilder: returnType.name.isWidgetBuilder,
previewAnnotation: preview,
),
);
} else if (_currentMethod != null && !hasRequiredParams(_currentMethod!.parameters)) {
final TypeAnnotation? returnTypeAnnotation = _currentMethod!.returnType;
if (returnTypeAnnotation is NamedType) {
final Token returnType = returnTypeAnnotation.name;
if (returnType.isWidget || returnType.isWidgetBuilder) {
final parentClass = _currentMethod!.parent! as ClassDeclaration;
previewEntries.add(
PreviewDetails(
packageName: packageName,
functionName: '${parentClass.name}.${_currentMethod!.name}',
isBuilder: returnType.isWidgetBuilder,
previewAnnotation: preview,
),
);
}
}
}
}
}

View File

@ -22,6 +22,9 @@ extension TokenExtension on Token {
/// Convenience getter to identify WidgetBuilder types.
bool get isWidgetBuilder => toString() == 'WidgetBuilder';
/// Convenience getter to identify Widget types.
bool get isWidget => toString() == 'Widget';
}
extension AnnotationExtension on Annotation {

View File

@ -0,0 +1,181 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/widget_preview/dependency_graph.dart';
import 'package:flutter_tools/src/widget_preview/preview_detector.dart';
import 'package:test/test.dart';
import '../../../../src/common.dart';
import '../../../../src/context.dart';
import '../utils/preview_details_matcher.dart';
import '../utils/preview_detector_test_utils.dart';
import '../utils/preview_project.dart';
// Note: this test isn't under the general.shard since tests under that directory
// have a 2000ms time out and these tests write to the real file system and watch
// directories for changes. This can be slow on heavily loaded machines and cause
// flaky failures.
/// Creates a project with files containing invalid preview applications.
class BasicProjectWithInvalidPreviews extends WidgetPreviewProject {
BasicProjectWithInvalidPreviews._({
required super.projectRoot,
required List<String> pathsWithPreviews,
required List<String> pathsWithoutPreviews,
}) {
final initialSources = <WidgetPreviewSourceFile>[];
for (final path in pathsWithPreviews) {
initialSources.add((path: path, source: _invalidPreviewContainingFileContents));
librariesWithPreviews.add(toPreviewPath(path));
}
for (final path in pathsWithoutPreviews) {
initialSources.add((path: path, source: _emptySource));
librariesWithoutPreviews.add(toPreviewPath(path));
}
initialSources.forEach(writeFile);
}
static Future<BasicProjectWithInvalidPreviews> create({
required Directory projectRoot,
required List<String> pathsWithPreviews,
required List<String> pathsWithoutPreviews,
}) async {
final project = BasicProjectWithInvalidPreviews._(
projectRoot: projectRoot,
pathsWithPreviews: pathsWithPreviews,
pathsWithoutPreviews: pathsWithoutPreviews,
);
await project.initializePubspec();
return project;
}
final librariesWithPreviews = <PreviewPath>{};
final librariesWithoutPreviews = <PreviewPath>{};
/// Adds a file containing previews at [path].
void addPreviewContainingFile({required String path}) {
writeFile((path: path, source: _invalidPreviewContainingFileContents));
final PreviewPath previewPath = toPreviewPath(path);
librariesWithoutPreviews.remove(previewPath);
librariesWithPreviews.add(previewPath);
}
Map<PreviewPath, List<PreviewDetailsMatcher>> get matcherMapping =>
<PreviewPath, List<PreviewDetailsMatcher>>{
for (final PreviewPath path in librariesWithPreviews) path: [],
};
static const _emptySource = '''
void main() {}
''';
static const _invalidPreviewContainingFileContents = '''
@Preview(name: 'Invalid preview on class declaration')
class ClassDeclaration extends StatelessWidget {
@Preview(name: 'Invalid preview on constructor with required parameters')
ClassDeclaration(int i);
@Preview(name: 'Invalid preview on getter');
int get foo => 1;
@Preview(name: 'Invalid preview on setter');
set foo(x) {
print('foo set');
};
@Preview(name: 'Invalid preview on field')
final int bar = 2;
@Preview(name: 'Invalid preview on member function')
Widget memberFunction() => Text('Member');
@override
Widget build(BuildContext context) => Text('Foo');
}
@Preview(name: 'Invalid preview on function with void return')
void previews() => Text('Foo');
@Preview(name: 'Invalid preview on function with parameter')
Widget foo(int bar) => Text('Foo');
@Preview(name: 'Invalid preview on extension')
extension on ClassDeclaration {}
''';
}
void main() {
initializeTestPreviewDetectorState();
group('$PreviewDetector', () {
// Note: we don't use a MemoryFileSystem since we don't have a way to
// provide it to package:analyzer APIs without writing a significant amount
// of wrapper logic.
late PreviewDetector previewDetector;
late BasicProjectWithInvalidPreviews project;
setUp(() {
previewDetector = createTestPreviewDetector();
});
tearDown(() async {
await previewDetector.dispose();
});
testUsingContext('ignores invalid previews in existing files', () async {
project = await BasicProjectWithInvalidPreviews.create(
projectRoot: previewDetector.projectRoot,
pathsWithPreviews: <String>['foo.dart'],
pathsWithoutPreviews: <String>[],
);
final PreviewDependencyGraph mapping = await previewDetector.initialize();
expectContainsPreviews(mapping, project.matcherMapping);
});
testUsingContext('ignores invalid previews in updated files', () async {
project = await BasicProjectWithInvalidPreviews.create(
projectRoot: previewDetector.projectRoot,
pathsWithPreviews: <String>[],
pathsWithoutPreviews: <String>['foo.dart'],
);
// Initialize the file watcher.
final PreviewDependencyGraph initialPreviews = await previewDetector.initialize();
expectContainsPreviews(initialPreviews, project.matcherMapping);
await waitForChangeDetected(
onChangeDetected: (PreviewDependencyGraph updated) {
// There should be no valid previews in foo.dart.
expectContainsPreviews(updated, project.matcherMapping);
},
changeOperation: () => project.addPreviewContainingFile(path: 'foo.dart'),
);
});
testUsingContext('ignores invalid previews in newly added files', () async {
project = await BasicProjectWithInvalidPreviews.create(
projectRoot: previewDetector.projectRoot,
pathsWithPreviews: <String>[],
pathsWithoutPreviews: <String>[],
);
// The initial mapping should be empty as there's no files containing previews.
const expectedInitialMapping = <PreviewPath, LibraryPreviewNode>{};
// Initialize the file watcher.
final PreviewDependencyGraph initialPreviews = await previewDetector.initialize();
expect(initialPreviews, expectedInitialMapping);
await waitForChangeDetected(
onChangeDetected: (PreviewDependencyGraph updated) {
// There should be no valid previews in baz.dart.
expectContainsPreviews(updated, project.matcherMapping);
},
// Create baz.dart, which contains previews.
changeOperation: () => project.addPreviewContainingFile(path: 'baz.dart'),
);
});
});
}