diff --git a/packages/solid_annotations/CHANGELOG.md b/packages/solid_annotations/CHANGELOG.md index 5e1d702..175e63d 100644 --- a/packages/solid_annotations/CHANGELOG.md +++ b/packages/solid_annotations/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0-dev.4 + +- **FEAT**: Add `previousReady` / `previousError` source-time stubs on the `@SolidQuery` tear-off, mirroring `Resource.previousReady` / `previousError` (solidart 3.0.0-dev.2) — the most recent ready / error state, retained across ANY number of failures (unlike `previousState`, the single prior state). Read as `.previousReady?.value` / `.previousError?.error`. + ## 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. diff --git a/packages/solid_annotations/lib/src/query_extensions.dart b/packages/solid_annotations/lib/src/query_extensions.dart index 69ad92e..86516b4 100644 --- a/packages/solid_annotations/lib/src/query_extensions.dart +++ b/packages/solid_annotations/lib/src/query_extensions.dart @@ -201,6 +201,27 @@ extension RefreshFuture on Future Function() { Future? get previousState { throw Exception(_stubMessage); } + + /// Source-time stub for `.previousReady` on a Future-form query. After + /// lowering, `` is a `Resource` and this resolves to + /// `Resource.previousReady` — the most recent `ResourceReady?` the + /// resource held, retained across ANY number of intervening failures/refreshes + /// (unlike [previousState], the single prior state). Read as + /// `.previousReady?.value` to keep a UI populated across even repeated + /// failures: `().asReady?.value ?? .previousReady?.value`. + // ignore: library_private_types_in_public_api + _AsReadyResult? get previousReady { + throw Exception(_stubMessage); + } + + /// Source-time stub for `.previousError` on a Future-form query — the + /// most recent `ResourceError?` the resource held (the counterpart of + /// [previousReady]). Read as `.previousError?.error` / + /// `.previousError?.stackTrace`. + // ignore: library_private_types_in_public_api + _AsErrorResult? get previousError { + throw Exception(_stubMessage); + } } /// Stub `.refresh()` on a `Stream Function()` tear-off. Same shape as @@ -216,6 +237,20 @@ extension RefreshStream on Stream Function() { Stream? get previousState { throw Exception(_stubMessage); } + + /// Source-time stub for `.previousReady` on a Stream-form query. See + /// [RefreshFuture.previousReady]. + // ignore: library_private_types_in_public_api + _AsReadyResult? get previousReady { + throw Exception(_stubMessage); + } + + /// Source-time stub for `.previousError` on a Stream-form query. See + /// [RefreshFuture.previousError]. + // ignore: library_private_types_in_public_api + _AsErrorResult? get previousError { + 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 1a0b45b..b242a93 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.3 +version: 3.0.0-dev.4 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 572d6a3..b1e4db0 100644 --- a/packages/solid_generator/CHANGELOG.md +++ b/packages/solid_generator/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0-dev.8 + +- **FEAT**: Recognize `.previousReady` and `.previousError` (alongside the existing `previousState`) as tracked reads, so a `build()` reading only a retained-state getter gets the `SignalBuilder` wrap + `flutter_solidart` import. Same-class and cross-instance (origin-qualified). Backs solidart 3.0.0-dev.2's new `Resource.previousReady`/`previousError`. + ## 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. diff --git a/packages/solid_generator/lib/src/value_rewriter.dart b/packages/solid_generator/lib/src/value_rewriter.dart index 61accf6..a7448d7 100644 --- a/packages/solid_generator/lib/src/value_rewriter.dart +++ b/packages/solid_generator/lib/src/value_rewriter.dart @@ -314,19 +314,22 @@ 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'; +/// The retained-state getters `solid_annotations` exposes on the +/// `RefreshFuture`/`RefreshStream` query tear-off — `previousState`, +/// `previousReady`, and `previousError`. After lowering each resolves directly +/// to the matching `Resource.previous*` getter, which IS reactive at the signal +/// level (`ReadSignal.previousValue` reports observed) — so a bare +/// `.previous*` read (same-class) or `..previous*` +/// 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 Set _queryRetainedStateGetterNames = { + 'previousState', + 'previousReady', + 'previousError', +}; /// `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` @@ -717,12 +720,12 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor { // [_isUntrackedQueryCall]. return; } - // Cross-instance `..previousState` — the + // Cross-instance `..previous{State,Ready,Error}` — the // PropertyAccess counterpart of [visitPrefixedIdentifier]'s same-class // branch. A tracked read with NO source edit. - if (node.propertyName.name == _queryPreviousStateGetterName && + if (_queryRetainedStateGetterNames.contains(node.propertyName.name) && _untrackedDepth == 0) { - _maybeRecordCrossClassPreviousState(node); + _maybeRecordCrossClassRetainedState(node); } // Multi-level cross-class chain rewrite. `a.b.c.d` parses as // PropertyAccess(target=PropertyAccess(target=PrefixedIdentifier(a, b), @@ -797,10 +800,10 @@ 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` + /// Cross-instance `..previous{State,Ready,Error}` + /// detector — the query counterpart of [_isCrossClassQueryCall], but for the + /// PropertyAccess tear-off shape (`.previous*`) instead of the + /// MethodInvocation call shape (`()`). [node] is the outer `.previous*` /// PropertyAccess; its target must be the `.` /// PrefixedIdentifier — the only chain shape recognized here, mirroring /// the single-level scope [_maybeRewriteCrossClass] keeps for @@ -808,7 +811,7 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor { /// 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) { + void _maybeRecordCrossClassRetainedState(PropertyAccess node) { final target = node.target; if (target is! PrefixedIdentifier) return; if (_isShadowed(target.prefix.name)) return; @@ -847,12 +850,12 @@ 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 && + // Same-class `.previous{State,Ready,Error}` — a tracked read + // with NO source edit (see [_queryRetainedStateGetterNames]). Must be an + // exact name match against one of those getters, never `refresh` — the + // tear-off shape is otherwise identical (`.`) + // and `refresh` must stay untracked. + if (_queryRetainedStateGetterNames.contains(node.identifier.name) && _queryNames.contains(node.prefix.name) && !_isShadowed(node.prefix.name) && _untrackedDepth == 0) { diff --git a/packages/solid_generator/pubspec.yaml b/packages/solid_generator/pubspec.yaml index 16e205b..e6ae834 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.7 +version: 3.0.0-dev.8 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_ready/view.dart b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_ready/view.dart new file mode 100644 index 0000000..bc2e295 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_ready/view.dart @@ -0,0 +1,30 @@ +// Cross-instance `..previousReady` / `.previousError` — +// the tear-off counterpart proving BOTH new retained-state getters (not just +// `previousState`) drive `SignalBuilder` placement + the `flutter_solidart` +// import through the cross-instance `classQueryNames` registry, when the +// consumer's `build()` reads ONLY the tear-offs (no `viewModel.customers()` +// call anywhere). +// +// Deliberately does NOT import `solid_annotations` (probe-path exercise, see +// the sibling `..._previous_state` fixture), so the `.value`/`.error`/ +// `previousReady`/`previousError` source-time stubs are unavailable here; the +// resulting `undefined_getter` diagnostics are expected and silenced. +// 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.previousReady?.value.length ?? 0} ' + '${viewModel.customers.previousError?.error}', + ); + } +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_ready/view_model.dart b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_ready/view_model.dart new file mode 100644 index 0000000..cc15ed2 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query_previous_ready/view_model.dart @@ -0,0 +1,16 @@ +// Cross-instance `.previousReady`/`.previousError` tear-off fixture: a plain +// view-model declaring a `@SolidQuery`, consumed cross-file by a pure-consumer +// `StatelessWidget`'s `build()` (see `view.dart`) through the retained-state +// tear-offs 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_ready_alone_in_build.dart b/packages/solid_generator/test/golden/inputs/query_previous_ready_alone_in_build.dart new file mode 100644 index 0000000..be8f3f4 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/query_previous_ready_alone_in_build.dart @@ -0,0 +1,21 @@ +// A build() reading `.previousReady?.value` with NO `()` call +// anywhere else in the same build. Proves the `.previousReady` tear-off ALONE +// is recognized as a tracked read — the same recognition as `.previousState`, +// generalized to the retained-state getter set (previousState / previousReady / +// previousError). `previousReady` lowers to `Resource.previousReady` +// (a `ResourceReady?`), so `.value` is read directly, not via `.asReady`. +// 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.previousReady?.value}'); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_ready/view.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_ready/view.g.dart new file mode 100644 index 0000000..ce8a8e3 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_ready/view.g.dart @@ -0,0 +1,34 @@ +// Cross-instance `..previousReady` / `.previousError` — +// the tear-off counterpart proving BOTH new retained-state getters (not just +// `previousState`) drive `SignalBuilder` placement + the `flutter_solidart` +// import through the cross-instance `classQueryNames` registry, when the +// consumer's `build()` reads ONLY the tear-offs (no `viewModel.customers()` +// call anywhere). +// +// Deliberately does NOT import `solid_annotations` (probe-path exercise, see +// the sibling `..._previous_state` fixture), so the `.value`/`.error`/ +// `previousReady`/`previousError` source-time stubs are unavailable here; the +// resulting `undefined_getter` diagnostics are expected and silenced. +// 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.previousReady?.value.length ?? 0} ' + '${viewModel.customers.previousError?.error}', + ); + }, + ); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_ready/view_model.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query_previous_ready/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_ready/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_ready_alone_in_build.g.dart b/packages/solid_generator/test/golden/outputs/query_previous_ready_alone_in_build.g.dart new file mode 100644 index 0000000..ee81459 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/query_previous_ready_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.previousReady?.value}'); + }, + ); + } +} diff --git a/packages/solid_generator/test/integration/golden_helpers.dart b/packages/solid_generator/test/integration/golden_helpers.dart index 71cb512..e3a9160 100644 --- a/packages/solid_generator/test/integration/golden_helpers.dart +++ b/packages/solid_generator/test/integration/golden_helpers.dart @@ -55,6 +55,8 @@ const List goldenNames = [ 'query_when_in_build', 'query_refresh_in_onpressed', 'query_previous_state_alone_in_build', + 'query_previous_ready_alone_in_build', + 'cross_file_pure_consumer_widget_query_previous_ready', 'query_on_state_class', 'query_on_plain_class', 'query_with_one_signal_dep',