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.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 `<query>.previousReady?.value` / `<query>.previousError?.error`.

## 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.
Expand Down
35 changes: 35 additions & 0 deletions packages/solid_annotations/lib/src/query_extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,27 @@ extension RefreshFuture<T> on Future<T> Function() {
Future<T>? get previousState {
throw Exception(_stubMessage);
}

/// Source-time stub for `<query>.previousReady` on a Future-form query. After
/// lowering, `<query>` is a `Resource<T>` and this resolves to
/// `Resource.previousReady` — the most recent `ResourceReady<T>?` the
/// resource held, retained across ANY number of intervening failures/refreshes
/// (unlike [previousState], the single prior state). Read as
/// `<query>.previousReady?.value` to keep a UI populated across even repeated
/// failures: `<query>().asReady?.value ?? <query>.previousReady?.value`.
// ignore: library_private_types_in_public_api
_AsReadyResult<T>? get previousReady {
throw Exception(_stubMessage);
}

/// Source-time stub for `<query>.previousError` on a Future-form query — the
/// most recent `ResourceError<T>?` the resource held (the counterpart of
/// [previousReady]). Read as `<query>.previousError?.error` /
/// `<query>.previousError?.stackTrace`.
// ignore: library_private_types_in_public_api
_AsErrorResult<T>? get previousError {
throw Exception(_stubMessage);
}
}

/// Stub `.refresh()` on a `Stream<T> Function()` tear-off. Same shape as
Expand All @@ -216,6 +237,20 @@ extension RefreshStream<T> on Stream<T> Function() {
Stream<T>? get previousState {
throw Exception(_stubMessage);
}

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

/// Source-time stub for `<query>.previousError` on a Stream-form query. See
/// [RefreshFuture.previousError].
// ignore: library_private_types_in_public_api
_AsErrorResult<T>? get previousError {
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.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
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.8

- **FEAT**: Recognize `<query>.previousReady` and `<query>.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 `<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.
Expand Down
57 changes: 30 additions & 27 deletions packages/solid_generator/lib/src/value_rewriter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -314,19 +314,22 @@ 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';
/// The retained-state getters `solid_annotations` exposes on the
/// `RefreshFuture<T>`/`RefreshStream<T>` 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
/// `<query>.previous*` read (same-class) or `<receiver>.<queryName>.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 `<query>.refresh`,
/// which stays untracked (an action, not a reactive read).
const Set<String> _queryRetainedStateGetterNames = {
'previousState',
'previousReady',
'previousError',
};

/// `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`
Expand Down Expand Up @@ -717,12 +720,12 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor<void> {
// [_isUntrackedQueryCall].
return;
}
// Cross-instance `<receiver>.<queryName>.previousState` — the
// Cross-instance `<receiver>.<queryName>.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),
Expand Down Expand Up @@ -797,18 +800,18 @@ 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`
/// Cross-instance `<receiver>.<queryName>.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 `<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) {
void _maybeRecordCrossClassRetainedState(PropertyAccess node) {
final target = node.target;
if (target is! PrefixedIdentifier) return;
if (_isShadowed(target.prefix.name)) return;
Expand Down Expand Up @@ -847,12 +850,12 @@ 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 &&
// Same-class `<queryName>.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 (`<queryName>.<getterOrMethod>`)
// and `refresh` must stay untracked.
if (_queryRetainedStateGetterNames.contains(node.identifier.name) &&
_queryNames.contains(node.prefix.name) &&
!_isShadowed(node.prefix.name) &&
_untrackedDepth == 0) {
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.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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Cross-instance `<receiver>.<queryName>.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}',
);
}
}
Original file line number Diff line number Diff line change
@@ -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<List<Customer>> customers() async => const [Customer('Ada')];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// A build() reading `<query>.previousReady?.value` with NO `<query>()` 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<T>?`), 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<int> fetchCount() async => 0;

@override
Widget build(BuildContext context) {
return Text('${fetchCount.previousReady?.value}');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Cross-instance `<receiver>.<queryName>.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}',
);
},
);
}
}
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.previousReady?.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 @@ -55,6 +55,8 @@ const List<String> goldenNames = <String>[
'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',
Expand Down
Loading