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
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1109,7 +1109,7 @@ When a generated piece of code (anything under `lib/`) references a reactive val

### 5.1 Identifier rewrite

The rewrite is **type-driven and chain-aware**: any expression position whose resolved static type is `SignalBase<T>` or a subtype (`Signal<T>`, `Computed<T>`, `ReadSignal<T>`) from `package:flutter_solidart` receives a `.value` append at that position. The rule applies uniformly to:
The rewrite is **type-driven and chain-aware**: any expression position whose resolved static type is `SignalBase<T>` or a subtype (`Signal<T>`, `Computed<T>`, `ReadSignal<T>`) receives a `.value` append at that position. `SignalBase` and its subtypes are declared in `package:solidart`; `package:flutter_solidart` re-exports them, and the analyzer reports the declaring library regardless of which package the source imports from. The rule applies uniformly to:

- Bare `SimpleIdentifier`s — same-class same-class `@SolidState` reads (M1 case).
- `PrefixedIdentifier` and `PropertyAccess` chains of arbitrary depth — cross-class reactive reads (M6 case, e.g. an `@SolidEnvironment` field whose type carries `@SolidState` declarations).
Expand Down
52 changes: 52 additions & 0 deletions packages/integration_tests/lib/foreign_signal_app.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_solidart/flutter_solidart.dart';
import 'package:provider/provider.dart';

class Gate {
/// Tri-state: `null` means "not known yet".
final Signal<bool?> complete = Signal(null);

// `complete` is hand-written, so the generator synthesizes no disposal for
// it — the owner disposes it, and the provider scope's `dispose:` callback
// calls through to here.
void dispose() => complete.dispose();
}

class GateView extends StatefulWidget {
const GateView({super.key});

@override
State<GateView> createState() => _GateViewState();
}

class _GateViewState extends State<GateView> {
late final gate = context.read<Gate>();
late final ready = Computed<bool>(
() => gate.complete.value ?? false,
name: 'ready',
);

@override
void dispose() {
ready.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Column(
children: [
SignalBuilder(
builder: (context, child) {
return Text('raw: ${gate.complete.value}');
},
),
SignalBuilder(
builder: (context, child) {
return Text('ready: ${ready.value}');
},
),
],
);
}
}
45 changes: 45 additions & 0 deletions packages/integration_tests/source/foreign_signal_app.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// End-to-end source for the foreign-signal reactivity test: a `Gate` holding
// a hand-written `flutter_solidart` `Signal` (NOT `@SolidState` — the
// generator does not manage it) reached through an `@SolidEnvironment`
// receiver.
//
// Both read sites are covered: `build()` reads `gate.complete.value`
// directly and must get a `SignalBuilder` wrap, and the `@SolidState` getter
// `ready` derives from the same foreign signal and must lower to a
// `Computed`. Unlike the golden fixture of the same shape, `flutter_solidart`
// resolves here, so this exercises the resolved-`staticType` tier and the
// multi-level `<env>.<signal>.value` receiver the AST fallback cannot reach.

import 'package:flutter/material.dart';
import 'package:flutter_solidart/flutter_solidart.dart';
import 'package:solid_annotations/solid_annotations.dart';

class Gate {
/// Tri-state: `null` means "not known yet".
final Signal<bool?> complete = Signal(null);

// `complete` is hand-written, so the generator synthesizes no disposal for
// it — the owner disposes it, and the provider scope's `dispose:` callback
// calls through to here.
void dispose() => complete.dispose();
}

class GateView extends StatelessWidget {
GateView({super.key});

@SolidEnvironment()
late Gate gate;

@SolidState()
bool get ready => gate.complete.value ?? false;

@override
Widget build(BuildContext context) {
return Column(
children: [
Text('raw: ${gate.complete.value}'),
Text('ready: $ready'),
],
);
}
}
60 changes: 60 additions & 0 deletions packages/integration_tests/test/foreign_signal_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Runtime fence for reads of a FOREIGN solidart signal — a hand-written
// `Signal` the generator does not manage, reached through an
// `@SolidEnvironment` receiver (`gate.complete.value`).
//
// Before the type-aware reactivity rule the generator counted only its own
// `@SolidState` / `@SolidQuery` members as reactive dependencies, so this
// shape produced a `build()` with NO `SignalBuilder` — the widget read the
// signal outside any tracking context and silently never rebuilt — and a
// `@SolidState` getter deriving from the same signal was rejected outright
// with "has no reactive dependencies".
//
// `Gate` and `GateView` are imported from the generator-lowered
// `package:integration_tests/foreign_signal_app.dart`, so the widget under
// test is the real production output of
// `packages/integration_tests/source/foreign_signal_app.dart`. Unlike the
// paired golden of the same shape, `flutter_solidart` resolves here, which
// makes this the coverage for the resolved-`staticType` tier and for the
// multi-level receiver the AST fallback cannot reach.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_tests/foreign_signal_app.dart';
import 'package:solid_annotations/solid_annotations.dart';

