From 0b43048d0d50a985cab677d6cbae8b29e4036323 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Sat, 29 Aug 2026 14:57:29 +0700 Subject: [PATCH] fix(solid_generator): lower cross-instance @SolidState in mixed-file pure consumers Whole-file pure-consumer lowering only ran when NO class in the file was annotated. In a mixed file, a no-annotation consumer that reaches another class's @SolidState through a plain constructor/instance field was passed through verbatim, so a cross-instance write (vm.x = v) hit assignment_to_final on the generated final Signal, and reads stayed non-reactive. Adds a per-class lowerPureConsumerClass (mirroring the whole-file collectors) that _resultForClass invokes for no-annotation classes, so the same .value lowering + SignalBuilder wrap fires in mixed files too. Adds mixed_file_pure_consumer_{widget,plain_class} goldens. Reviewed by 2 adversarial reviewers (0 findings; plain-class golden added on rec). --- packages/solid_generator/CHANGELOG.md | 4 + packages/solid_generator/lib/builder.dart | 33 +++++- .../lib/src/cross_file_consumer_rewriter.dart | 104 ++++++++++++++++++ packages/solid_generator/pubspec.yaml | 2 +- .../mixed_file_pure_consumer_plain_class.dart | 27 +++++ .../mixed_file_pure_consumer_widget.dart | 33 ++++++ ...ixed_file_pure_consumer_plain_class.g.dart | 21 ++++ .../mixed_file_pure_consumer_widget.g.dart | 30 +++++ .../test/integration/golden_helpers.dart | 2 + 9 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 packages/solid_generator/test/golden/inputs/mixed_file_pure_consumer_plain_class.dart create mode 100644 packages/solid_generator/test/golden/inputs/mixed_file_pure_consumer_widget.dart create mode 100644 packages/solid_generator/test/golden/outputs/mixed_file_pure_consumer_plain_class.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/mixed_file_pure_consumer_widget.g.dart diff --git a/packages/solid_generator/CHANGELOG.md b/packages/solid_generator/CHANGELOG.md index cb71678..9194f0b 100644 --- a/packages/solid_generator/CHANGELOG.md +++ b/packages/solid_generator/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0-dev.10 + +- **FIX**: A pure-consumer class that reaches a registered class's reactive members through a plain instance/constructor field (the common Flutter DI shape) now lowers even when it is co-located with annotated classes in the same file. Whole-file pure-consumer lowering only ran when NO class in the file was annotated; in a mixed file the consumer was passed through verbatim, so a cross-instance `@SolidState` write (`vm.field = x`) hit `assignment_to_final` on the generated `final Signal`, and a read stayed non-reactive. The no-annotation branch now applies the same per-class `.value` lowering + `SignalBuilder` wrap. + ## 3.0.0-dev.9 - **FIX**: A documentation-comment reference to a reactive field (`/// … [count] …`) is no longer rewritten to `[count.value]` — the value rewriter skips comment references (which resolve as declarations, not runtime reads), so the generated doc comment stays a valid reference. The member body still gets its `.value` append. diff --git a/packages/solid_generator/lib/builder.dart b/packages/solid_generator/lib/builder.dart index 8d42575..bc44d4c 100644 --- a/packages/solid_generator/lib/builder.dart +++ b/packages/solid_generator/lib/builder.dart @@ -1153,7 +1153,38 @@ RewriteResult _resultForClass( Map>> classQueryNamesOrigins, Set classQueryNamesShadowedNames, ) { - if (c.hasNoAnnotations) return _passthroughResult(c.decl, source); + if (c.hasNoAnnotations) { + // A no-annotation class can still be a PURE CONSUMER of a registered + // class's reactive members through a plain instance/constructor field + // (the common Flutter DI shape). Whole-file [lowerPureConsumers] only + // runs when NO class in the file is annotated; in a MIXED file this + // per-class lowering is the only path that reaches such a consumer, so a + // cross-class read/write (e.g. `vm.query = value`) still lowers instead + // of silently emitting `assignment_to_final` on the generated Signal. + final lowered = lowerPureConsumerClass( + c.decl, + source, + classRegistry: classRegistry, + classCollectionFields: classCollectionFields, + classRegistryOrigins: classRegistryOrigins, + classCollectionFieldsOrigins: classCollectionFieldsOrigins, + classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, + ); + return ( + text: lowered.text, + // A placed `SignalBuilder` wrap needs `flutter_solidart` in the output + // import block (keyed off `solidartNames` in `_renderOutput`); a + // `.value`-only lowering introduces no new identifier and needs none. + solidartNames: lowered.emittedSignalBuilder + ? const {'SignalBuilder'} + : const {}, + emitsDisposable: false, + constCtorNames: const {}, + ); + } return _rewriteClass( c.decl, c.fields, diff --git a/packages/solid_generator/lib/src/cross_file_consumer_rewriter.dart b/packages/solid_generator/lib/src/cross_file_consumer_rewriter.dart index f6e33be..23cac9d 100644 --- a/packages/solid_generator/lib/src/cross_file_consumer_rewriter.dart +++ b/packages/solid_generator/lib/src/cross_file_consumer_rewriter.dart @@ -99,6 +99,110 @@ PureConsumerLowering lowerPureConsumers( ); } +/// Per-class counterpart of [lowerPureConsumers]: lowers ONE pure-consumer +/// class's slice. +/// +/// [lowerPureConsumers] only runs when the WHOLE file carries no reactive +/// annotations. A pure consumer co-located with annotated classes in a MIXED +/// file (e.g. a presentation `StatelessWidget` that writes another object's +/// `@SolidState` through a plain constructor field, declared next to the +/// annotated widget that owns the query) never reaches it, so its cross-class +/// reads/writes stay un-lowered — a `vm.field = x` write then hits +/// `assignment_to_final` on the generated `final Signal`. `builder.dart` calls +/// this per no-annotation class so the same lowering fires there too. +/// +/// [decl] is the consumer class; [text] is the full source (edit offsets are +/// absolute, rebased onto the returned slice). Returns the (possibly unchanged) +/// class slice and whether a `SignalBuilder` wrap was placed on its `build`. +/// Only [ClassKind.statelessWidget] / [ClassKind.stateClass] (via `build`) and +/// [ClassKind.plainClass] (via its constructors/methods) are lowered — the same +/// scope [collectPureConsumerWidgetEdits] / [collectPureConsumerCrossFileEdits] +/// cover whole-file; any other kind round-trips verbatim. +({String text, bool emittedSignalBuilder}) lowerPureConsumerClass( + ClassDeclaration decl, + String text, { + required Map> classRegistry, + required Map> classCollectionFields, + Map>> classRegistryOrigins = const {}, + Map>> classCollectionFieldsOrigins = const {}, + Set classRegistryShadowedNames = const {}, + Map> classQueryNames = const {}, + Map>> classQueryNamesOrigins = const {}, + Set classQueryNamesShadowedNames = const {}, +}) { + final slice = text.substring(decl.offset, decl.end); + // Nothing to resolve receivers against → verbatim (mirrors the collectors' + // guard; issue #110 keeps shadowed-name sets in the condition). + if (classRegistry.isEmpty && + classRegistryShadowedNames.isEmpty && + classQueryNames.isEmpty && + classQueryNamesShadowedNames.isEmpty) { + return (text: slice, emittedSignalBuilder: false); + } + final kind = classKindOf(decl); + final isWidget = + kind == ClassKind.statelessWidget || kind == ClassKind.stateClass; + if (!isWidget && kind != ClassKind.plainClass) { + return (text: slice, emittedSignalBuilder: false); + } + + final edits = []; + var emittedSignalBuilder = false; + for (final member in decl.members) { + // A widget's `build` needs the `SignalBuilder`-wrap placement (a bare + // `.value` read would look fixed while staying non-reactive), so it goes + // through [rewriteBuildMethod]; every other member gets the plain + // per-identifier `.value` lowering. + if (isWidget && + member is MethodDeclaration && + member.name.lexeme == 'build') { + final original = text.substring(member.offset, member.end); + final rewritten = rewriteBuildMethod( + member, + const {}, + text, + classRegistry: classRegistry, + classCollectionFields: classCollectionFields, + classRegistryOrigins: classRegistryOrigins, + classCollectionFieldsOrigins: classCollectionFieldsOrigins, + classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, + ); + if (rewritten.emittedWrap) emittedSignalBuilder = true; + if (rewritten.text != original) { + edits.add(ValueEdit(member.offset, member.end, rewritten.text)); + } + continue; + } + if (member is ConstructorDeclaration) { + if (member.factoryKeyword != null) continue; + } else if (member is! MethodDeclaration) { + continue; + } + final result = collectValueEdits( + member, + const {}, + text, + classRegistry: classRegistry, + classCollectionFields: classCollectionFields, + classRegistryOrigins: classRegistryOrigins, + classCollectionFieldsOrigins: classCollectionFieldsOrigins, + classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, + ); + edits.addAll(result.edits); + } + if (edits.isEmpty) return (text: slice, emittedSignalBuilder: false); + return ( + text: applyEditsToRange(slice, edits, decl.offset), + emittedSignalBuilder: emittedSignalBuilder, + ); +} + /// Collects `.value`-lowering edits for a PURE CONSUMER plain class — one /// that reaches a cross-file `@SolidState`-bearing class only through /// constructor injection or a plain instance field, while declaring NO diff --git a/packages/solid_generator/pubspec.yaml b/packages/solid_generator/pubspec.yaml index 54c1da7..b528bea 100644 --- a/packages/solid_generator/pubspec.yaml +++ b/packages/solid_generator/pubspec.yaml @@ -1,6 +1,6 @@ name: solid_generator description: Solid source-to-lib code generator for Flutter reactive state. -version: 3.0.0-dev.9 +version: 3.0.0-dev.10 homepage: https://solid.mariuti.com repository: https://github.com/nank1ro/solid issue_tracker: https://github.com/nank1ro/solid/issues diff --git a/packages/solid_generator/test/golden/inputs/mixed_file_pure_consumer_plain_class.dart b/packages/solid_generator/test/golden/inputs/mixed_file_pure_consumer_plain_class.dart new file mode 100644 index 0000000..7a0af41 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/mixed_file_pure_consumer_plain_class.dart @@ -0,0 +1,27 @@ +// The plain-class (non-widget) sibling of `mixed_file_pure_consumer_widget`: +// a pure-consumer plain class that reads/writes another class's `@SolidState` +// through a constructor-injected field, co-located in the SAME file as the +// annotated class. Exercises the `ClassKind.plainClass` branch of the per-class +// `lowerPureConsumerClass` (a mixed file never reaches the whole-file +// `lowerPureConsumers`). Without it, `_counter.count = …` stays un-lowered and +// hits `assignment_to_final` on the generated `final Signal`. +// +// A pure consumer owns no reactive member, so `Controller` must NOT gain +// `implements Disposable` — only `.value` lowering applies (no `SignalBuilder`, +// no wrap — that is a widget-`build`-only concern). +import 'package:solid_annotations/solid_annotations.dart'; + +class Counter { + @SolidState() + int count = 0; +} + +class Controller { + Controller(this._counter); + + final Counter _counter; + + int read() => _counter.count; + + void increment() => _counter.count = _counter.count + 1; +} diff --git a/packages/solid_generator/test/golden/inputs/mixed_file_pure_consumer_widget.dart b/packages/solid_generator/test/golden/inputs/mixed_file_pure_consumer_widget.dart new file mode 100644 index 0000000..abb72d6 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/mixed_file_pure_consumer_widget.dart @@ -0,0 +1,33 @@ +// A pure-consumer `StatelessWidget` co-located in the SAME file as the +// annotated class it reads/writes. Whole-file `lowerPureConsumers` only fires +// when NO class in the file is annotated, so this mixed-file shape exercises +// the per-class `lowerPureConsumerClass` path (the no-annotation branch of +// `builder.dart::_resultForClass`). Without it, `counter.count = …` stays +// un-lowered and hits `assignment_to_final` on the generated `final Signal`, +// and the `counter.count` read stays non-reactive (no `SignalBuilder` wrap). +// +// `CounterView` owns no reactive member, so it must NOT be lifted to a +// `StatefulWidget` and must NOT gain `implements Disposable` — a pure consumer +// disposes nothing. Only `.value` lowering + the `build` wrap apply. +import 'package:flutter/widgets.dart'; + +import 'package:solid_annotations/solid_annotations.dart'; + +class Counter { + @SolidState() + int count = 0; +} + +class CounterView extends StatelessWidget { + const CounterView({required this.counter, super.key}); + + final Counter counter; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => counter.count = counter.count + 1, + child: Text('${counter.count}'), + ); + } +} diff --git a/packages/solid_generator/test/golden/outputs/mixed_file_pure_consumer_plain_class.g.dart b/packages/solid_generator/test/golden/outputs/mixed_file_pure_consumer_plain_class.g.dart new file mode 100644 index 0000000..4e1a51f --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/mixed_file_pure_consumer_plain_class.g.dart @@ -0,0 +1,21 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class Counter implements Disposable { + final count = Signal(0, name: 'count'); + + @override + void dispose() { + count.dispose(); + } +} + +class Controller { + Controller(this._counter); + + final Counter _counter; + + int read() => _counter.count.value; + + void increment() => _counter.count.value = _counter.count.value + 1; +} diff --git a/packages/solid_generator/test/golden/outputs/mixed_file_pure_consumer_widget.g.dart b/packages/solid_generator/test/golden/outputs/mixed_file_pure_consumer_widget.g.dart new file mode 100644 index 0000000..55278d6 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/mixed_file_pure_consumer_widget.g.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class Counter implements Disposable { + final count = Signal(0, name: 'count'); + + @override + void dispose() { + count.dispose(); + } +} + +class CounterView extends StatelessWidget { + const CounterView({required this.counter, super.key}); + + final Counter counter; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => counter.count.value = counter.count.value + 1, + child: SignalBuilder( + builder: (context, child) { + return Text('${counter.count.value}'); + }, + ), + ); + } +} diff --git a/packages/solid_generator/test/integration/golden_helpers.dart b/packages/solid_generator/test/integration/golden_helpers.dart index 59c6230..3e855c3 100644 --- a/packages/solid_generator/test/integration/golden_helpers.dart +++ b/packages/solid_generator/test/integration/golden_helpers.dart @@ -161,6 +161,8 @@ const List goldenNames = [ 'cross_file_pure_consumer_widget_and_static', 'cross_file_pure_consumer_widget_show_restricted_import', 'cross_file_pure_consumer_widget_query', + 'mixed_file_pure_consumer_widget', + 'mixed_file_pure_consumer_plain_class', 'cross_file_query_qualified_registry_disambiguation', 'cross_file_pure_consumer_widget_query_previous_state', 'cross_file_super_param_bare_pure_consumer',