From a722e88ba9ce0fbb22fec4ce712898804c4482cf Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Fri, 28 Aug 2026 20:45:12 +0700 Subject: [PATCH] =?UTF-8?q?feat(solid):=20@SolidQuery=20previousState=20?= =?UTF-8?q?=E2=80=94=20annotations=20stub=20+=20generator=20tracked-read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit solid_annotations 3.0.0-dev.3: add a previousState source-time stub on the @SolidQuery tear-off (.previousState), mirroring Resource.previousState. Reads as .previousState?.asReady?.value and lets a consumer show the value the resource retained across a failed refresh (retention holds with the default useRefreshing: true). solid_generator 3.0.0-dev.7: recognize .previousState (a tear-off property, not the () call) as a tracked read so the enclosing build() gets a SignalBuilder wrap + flutter_solidart import, byte-identical (lib resolves to Resource.previousState). Same-class and cross-instance, the latter origin-qualified via the existing #110 machinery. Mirrors the .previousValue/.hasValue wiring for @SolidState. Two goldens prove a build reading only .previousState is still wrapped + imported. --- packages/solid_annotations/CHANGELOG.md | 4 ++ .../lib/src/query_extensions.dart | 20 ++++++ packages/solid_annotations/pubspec.yaml | 2 +- packages/solid_generator/CHANGELOG.md | 4 ++ .../lib/src/value_rewriter.dart | 62 +++++++++++++++++++ packages/solid_generator/pubspec.yaml | 2 +- .../view.dart | 30 +++++++++ .../view_model.dart | 16 +++++ .../query_previous_state_alone_in_build.dart | 23 +++++++ .../view.g.dart | 34 ++++++++++ .../view_model.g.dart | 20 ++++++ ...query_previous_state_alone_in_build.g.dart | 28 +++++++++ .../test/integration/golden_helpers.dart | 2 + 13 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_state/view.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_state/view_model.dart create mode 100644 packages/solid_generator/test/golden/inputs/query_previous_state_alone_in_build.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_state/view.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_state/view_model.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/query_previous_state_alone_in_build.g.dart diff --git a/packages/solid_annotations/CHANGELOG.md b/packages/solid_annotations/CHANGELOG.md index f99beef..5e1d702 100644 --- a/packages/solid_annotations/CHANGELOG.md +++ b/packages/solid_annotations/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0-dev.3 + +- **FEAT**: Add a `previousState` source-time stub on the `@SolidQuery` tear-off (`.previousState`), mirroring `Resource.previousState` — the `ResourceState?` 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 `.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. diff --git a/packages/solid_annotations/lib/src/query_extensions.dart b/packages/solid_annotations/lib/src/query_extensions.dart index 5fbc620..69ad92e 100644 --- a/packages/solid_annotations/lib/src/query_extensions.dart +++ b/packages/solid_annotations/lib/src/query_extensions.dart @@ -187,6 +187,20 @@ extension RefreshFuture on Future Function() { Future refresh() { throw Exception(_stubMessage); } + + /// Source-time stub for `.previousState` on a Future-form query. After + /// lowering, `` is a `Resource` and this resolves to + /// `Resource.previousState` — the `ResourceState?` 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?` return reuses the [FutureWhen] + /// state accessors, so `.previousState?.asReady?.value` typechecks + /// identically source- and lib-side. + Future? get previousState { + throw Exception(_stubMessage); + } } /// Stub `.refresh()` on a `Stream Function()` tear-off. Same shape as @@ -196,6 +210,12 @@ extension RefreshStream on Stream Function() { Future refresh() { throw Exception(_stubMessage); } + + /// Source-time stub for `.previousState` on a Stream-form query. See + /// [RefreshFuture.previousState]. + Stream? get previousState { + throw Exception(_stubMessage); + } } /// Library-private placeholder mirroring the public surface of diff --git a/packages/solid_annotations/pubspec.yaml b/packages/solid_annotations/pubspec.yaml index 3890d86..1a0b45b 100644 --- a/packages/solid_annotations/pubspec.yaml +++ b/packages/solid_annotations/pubspec.yaml @@ -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 diff --git a/packages/solid_generator/CHANGELOG.md b/packages/solid_generator/CHANGELOG.md index 9b16d4a..572d6a3 100644 --- a/packages/solid_generator/CHANGELOG.md +++ b/packages/solid_generator/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0-dev.7 + +- **FEAT**: A `build()` reading `.previousState` (the `solid_annotations` `previousState` tear-off) now gets a `SignalBuilder` wrap and `flutter_solidart` import even with no `()` 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. `.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. diff --git a/packages/solid_generator/lib/src/value_rewriter.dart b/packages/solid_generator/lib/src/value_rewriter.dart index c774b80..61accf6 100644 --- a/packages/solid_generator/lib/src/value_rewriter.dart +++ b/packages/solid_generator/lib/src/value_rewriter.dart @@ -314,6 +314,20 @@ const String _untrackedValueGetterName = 'untrackedValue'; /// tracking context. const String _untrackedStateGetterName = 'untrackedState'; +/// The `previousState` getter `solid_annotations` exposes on the +/// `RefreshFuture`/`RefreshStream` query tear-off +/// (`.previousState`). After lowering this resolves directly to +/// `Resource.previousState`, which IS reactive at the signal level +/// (`ReadSignal.previousValue` reports observed) — so a bare +/// `.previousState` read (same-class) or +/// `..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 `.refresh`, which stays untracked (an +/// action, not a reactive read). +const String _queryPreviousStateGetterName = 'previousState'; + /// `SignalBase` 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 @@ -703,6 +717,13 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor { // [_isUntrackedQueryCall]. return; } + // Cross-instance `..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 @@ -776,6 +797,36 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor { } } + /// Cross-instance `..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 `.` + /// 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 `.untracked` to `.untrackedValue` (the @@ -796,6 +847,17 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor { // to the prefix, corrupting the replacement just emitted. return; } + // Same-class `.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 (`.`) 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 diff --git a/packages/solid_generator/pubspec.yaml b/packages/solid_generator/pubspec.yaml index 04d68d3..16e205b 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.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 diff --git a/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_state/view.dart b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_state/view.dart new file mode 100644 index 0000000..72d347d --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_state/view.dart @@ -0,0 +1,30 @@ +// Cross-instance `..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}', + ); + } +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_state/view_model.dart b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_state/view_model.dart new file mode 100644 index 0000000..b3b9a86 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_state/view_model.dart @@ -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> customers() async => const [Customer('Ada')]; +} diff --git a/packages/solid_generator/test/golden/inputs/query_previous_state_alone_in_build.dart b/packages/solid_generator/test/golden/inputs/query_previous_state_alone_in_build.dart new file mode 100644 index 0000000..cacf3fe --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/query_previous_state_alone_in_build.dart @@ -0,0 +1,23 @@ +// A build() reading `.previousState?.asReady?.value` with NO +// `()` 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 +// (`()`); 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 fetchCount() async => 0; + + @override + Widget build(BuildContext context) { + return Text('${fetchCount.previousState?.asReady?.value}'); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_state/view.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_state/view.g.dart new file mode 100644 index 0000000..e407900 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_state/view.g.dart @@ -0,0 +1,34 @@ +// Cross-instance `..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}', + ); + }, + ); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_state/view_model.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_state/view_model.g.dart new file mode 100644 index 0000000..f855d4e --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_state/view_model.g.dart @@ -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>( + () async => const [Customer('Ada')], + name: 'customers', + ); + + @override + void dispose() { + customers.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/query_previous_state_alone_in_build.g.dart b/packages/solid_generator/test/golden/outputs/query_previous_state_alone_in_build.g.dart new file mode 100644 index 0000000..9ac24d7 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/query_previous_state_alone_in_build.g.dart @@ -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 createState() => _CounterScreenState(); +} + +class _CounterScreenState extends State { + late final fetchCount = Resource(() 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}'); + }, + ); + } +} diff --git a/packages/solid_generator/test/integration/golden_helpers.dart b/packages/solid_generator/test/integration/golden_helpers.dart index 506bfe4..71cb512 100644 --- a/packages/solid_generator/test/integration/golden_helpers.dart +++ b/packages/solid_generator/test/integration/golden_helpers.dart @@ -54,6 +54,7 @@ const List goldenNames = [ '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', @@ -157,6 +158,7 @@ const List goldenNames = [ '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',