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_annotations/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 3.0.0-dev.3

- **FEAT**: Add a `previousState` source-time stub on the `@SolidQuery` tear-off (`<query>.previousState`), mirroring `Resource.previousState` — the `ResourceState<T>?` immediately before the current one. With the default `useRefreshing: true` this retains the last `ready` value across a failed refresh (a `ResourceError` otherwise drops it); with `useRefreshing: false` a refresh re-enters `loading` immediately, so `previousState` is loading during that window, not the last ready value. Reads as `<query>.previousState?.asReady?.value` and typechecks identically source- and lib-side via the existing `FutureWhen`/`StreamWhen` state accessors.

## 3.0.0-dev.2

- **DOCS**: Update `WidgetEnvironment.environment()` doc comment for `solid_generator`'s type-aware `dispose:` auto-injection.
Expand Down
20 changes: 20 additions & 0 deletions packages/solid_annotations/lib/src/query_extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,20 @@ extension RefreshFuture<T> on Future<T> Function() {
Future<void> refresh() {
throw Exception(_stubMessage);
}

/// Source-time stub for `<query>.previousState` on a Future-form query. After
/// lowering, `<query>` is a `Resource<T>` and this resolves to
/// `Resource.previousState` — the `ResourceState<T>?` just before the current
/// one. With the default `@SolidQuery(useRefreshing: true)`, this retains the
/// last `ready` value across a failed refresh (a `ResourceError` otherwise
/// drops it). With `useRefreshing: false`, a refresh re-enters `loading`
/// immediately, so `previousState` is `ResourceLoading` during that window —
/// NOT the last ready value. The `Future<T>?` return reuses the [FutureWhen]
/// state accessors, so `<query>.previousState?.asReady?.value` typechecks
/// identically source- and lib-side.
Future<T>? get previousState {
throw Exception(_stubMessage);
}
}

/// Stub `.refresh()` on a `Stream<T> Function()` tear-off. Same shape as
Expand All @@ -196,6 +210,12 @@ extension RefreshStream<T> on Stream<T> Function() {
Future<void> refresh() {
throw Exception(_stubMessage);
}

/// Source-time stub for `<query>.previousState` on a Stream-form query. See
/// [RefreshFuture.previousState].
Stream<T>? get previousState {
throw Exception(_stubMessage);
}
}

/// Library-private placeholder mirroring the public surface of
Expand Down
2 changes: 1 addition & 1 deletion packages/solid_annotations/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: solid_annotations
description: Annotations for the solid transpiler to enable fine-grained reactivity in Flutter applications.
version: 3.0.0-dev.2
version: 3.0.0-dev.3
homepage: https://solid.mariuti.com
repository: https://github.com/nank1ro/solid
issue_tracker: https://github.com/nank1ro/solid/issues
Expand Down
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.7

- **FEAT**: A `build()` reading `<query>.previousState` (the `solid_annotations` `previousState` tear-off) now gets a `SignalBuilder` wrap and `flutter_solidart` import even with no `<query>()` call anywhere in the same build — same-class and cross-instance — since `Resource.previousState` is reactive at the signal level. No source edit; the tear-off resolves to `Resource.previousState` unchanged. `<query>.refresh` stays untracked.

## 3.0.0-dev.6

- **FEAT**: A widget `build()` can read another class's `@SolidQuery` method cross-instance (e.g. `viewModel.customers().isLoading`) and now gets the `SignalBuilder` wrap and `flutter_solidart` import it needs, while the `query()` call sites and the `query.refresh` tear-off stay byte-identical (no `.value` rewrite). Previously only the class declaring a query could consume it, even though cross-instance `@SolidState` reads already worked. The cross-file query-name registry is origin-qualified exactly like the `@SolidState` registry (#110 parity): an ambiguous simple name resolves only on a receiver's resolved-library match, so a same-named non-query method is never spuriously tracked.
Expand Down
62 changes: 62 additions & 0 deletions packages/solid_generator/lib/src/value_rewriter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,20 @@ const String _untrackedValueGetterName = 'untrackedValue';
/// tracking context.
const String _untrackedStateGetterName = 'untrackedState';

/// The `previousState` getter `solid_annotations` exposes on the
/// `RefreshFuture<T>`/`RefreshStream<T>` query tear-off
/// (`<query>.previousState`). After lowering this resolves directly to
/// `Resource.previousState`, which IS reactive at the signal level
/// (`ReadSignal.previousValue` reports observed) — so a bare
/// `<query>.previousState` read (same-class) or
/// `<receiver>.<queryName>.previousState` read (cross-instance) is a
/// tracked read for `SignalBuilder` placement, mirroring
/// [_trackedSignalApiGetters]'s `.hasValue` / `.previousValue` treatment of
/// a `@SolidState` field. Query counterpart of [_untrackedStateGetterName];
/// must NOT be confused with `<query>.refresh`, which stays untracked (an
/// action, not a reactive read).
const String _queryPreviousStateGetterName = 'previousState';