void main() {
testWidgets('mutating a foreign Signal rebuilds both the direct build() '
'read and the @SolidState getter derived from it', (tester) async {
final gate = Gate();

await tester.pumpWidget(
MaterialApp(
home: const GateView().environment<Gate>(
(_) => gate,
dispose: (_, g) => g.dispose(),
),
),
);

expect(find.text('raw: null'), findsOneWidget);
expect(find.text('ready: false'), findsOneWidget);

gate.complete.value = true;
await tester.pump();

expect(
find.text('raw: true'),
findsOneWidget,
reason:
'the direct `gate.complete.value` read in build() must be '
'wrapped in a SignalBuilder',
);
expect(
find.text('ready: true'),
findsOneWidget,
reason:
'the @SolidState getter deriving from the foreign signal must '
'lower to a Computed that re-runs',
);
});
}
6 changes: 6 additions & 0 deletions packages/solid_generator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 3.0.0-dev.11

- **FIX**: A read of a signal the generator does NOT manage — a hand-written `Signal` / `Computed` / `Resource` held in a plain field or reached through an `@SolidEnvironment` receiver, e.g. `gate.complete.value` — now counts as a reactive dependency. Reactivity analysis previously recognized only the generator's own `@SolidState` / `@SolidQuery` members, so a `build()` reading a foreign signal got NO `SignalBuilder` and silently never rebuilt, and a `@SolidState` getter deriving from one was rejected with "has no reactive dependencies" instead of lowering to a `Computed`.
- Detection mirrors the `@SolidEnvironment` validator's two tiers: the receiver's resolved `staticType` reaching solidart's `SignalBase`, falling back to the declared type's lexeme when the resolver hasn't run. Every read form is covered — `.value`, `.hasValue`, `.previousValue`, `.hasPreviousValue`, the callable form (`sig()` / `gate.complete()`), and `Resource.state` (the non-deprecated read; upstream `Resource.value` is deprecated). A non-signal receiver (`controller.value`) stays untracked, `.value` on a generator-managed member keeps its existing opt-out meaning, and the documented opt-outs — `on*` callbacks, `untracked(…)`, and `.untrackedValue` — are unchanged. On a foreign signal the `.untracked` getter opts out of the `SignalBuilder` wrap in `build()`, but it is not rewritten to `.untrackedValue`, so a `.untracked` read inside a `Computed` / `@SolidEffect` body still subscribes at runtime.
- Scope: a file the generator never touches at all (no `solid_annotations` import, no provider call site, no cross-file connection to a registered class) still passes through verbatim — this fix only reaches foreign-signal reads inside files the generator already processes for some other reason. `@SolidQuery` also stays asymmetric: a query body reading a foreign signal gets the same tracked-read/correctness treatment as any other reactive body, but — unlike a same-class or cross-class `@SolidState` dependency — the generator does not synthesize a `Resource.source:` from it.

## 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.
Expand Down
16 changes: 0 additions & 16 deletions packages/solid_generator/lib/src/annotation_reader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,6 @@ const String solidQueryName = 'SolidQuery';
/// as [solidStateName].
const String solidEnvironmentName = 'SolidEnvironment';

/// Lexemes of the `flutter_solidart` types whose runtime classes extend
/// `SignalBase<T>`. Matched against the type-annotation lexeme as the
/// unresolved-AST fallback in
/// `target_validator._isSignalBaseTyped`; the Element-based primary path
/// walks `InterfaceType.allSupertypes` when the resolver populated the
/// type. Consumed by the target validator (rejecting
/// `@SolidEnvironment` fields typed as one of these) and by the
/// cross-class `.value` rewrite. Excludes `SignalBuilder` /
/// `SolidartConfig` (those are non-`SignalBase` solidart names).
const Set<String> signalBaseTypeNames = {
'Signal',
'Computed',
'Effect',
'Resource',
};

