Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/solid_generator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
33 changes: 32 additions & 1 deletion packages/solid_generator/lib/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1153,7 +1153,38 @@ RewriteResult _resultForClass(
Map<String, Map<String, Set<String>>> classQueryNamesOrigins,
Set<String> 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 <String>{},
emitsDisposable: false,
constCtorNames: const <String>{},
);
}
return _rewriteClass(
c.decl,
c.fields,
Expand Down
104 changes: 104 additions & 0 deletions packages/solid_generator/lib/src/cross_file_consumer_rewriter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Set<String>> classRegistry,
required Map<String, Set<String>> classCollectionFields,
Map<String, Map<String, Set<String>>> classRegistryOrigins = const {},
Map<String, Map<String, Set<String>>> classCollectionFieldsOrigins = const {},
Set<String> classRegistryShadowedNames = const {},
Map<String, Set<String>> classQueryNames = const {},
Map<String, Map<String, Set<String>>> classQueryNamesOrigins = const {},
Set<String> 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 = <ValueEdit>[];
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 <String>{},
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 <String>{},
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
Expand Down
2 changes: 1 addition & 1 deletion packages/solid_generator/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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}'),
);
}
}
Original file line number Diff line number Diff line change
@@ -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<int>(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;
}
Original file line number Diff line number Diff line change
@@ -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<int>(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}');
},
),
);
}
}
2 changes: 2 additions & 0 deletions packages/solid_generator/test/integration/golden_helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ const List<String> goldenNames = <String>[
'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',
Expand Down
Loading