/// `SignalBase<T>` getter names that take a reactive receiver as-is, so a
/// bare tracked-field access followed by any of them must skip the `.value`
/// append. A type-driven rewriter would derive this from the resolved
Expand Down Expand Up @@ -703,6 +717,13 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor<void> {
// [_isUntrackedQueryCall].
return;
}
// Cross-instance `<receiver>.<queryName>.previousState` — the
// PropertyAccess counterpart of [visitPrefixedIdentifier]'s same-class
// branch. A tracked read with NO source edit.
if (node.propertyName.name == _queryPreviousStateGetterName &&
_untrackedDepth == 0) {
_maybeRecordCrossClassPreviousState(node);
}
// Multi-level cross-class chain rewrite. `a.b.c.d` parses as
// PropertyAccess(target=PropertyAccess(target=PrefixedIdentifier(a, b),
// property=c), property=d); `getController().field` parses as
Expand Down Expand Up @@ -776,6 +797,36 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor<void> {
}
}

/// Cross-instance `<receiver>.<queryName>.previousState` detector — the
/// query counterpart of [_isCrossClassQueryCall], but for the
/// PropertyAccess tear-off shape (`.previousState`) instead of the
/// MethodInvocation call shape (`()`). [node] is the outer `.previousState`
/// PropertyAccess; its target must be the `<receiver>.<queryName>`
/// PrefixedIdentifier — the only chain shape recognized here, mirroring
/// the single-level scope [_maybeRewriteCrossClass] keeps for
/// `@SolidState` fields. Records the tracked-read offset with NO source
/// edit when the target prefix's resolved declared type names a class
/// whose query set (via [_queryNamesForCrossClassName]) contains the
/// target identifier's name.
void _maybeRecordCrossClassPreviousState(PropertyAccess node) {
final target = node.target;
if (target is! PrefixedIdentifier) return;
if (_isShadowed(target.prefix.name)) return;
final receiverType = _resolveReceiverType(target.prefix);
final declaredTypeName =
receiverType?.name ?? _environmentFields[target.prefix.name];
if (declaredTypeName == null) return;
final queryNamesOfType = _queryNamesForCrossClassName(
declaredTypeName,
receiverType?.libraryUri,
);
if (queryNamesOfType == null ||
!queryNamesOfType.contains(target.identifier.name)) {
return;
}
_recordTrackedRead(node.offset, target.identifier.name);
}