/// Lexeme of the `Future` return-type identifier on a `@SolidQuery` method.
/// Matched textually on the return-type's [NamedType] lexeme — the user
/// must import `dart:async` (or its re-export via `dart:core`) to write the
Expand Down
39 changes: 39 additions & 0 deletions packages/solid_generator/lib/src/element_utils.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';

/// `true` iff [uri] is a `package:<packageName>/...` URI.
Expand Down Expand Up @@ -31,3 +32,41 @@ bool supertypeChainContains(
}
return false;
}

/// Lexemes of the well-known `flutter_solidart` reactive-primitive types
/// rejected on an `@SolidEnvironment` field / matched as a foreign-signal
/// receiver. Most (`Signal`, `Computed`, `Resource`) extend `SignalBase<T>`;
/// `Effect` does NOT (it `implements ReactionInterface`) but is included
/// here as the same kind of generator-adjacent primitive an env field
/// shouldn't hold directly. Matched against a type-annotation lexeme as the
/// unresolved-AST fallback in `target_validator._isSignalBaseTyped` and
/// `value_rewriter._isSolidartSignalReceiver`; the Element-based primary
/// path — [isSolidartSignalType], which genuinely checks the `SignalBase`
/// supertype chain — does not need this exception and correctly excludes
/// `Effect`. Excludes `SignalBuilder` / `SolidartConfig` (non-reactive
/// solidart names).
const Set<String> signalBaseTypeNames = {
'Signal',
'Computed',
'Effect',
'Resource',
};

/// `true` iff [type] is `SignalBase<T>` or a subtype of it.
///
/// `SignalBase` and its subtypes are declared in `package:solidart`;
/// `package:flutter_solidart` re-exports them. The declaring library — not
/// the importing one — is what an Element reports, so both package names are
/// accepted.
bool isSolidartSignalType(InterfaceType type) {
if (_isSignalBaseElement(type.element)) return true;
for (final supertype in type.allSupertypes) {
if (_isSignalBaseElement(supertype.element)) return true;
}
return false;
}

bool _isSignalBaseElement(InterfaceElement element) =>
element.name == 'SignalBase' &&
(isFromPackage(element.library.uri, 'solidart') ||
isFromPackage(element.library.uri, 'flutter_solidart'));
24 changes: 6 additions & 18 deletions packages/solid_generator/lib/src/target_validator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,8 @@ void _validateEnvironmentField(FieldDeclaration field, String className) {
}
// `SignalBase`-typed environment field detection, two-tier:
// 1. Element-based: when the resolver populated the type, walk
// `allSupertypes` for a `SignalBase` class anchored to the
// `package:flutter_solidart/` library URI. Catches aliased imports
// and user-defined subclasses of `SignalBase`.
// `allSupertypes` for solidart's `SignalBase` class. Catches aliased
// imports and user-defined subclasses of `SignalBase`.
// 2. Textual fallback: when the resolver hasn't run (parsed-AST
// fallback or test sandboxes), match the type name's lexeme against
// [signalBaseTypeNames]. The user-defined subclass case is missed
Expand All @@ -368,23 +367,12 @@ void _validateEnvironmentField(FieldDeclaration field, String className) {
}
}

/// `true` iff [type]'s resolved supertype chain contains a `SignalBase`
/// declared in `package:flutter_solidart/`, or — when the resolver hasn't
/// produced a type — the type's lexeme is in [signalBaseTypeNames].
/// `true` iff [type] resolves to solidart's `SignalBase` or a subtype of it
/// ([isSolidartSignalType]), or — when the resolver hasn't produced a type —
/// the type's lexeme is in [signalBaseTypeNames].
bool _isSignalBaseTyped(NamedType type) {
final resolved = type.type;
if (resolved is InterfaceType) {
final element = resolved.element;
if (element.name == 'SignalBase' &&
isFromPackage(element.library.uri, 'flutter_solidart')) {
return true;
}
return supertypeChainContains(
resolved.allSupertypes,
'SignalBase',
packageName: 'flutter_solidart',
);
}
if (resolved is InterfaceType) return isSolidartSignalType(resolved);
// Type unresolved (parsed-AST fallback / test sandbox). Fall back to
// lexeme match against the well-known set.
return signalBaseTypeNames.contains(type.name.lexeme);
Expand Down
Loading
Loading