@override
void visitPrefixedIdentifier(PrefixedIdentifier node) {
// Rewrite `<reactiveField>.untracked` to `<field>.untrackedValue` (the
Expand All @@ -796,6 +847,17 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor<void> {
// to the prefix, corrupting the replacement just emitted.
return;
}
// Same-class `<queryName>.previousState` — a tracked read with NO
// source edit (see [_queryPreviousStateGetterName]). Must be an exact
// name match against `previousState`, never `refresh` — the tear-off
// shape is otherwise identical (`<queryName>.<getterOrMethod>`) and
// `refresh` must stay untracked.
if (node.identifier.name == _queryPreviousStateGetterName &&
_queryNames.contains(node.prefix.name) &&
!_isShadowed(node.prefix.name) &&
_untrackedDepth == 0) {
_recordTrackedRead(node.offset, node.prefix.name);
}
// Cross-class single-level slice: if the prefix is a `SimpleIdentifier`
// resolving to either a method parameter OR a host-class
// `@SolidEnvironment` field whose declared type names a class in
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.6
version: 3.0.0-dev.7
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,30 @@
// Cross-instance `<receiver>.<queryName>.previousState` — the tear-off
// counterpart of `cross_file_pure_consumer_widget_query`'s call-form test.
// `CustomersScreen.build()` reads ONLY `viewModel.customers.previousState`
// (no `viewModel.customers()` call anywhere), proving the tear-off form
// alone drives `SignalBuilder` placement + the `flutter_solidart` import
// through the cross-instance `classQueryNames` registry.
//
// Deliberately does NOT import `solid_annotations` — see
// `cross_file_pure_consumer_widget_query/view.dart`'s comment for why (the
// probe-path exercise). The `.asReady` source-time stub extension is
// therefore unavailable here; the resulting `undefined_getter` diagnostic
// is expected and silenced below rather than worked around.
// ignore_for_file: undefined_getter

import 'package:flutter/widgets.dart';

import 'view_model.dart';

class CustomersScreen extends StatelessWidget {
const CustomersScreen(this.viewModel, {super.key});

final CustomersViewModel viewModel;

@override
Widget build(BuildContext context) {
return Text(
'${viewModel.customers.previousState?.asReady?.value.length ?? 0}',
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Cross-instance `.previousState` tear-off fixture: a plain view-model class
// declaring a `@SolidQuery` method, consumed cross-file by a pure-consumer
// `StatelessWidget`'s `build()` (see `view.dart`) through the `.previousState`
// tear-off ONLY (no `viewModel.customers()` call anywhere in the consumer).
import 'package:solid_annotations/solid_annotations.dart';

class Customer {
const Customer(this.name);

final String name;
}

class CustomersViewModel {
@SolidQuery()
Future<List<Customer>> customers() async => const [Customer('Ada')];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// A build() reading `<query>.previousState?.asReady?.value` with NO
// `<query>()` call anywhere else in the same build. Proves the
// `.previousState` tear-off ALONE is recognized as a tracked read — before
// this fix, the only tracked-read detection for a query was the CALL form
// (`<query>()`); a build reading solely the `.previousState` tear-off form
// got no `SignalBuilder` wrap and no `flutter_solidart` import, even
// though `Resource.previousState` is genuinely reactive at the signal
// level (`ReadSignal.previousValue` reports observed).
// ignore_for_file: prefer_const_constructors_in_immutables
import 'package:solid_annotations/solid_annotations.dart';
import 'package:flutter/material.dart';

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

@SolidQuery()
Future<int> fetchCount() async => 0;

@override
Widget build(BuildContext context) {
return Text('${fetchCount.previousState?.asReady?.value}');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Cross-instance `<receiver>.<queryName>.previousState` — the tear-off
// counterpart of `cross_file_pure_consumer_widget_query`'s call-form test.
// `CustomersScreen.build()` reads ONLY `viewModel.customers.previousState`
// (no `viewModel.customers()` call anywhere), proving the tear-off form
// alone drives `SignalBuilder` placement + the `flutter_solidart` import
// through the cross-instance `classQueryNames` registry.
//
// Deliberately does NOT import `solid_annotations` — see
// `cross_file_pure_consumer_widget_query/view.dart`'s comment for why (the
// probe-path exercise). The `.asReady` source-time stub extension is
// therefore unavailable here; the resulting `undefined_getter` diagnostic
// is expected and silenced below rather than worked around.
// ignore_for_file: undefined_getter

import 'package:flutter/widgets.dart';
import 'package:flutter_solidart/flutter_solidart.dart';
import 'view_model.dart';

class CustomersScreen extends StatelessWidget {
const CustomersScreen(this.viewModel, {super.key});

final CustomersViewModel viewModel;

@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context, child) {
return Text(
'${viewModel.customers.previousState?.asReady?.value.length ?? 0}',
);
},
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:flutter_solidart/flutter_solidart.dart';
import 'package:solid_annotations/solid_annotations.dart';

class Customer {
const Customer(this.name);

final String name;
}

class CustomersViewModel implements Disposable {
late final customers = Resource<List<Customer>>(
() async => const [Customer('Ada')],
name: 'customers',
);

@override
void dispose() {
customers.dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_solidart/flutter_solidart.dart';

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

@override
State<CounterScreen> createState() => _CounterScreenState();
}

class _CounterScreenState extends State<CounterScreen> {
late final fetchCount = Resource<int>(() async => 0, name: 'fetchCount');

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

@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context, child) {
return Text('${fetchCount.previousState?.asReady?.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 @@ -54,6 +54,7 @@ const List<String> goldenNames = <String>[
'query_with_signal_computed_effect',
'query_when_in_build',
'query_refresh_in_onpressed',
'query_previous_state_alone_in_build',
'query_on_state_class',
'query_on_plain_class',
'query_with_one_signal_dep',
Expand Down Expand Up @@ -157,6 +158,7 @@ const List<String> goldenNames = <String>[
'cross_file_pure_consumer_widget_show_restricted_import',
'cross_file_pure_consumer_widget_query',
'cross_file_query_qualified_registry_disambiguation',
'cross_file_pure_consumer_widget_query_previous_state',
'cross_file_super_param_bare_pure_consumer',
'cross_file_super_param_bare_no_state',
'cross_file_super_param_bare_chain',
Expand Down
Loading