From a6f30ca7b4d73f4d83e9ec3a4c71cdbd97feff84 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Fri, 28 Aug 2026 18:27:50 +0700 Subject: [PATCH 1/2] feat(solid_generator): recognize cross-instance @SolidQuery reads in build() Let a widget's build() read another class's @SolidQuery method through a constructor-injected or @SolidEnvironment field (e.g. viewModel.customers()) and get a SignalBuilder wrap + flutter_solidart import, matching the cross-instance @SolidState support that already existed. Adds a new classQueryNames cross-class registry (query counterpart of classRegistry), threaded through the build-method rewrite path only; call sites stay byte-identical since the lowered call still resolves through Resource.call(). Hardens the new registry with the same origin/library-URI disambiguation (#110-style) the @SolidState registry already has, so a same-simple-name collision across two imported files can't cause a wrong-class match. Adds two golden fixtures: a pure-consumer widget cross-instance query read, and a same-name-collision disambiguation case proving the real query gets wrapped while a same-named non-query method on a different class does not. --- packages/solid_generator/lib/builder.dart | 149 +++++++++++++++++- .../lib/src/build_rewriter.dart | 15 ++ .../lib/src/cross_file_consumer_rewriter.dart | 37 ++++- .../lib/src/state_class_rewriter.dart | 6 + .../lib/src/stateless_rewriter.dart | 6 + .../lib/src/value_rewriter.dart | 100 ++++++++++++ .../view.dart | 60 +++++++ .../view_model.dart | 24 +++ .../main.dart | 51 ++++++ .../repo_a.dart | 12 ++ .../repo_b.dart | 22 +++ .../view.g.dart | 64 ++++++++ .../view_model.g.dart | 20 +++ .../main.g.dart | 55 +++++++ .../repo_a.g.dart | 14 ++ .../repo_b.g.dart | 13 ++ .../test/integration/golden_helpers.dart | 2 + 17 files changed, 645 insertions(+), 5 deletions(-) create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query/view.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query/view_model.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/main.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/repo_a.dart create mode 100644 packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/repo_b.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query/view.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query/view_model.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/main.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/repo_a.g.dart create mode 100644 packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/repo_b.g.dart diff --git a/packages/solid_generator/lib/builder.dart b/packages/solid_generator/lib/builder.dart index 273d5c1..8d42575 100644 --- a/packages/solid_generator/lib/builder.dart +++ b/packages/solid_generator/lib/builder.dart @@ -174,6 +174,18 @@ class _SolidBuilder implements Builder { Map>>? probedClassRegistryOrigins; Map>>? probedClassCollectionFieldsOrigins; Set? probedClassRegistryShadowedNames; + // Cross-class `@SolidQuery` name map (issue: cross-instance query + // consumption) — the query counterpart of `probedCrossFileRegistry`. + // Populated in lockstep with the maps above; a pure-query ViewModel + // (zero `@SolidState` members) contributes ONLY here, never to + // `probedCrossFileRegistry`, so the short-circuit below must consult + // this map independently. + Map>? probedCrossFileQueryNames; + // Origin-qualified counterparts of `probedCrossFileQueryNames` (issue + // #110 parity) — populated in lockstep, same discipline as + // `probedClassRegistryOrigins` / `probedClassRegistryShadowedNames`. + Map>>? probedCrossFileQueryNamesOrigins; + Set? probedCrossFileQueryNamesShadowedNames; if (!hasSolidAnnotation && !hasProviderHint) { // Cheap syntactic pre-check (#106): seed candidate cross-file wanted @@ -207,6 +219,9 @@ class _SolidBuilder implements Builder { final probeRegistryOrigins = >>{}; final probeCollectionFieldsOrigins = >>{}; final probeShadowedNames = {}; + final probeQueryNames = >{}; + final probeQueryNamesOrigins = >>{}; + final probeQueryNamesShadowedNames = {}; await _populateCrossFileTypes( parsed.unit, buildStep, @@ -218,6 +233,9 @@ class _SolidBuilder implements Builder { probeRegistryOrigins, probeCollectionFieldsOrigins, probeShadowedNames, + probeQueryNames, + probeQueryNamesOrigins, + probeQueryNamesShadowedNames, ); // A name issue #110's finalize pass flagged as ambiguous (shadowed by // a local declaration, or collided across two-plus cross-file @@ -227,8 +245,15 @@ class _SolidBuilder implements Builder { // file's ONLY cross-file find is a shadowed name. Bailing out here on // `probeRegistry.isEmpty` alone (its pre-#110 condition) would treat // that file as having nothing to lower at all, silently reintroducing - // the bug this issue fixes. - if (probeRegistry.isEmpty && probeShadowedNames.isEmpty) { + // the bug this issue fixes. `probeQueryNames` gets the same treatment, + // plus its own shadowed-names set: a pure-query ViewModel consumer (no + // `@SolidState` anywhere) — possibly reached only via an ambiguous, + // origin-qualified query name — would otherwise be wrongly bailed out + // here too. + if (probeRegistry.isEmpty && + probeShadowedNames.isEmpty && + probeQueryNames.isEmpty && + probeQueryNamesShadowedNames.isEmpty) { await buildStep.writeAsString(outputId, source); return; } @@ -239,6 +264,9 @@ class _SolidBuilder implements Builder { probedClassRegistryOrigins = probeRegistryOrigins; probedClassCollectionFieldsOrigins = probeCollectionFieldsOrigins; probedClassRegistryShadowedNames = probeShadowedNames; + probedCrossFileQueryNames = probeQueryNames; + probedCrossFileQueryNamesOrigins = probeQueryNamesOrigins; + probedCrossFileQueryNamesShadowedNames = probeQueryNamesShadowedNames; } for (final diagnostic in parsed.errors) { @@ -305,6 +333,15 @@ class _SolidBuilder implements Builder { final sameFileRegistry = _prescanClassRegistry(unit); final sameFileCollections = _prescanClassCollectionFields(unit); final sameFileFieldTypes = _prescanClassFieldTypes(unit); + // Cross-class `@SolidQuery` name map (class name → `@SolidQuery` method + // names) — the query counterpart of `sameFileRegistry`, kept as a + // SEPARATE registry (see `_prescanClassQueryNames`'s doc comment). + final sameFileQueryNames = _prescanClassQueryNames(unit); + // Origin-qualified counterpart of `sameFileQueryNames` (issue #110 + // parity) plus the shadowed-name set — mirrors `sameFileRegistryOrigins` + // / `sameFileShadowedNames` below, but for queries. + final sameFileQueryNamesOrigins = >>{}; + final sameFileQueryNamesShadowedNames = {}; // Captures, per cross-class `@SolidState` field type text, the // `package:/` URIs that bring that type into scope // on the env-field's class file. Used by [_renderOutput] to inject the @@ -355,6 +392,11 @@ class _SolidBuilder implements Builder { probedClassCollectionFieldsOrigins!, ); sameFileShadowedNames.addAll(probedClassRegistryShadowedNames!); + sameFileQueryNames.addAll(probedCrossFileQueryNames!); + sameFileQueryNamesOrigins.addAll(probedCrossFileQueryNamesOrigins!); + sameFileQueryNamesShadowedNames.addAll( + probedCrossFileQueryNamesShadowedNames!, + ); } else { await _populateCrossFileTypes( unit, @@ -367,6 +409,9 @@ class _SolidBuilder implements Builder { sameFileRegistryOrigins, sameFileCollectionFieldsOrigins, sameFileShadowedNames, + sameFileQueryNames, + sameFileQueryNamesOrigins, + sameFileQueryNamesShadowedNames, ); } @@ -428,6 +473,9 @@ class _SolidBuilder implements Builder { classRegistryOrigins: sameFileRegistryOrigins, classCollectionFieldsOrigins: sameFileCollectionFieldsOrigins, classRegistryShadowedNames: sameFileShadowedNames, + classQueryNames: sameFileQueryNames, + classQueryNamesOrigins: sameFileQueryNamesOrigins, + classQueryNamesShadowedNames: sameFileQueryNamesShadowedNames, ); var current = lowered.text; if (hasProviderHint) { @@ -475,6 +523,9 @@ class _SolidBuilder implements Builder { sameFileRegistryOrigins, sameFileCollectionFieldsOrigins, sameFileShadowedNames, + sameFileQueryNames, + sameFileQueryNamesOrigins, + sameFileQueryNamesShadowedNames, ); await buildStep.writeAsString(outputId, transformed); } @@ -521,6 +572,30 @@ Map> _prescanClassRegistry(CompilationUnit unit) { return registry; } +/// Pre-scans every `ClassDeclaration` in [unit] and returns the cross-class +/// `@SolidQuery` name map (class name → `@SolidQuery` method names) — the +/// query counterpart of [_prescanClassRegistry]. Reuses +/// [_collectClassQueryNames] (the same per-class scan +/// `_collectAnnotatedClasses` already runs) so a class's query names are +/// derived identically whether consulted same-file or cross-file. +/// +/// Kept as a SEPARATE registry from [_prescanClassRegistry] — never merged +/// into it — because a query name and a `@SolidState` field name are +/// consumed differently downstream: `value_rewriter.dart` appends `.value` +/// to a resolved `classRegistry` field but emits NO edit for a +/// `classQueryNames` call (see `_ValueRewriteVisitor._isCrossClassQueryCall`). +/// Mixing the two maps would wrongly append `.value` to a bare +/// `viewModel.customers` tear-off read. +Map> _prescanClassQueryNames(CompilationUnit unit) { + final registry = >{}; + for (final decl in unit.declarations) { + if (decl is! ClassDeclaration) continue; + final names = _collectClassQueryNames(decl); + if (names.isNotEmpty) registry[decl.name.lexeme] = names; + } + return registry; +} + /// Pre-scans every `ClassDeclaration` in [unit] and returns the cross-class /// **type-text** map (class name → field/getter name → declared type text). /// Parallel to [_prescanClassRegistry] — the same scan but with the @@ -906,6 +981,9 @@ String _renderOutput( Map>> classRegistryOrigins, Map>> classCollectionFieldsOrigins, Set classRegistryShadowedNames, + Map> classQueryNames, + Map>> classQueryNamesOrigins, + Set classQueryNamesShadowedNames, ) { // Walk `unit.declarations` in source order. Class declarations are paired // with `annotatedClasses` (which `_collectAnnotatedClasses` populates in @@ -926,6 +1004,9 @@ String _renderOutput( classRegistryOrigins, classCollectionFieldsOrigins, classRegistryShadowedNames, + classQueryNames, + classQueryNamesOrigins, + classQueryNamesShadowedNames, ) else _passthroughResult(decl, source), @@ -1068,6 +1149,9 @@ RewriteResult _resultForClass( Map>> classRegistryOrigins, Map>> classCollectionFieldsOrigins, Set classRegistryShadowedNames, + Map> classQueryNames, + Map>> classQueryNamesOrigins, + Set classQueryNamesShadowedNames, ) { if (c.hasNoAnnotations) return _passthroughResult(c.decl, source); return _rewriteClass( @@ -1084,6 +1168,9 @@ RewriteResult _resultForClass( classRegistryOrigins, classCollectionFieldsOrigins, classRegistryShadowedNames, + classQueryNames, + classQueryNamesOrigins, + classQueryNamesShadowedNames, ); } @@ -1118,6 +1205,9 @@ RewriteResult _rewriteClass( Map>> classRegistryOrigins, Map>> classCollectionFieldsOrigins, Set classRegistryShadowedNames, + Map> classQueryNames, + Map>> classQueryNamesOrigins, + Set classQueryNamesShadowedNames, ) { final kind = classKindOf(decl); final className = decl.name.lexeme; @@ -1137,6 +1227,9 @@ RewriteResult _rewriteClass( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); case ClassKind.plainClass: return rewritePlainClass( @@ -1169,6 +1262,9 @@ RewriteResult _rewriteClass( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); case ClassKind.statefulWidget: throw CodeGenerationError( @@ -1231,6 +1327,9 @@ Future _populateCrossFileTypes( Map>> classRegistryOrigins, Map>> classCollectionFieldsOrigins, Set classRegistryShadowedNames, + Map> classQueryNames, + Map>> classQueryNamesOrigins, + Set classQueryNamesShadowedNames, ) async { // Walk every `@SolidEnvironment` field declaration in the unit. The // builder pre-scan does NOT pre-build env-field models — the readers do @@ -1392,6 +1491,8 @@ Future _populateCrossFileTypes( crossClassFieldTypeOriginUris, classRegistryOrigins, classCollectionFieldsOrigins, + classQueryNames, + classQueryNamesOrigins, ); } } @@ -1447,6 +1548,8 @@ Future _populateCrossFileTypes( crossClassFieldTypeOriginUris, classRegistryOrigins, classCollectionFieldsOrigins, + classQueryNames, + classQueryNamesOrigins, ); } @@ -1473,6 +1576,18 @@ Future _populateCrossFileTypes( classRegistry.remove(name); classCollectionFields.remove(name); } + // Query counterpart of the finalize pass above — same ambiguity rule, + // applied to [classQueryNamesOrigins] / [classQueryNames] instead. A + // pure-query class (no `@SolidState` members at all) is registered ONLY + // here — [classRegistryOrigins] never sees it — so this loop cannot be + // skipped even when the loop above found nothing to strip. + for (final name in classQueryNamesOrigins.keys) { + final origins = classQueryNamesOrigins[name]!; + final isAmbiguous = origins.length > 1 || declaredInUnit.contains(name); + if (!isAmbiguous) continue; + classQueryNamesShadowedNames.add(name); + classQueryNames.remove(name); + } } /// Scans [imported]'s top-level class declarations for names still present @@ -1518,6 +1633,14 @@ Future _populateCrossFileTypes( /// asset-derived URI form [crossClassFieldTypeOriginUris] already uses), /// unconditionally — this is the qualified data the finalize pass and /// `value_rewriter.dart`'s tier-1 URI match need for a name it flags. +/// +/// [classQueryNames] receives every matched class's `@SolidQuery` method +/// names — UNCONDITIONALLY, not gated on `scalarNames.isNotEmpty` like the +/// `@SolidState` registration above: a pure-query ViewModel (zero +/// `@SolidState` members) is exactly as valid a cross-instance query source +/// as one that also happens to own reactive state. [classQueryNamesOrigins] +/// is its origin-qualified counterpart (issue #110 parity), populated +/// unconditionally alongside it exactly like [classRegistryOrigins]. void _registerWantedClassesFrom( CompilationUnit imported, AssetId importedAssetId, @@ -1530,12 +1653,29 @@ void _registerWantedClassesFrom( Map> crossClassFieldTypeOriginUris, Map>> classRegistryOrigins, Map>> classCollectionFieldsOrigins, + Map> classQueryNames, + Map>> classQueryNamesOrigins, ) { for (final decl in imported.declarations) { if (decl is! ClassDeclaration) continue; final className = decl.name.lexeme; if (!wantedTypes.contains(className)) continue; if (!_importExposesName(directive, className)) continue; + // Computed once per matched class and shared by both the state and + // query origin maps below — same asset-derived URI form + // [crossClassFieldTypeOriginUris] already uses. + final originUri = _sourceToLibAsset(importedAssetId).uri.toString(); + final queryNamesFound = _collectClassQueryNames(decl); + if (queryNamesFound.isNotEmpty) { + (classQueryNames[className] ??= {}).addAll(queryNamesFound); + // Origin-qualified counterpart (issue #110 parity) — recorded + // unconditionally for every match, ambiguous or not; see this + // function's doc comment and [_populateCrossFileTypes]'s finalize + // pass. + (classQueryNamesOrigins[className] ??= + >{})[originUri] = + queryNamesFound; + } final scalarNames = {}; final collectionNames = {}; final fieldTypeTexts = {}; @@ -1574,7 +1714,6 @@ void _registerWantedClassesFrom( // Origin-qualified counterpart (issue #110) — recorded unconditionally // for every match, ambiguous or not; see this function's doc comment // and [_populateCrossFileTypes]'s finalize pass. - final originUri = _sourceToLibAsset(importedAssetId).uri.toString(); (classRegistryOrigins[className] ??= >{})[originUri] = scalarNames; if (collectionNames.isNotEmpty) { @@ -1667,6 +1806,8 @@ Future _populateCrossFileTypesOneHop( Map> crossClassFieldTypeOriginUris, Map>> classRegistryOrigins, Map>> classCollectionFieldsOrigins, + Map> classQueryNames, + Map>> classQueryNamesOrigins, ) async { // Same local-shadowing discipline the main walk once applied to the // original file, applied here relative to [hostUnit] itself (distinct @@ -1715,6 +1856,8 @@ Future _populateCrossFileTypesOneHop( crossClassFieldTypeOriginUris, classRegistryOrigins, classCollectionFieldsOrigins, + classQueryNames, + classQueryNamesOrigins, ); } } diff --git a/packages/solid_generator/lib/src/build_rewriter.dart b/packages/solid_generator/lib/src/build_rewriter.dart index 23d0d1b..1641c9d 100644 --- a/packages/solid_generator/lib/src/build_rewriter.dart +++ b/packages/solid_generator/lib/src/build_rewriter.dart @@ -58,6 +58,15 @@ typedef BuildMethodRewrite = ({String text, bool emittedWrap}); /// as tracked reads for SignalBuilder placement without mutating the call /// expression itself. /// +/// [classQueryNames] is the cross-class `@SolidQuery` name map (class name → +/// `@SolidQuery` method names) — the query counterpart of [classRegistry]. +/// A zero-arg `.()` call whose receiver's declared type +/// names a class in this map is recorded as a tracked read the same way, +/// with no call-site edit. Empty map → no-op for the cross-instance query +/// branch. [classQueryNamesOrigins] / [classQueryNamesShadowedNames] are its +/// origin-qualified counterparts (issue #110), mirroring +/// [classRegistryOrigins] / [classRegistryShadowedNames]. +/// /// [classRegistry] is the cross-class reactivity map (class name → reactive /// field/getter names). Threaded through to the value-rewrite visitor so the /// single-level `.` cross-class rewrite fires. Empty @@ -111,6 +120,9 @@ BuildMethodRewrite rewriteBuildMethod( Map>> classRegistryOrigins = const {}, Map>> classCollectionFieldsOrigins = const {}, Set classRegistryShadowedNames = const {}, + Map> classQueryNames = const {}, + Map>> classQueryNamesOrigins = const {}, + Set classQueryNamesShadowedNames = const {}, }) { final methodStart = buildMethod.offset; final methodEnd = buildMethod.end; @@ -129,6 +141,9 @@ BuildMethodRewrite rewriteBuildMethod( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); final wrapPlan = computeWrapPlan( buildMethod, diff --git a/packages/solid_generator/lib/src/cross_file_consumer_rewriter.dart b/packages/solid_generator/lib/src/cross_file_consumer_rewriter.dart index 6d935e4..f6e33be 100644 --- a/packages/solid_generator/lib/src/cross_file_consumer_rewriter.dart +++ b/packages/solid_generator/lib/src/cross_file_consumer_rewriter.dart @@ -60,6 +60,9 @@ PureConsumerLowering lowerPureConsumers( Map>> classRegistryOrigins = const {}, Map>> classCollectionFieldsOrigins = const {}, Set classRegistryShadowedNames = const {}, + Map> classQueryNames = const {}, + Map>> classQueryNamesOrigins = const {}, + Set classQueryNamesShadowedNames = const {}, }) { final widgetResult = collectPureConsumerWidgetEdits( unit, @@ -69,6 +72,9 @@ PureConsumerLowering lowerPureConsumers( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); final crossFileEdits = collectPureConsumerCrossFileEdits( unit, @@ -78,6 +84,9 @@ PureConsumerLowering lowerPureConsumers( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); if (widgetResult.edits.isEmpty && crossFileEdits.isEmpty) { return (text: text, emittedSignalBuilder: false); @@ -138,8 +147,14 @@ List collectPureConsumerCrossFileEdits( Map>> classRegistryOrigins = const {}, Map>> classCollectionFieldsOrigins = const {}, Set classRegistryShadowedNames = const {}, + Map> classQueryNames = const {}, + Map>> classQueryNamesOrigins = const {}, + Set classQueryNamesShadowedNames = const {}, }) { - if (classRegistry.isEmpty && classRegistryShadowedNames.isEmpty) { + if (classRegistry.isEmpty && + classRegistryShadowedNames.isEmpty && + classQueryNames.isEmpty && + classQueryNamesShadowedNames.isEmpty) { return const []; } final edits = []; @@ -161,6 +176,9 @@ List collectPureConsumerCrossFileEdits( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); edits.addAll(result.edits); } @@ -216,8 +234,14 @@ collectPureConsumerWidgetEdits( Map>> classRegistryOrigins = const {}, Map>> classCollectionFieldsOrigins = const {}, Set classRegistryShadowedNames = const {}, + Map> classQueryNames = const {}, + Map>> classQueryNamesOrigins = const {}, + Set classQueryNamesShadowedNames = const {}, }) { - if (classRegistry.isEmpty && classRegistryShadowedNames.isEmpty) { + if (classRegistry.isEmpty && + classRegistryShadowedNames.isEmpty && + classQueryNames.isEmpty && + classQueryNamesShadowedNames.isEmpty) { return (edits: const [], emittedSignalBuilder: false); } final edits = []; @@ -240,6 +264,9 @@ collectPureConsumerWidgetEdits( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); if (rewritten.emittedWrap) emittedSignalBuilder = true; if (rewritten.text != original) { @@ -258,6 +285,9 @@ collectPureConsumerWidgetEdits( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); edits.addAll(result.edits); continue; @@ -272,6 +302,9 @@ collectPureConsumerWidgetEdits( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); edits.addAll(result.edits); } diff --git a/packages/solid_generator/lib/src/state_class_rewriter.dart b/packages/solid_generator/lib/src/state_class_rewriter.dart index 5304193..6babe6f 100644 --- a/packages/solid_generator/lib/src/state_class_rewriter.dart +++ b/packages/solid_generator/lib/src/state_class_rewriter.dart @@ -47,6 +47,9 @@ RewriteResult rewriteStateClass( Map>> classRegistryOrigins = const {}, Map>> classCollectionFieldsOrigins = const {}, Set classRegistryShadowedNames = const {}, + Map> classQueryNames = const {}, + Map>> classQueryNamesOrigins = const {}, + Set classQueryNamesShadowedNames = const {}, }) { final className = classDecl.name.lexeme; // getter→Computed only ships for `StatelessWidget`. The in-place merge @@ -157,6 +160,9 @@ RewriteResult rewriteStateClass( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); pieces.add(buildRewrite.text); // `buildRewrite.emittedWrap` is sourced from `rewriteBuildMethod`'s diff --git a/packages/solid_generator/lib/src/stateless_rewriter.dart b/packages/solid_generator/lib/src/stateless_rewriter.dart index d2cd974..7d7d64b 100644 --- a/packages/solid_generator/lib/src/stateless_rewriter.dart +++ b/packages/solid_generator/lib/src/stateless_rewriter.dart @@ -34,6 +34,9 @@ RewriteResult rewriteStatelessWidget( Map>> classRegistryOrigins = const {}, Map>> classCollectionFieldsOrigins = const {}, Set classRegistryShadowedNames = const {}, + Map> classQueryNames = const {}, + Map>> classQueryNamesOrigins = const {}, + Set classQueryNamesShadowedNames = const {}, }) { final className = classDecl.name.lexeme; final stateClassName = '_${className}State'; @@ -116,6 +119,9 @@ RewriteResult rewriteStatelessWidget( classRegistryOrigins: classRegistryOrigins, classCollectionFieldsOrigins: classCollectionFieldsOrigins, classRegistryShadowedNames: classRegistryShadowedNames, + classQueryNames: classQueryNames, + classQueryNamesOrigins: classQueryNamesOrigins, + classQueryNamesShadowedNames: classQueryNamesShadowedNames, ); final buildMethodText = buildRewrite.text; diff --git a/packages/solid_generator/lib/src/value_rewriter.dart b/packages/solid_generator/lib/src/value_rewriter.dart index a7a392c..c774b80 100644 --- a/packages/solid_generator/lib/src/value_rewriter.dart +++ b/packages/solid_generator/lib/src/value_rewriter.dart @@ -172,6 +172,26 @@ bool _isOnPrefixedCallbackName(String name) { /// Empty (the default) for every caller that has no /// origin data to offer, which degrades this exactly to the pre-#110 /// name-only behavior. +/// +/// [classQueryNames] is the cross-class `@SolidQuery` name map (class name → +/// `@SolidQuery` method names) — the query counterpart of [classRegistry]. +/// A zero-arg cross-instance `.()` call whose +/// receiver's resolved declared type names a class in this map is a tracked +/// read (offset recorded, same as a same-class query call) but receives NO +/// source edit: the call already lowers to `Resource.call() => state` and +/// every trailing `.isLoading`/`.asReady`/`.asError` chain resolves through +/// upstream `flutter_solidart` extensions unchanged. Empty (the default) +/// for every caller that has not opted into cross-class query recognition — +/// currently only build-method callers. +/// +/// [classQueryNamesOrigins] and [classQueryNamesShadowedNames] are the query +/// counterparts of [classRegistryOrigins] / [classRegistryShadowedNames] +/// (issue #110) — a name `builder.dart` could not resolve unambiguously by +/// simple name alone (two-plus distinct cross-file query-bearing classes +/// sharing the name, or a cross-file class whose name is ALSO declared +/// locally) routes through [classQueryNamesOrigins] with a mandatory tier-1 +/// library-URI match instead of the flat [classQueryNames] — see +/// [_ValueRewriteVisitor._queryNamesForCrossClassName]. ValueRewriteResult collectValueEdits( AstNode node, Set reactiveFields, @@ -186,6 +206,9 @@ ValueRewriteResult collectValueEdits( Map>> classRegistryOrigins = const {}, Map>> classCollectionFieldsOrigins = const {}, Set classRegistryShadowedNames = const {}, + Map> classQueryNames = const {}, + Map>> classQueryNamesOrigins = const {}, + Set classQueryNamesShadowedNames = const {}, }) { final visitor = _ValueRewriteVisitor( reactiveFields, @@ -199,6 +222,9 @@ ValueRewriteResult collectValueEdits( classRegistryOrigins, classCollectionFieldsOrigins, classRegistryShadowedNames, + classQueryNames, + classQueryNamesOrigins, + classQueryNamesShadowedNames, ); node.accept(visitor); return ValueRewriteResult( @@ -365,6 +391,9 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor { this._classRegistryOrigins, this._classCollectionFieldsOrigins, this._classRegistryShadowedNames, + this._classQueryNames, + this._classQueryNamesOrigins, + this._classQueryNamesShadowedNames, ); final Set _reactiveFields; @@ -446,6 +475,25 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor { /// a mandatory tier-1 library-URI match instead of the flat maps. final Set _classRegistryShadowedNames; + /// Cross-class `@SolidQuery` name map (class name → `@SolidQuery` method + /// names) — the query counterpart of [_classRegistry]. Drives + /// [_isCrossClassQueryCall]; empty map → the cross-instance query branch + /// no-ops. + final Map> _classQueryNames; + + /// Per-name origin-qualified counterpart of [_classQueryNames] (issue + /// #110) — `name -> originUri -> query method names` — populated by + /// `builder.dart` ONLY for a name it flagged in + /// [_classQueryNamesShadowedNames]. Mirrors [_classRegistryOrigins]. + final Map>> _classQueryNamesOrigins; + + /// Names `builder.dart` could not resolve unambiguously by simple name + /// alone for [_classQueryNames] (issue #110) — mirrors + /// [_classRegistryShadowedNames]. [_queryNamesForCrossClassName] routes + /// these names through [_classQueryNamesOrigins] with a mandatory tier-1 + /// library-URI match instead of the flat [_classQueryNames]. + final Set _classQueryNamesShadowedNames; + final List edits = []; /// Tracked-read offsets keyed by signal name — drives the placement @@ -551,10 +599,62 @@ class _ValueRewriteVisitor extends RecursiveAstVisitor { // resolves to upstream extensions. _recordTrackedRead(node.offset, node.methodName.name); _recordTrackedQueryName(node.methodName.name); + } else if (_untrackedDepth == 0 && _isCrossClassQueryCall(node)) { + // Cross-instance `.()` — the query counterpart of + // [_maybeRewriteCrossClass]'s `.` shape. NO + // source edit: the call is byte-identical because it lowers to + // `Resource.call() -> ResourceState` and the trailing + // `.isLoading`/`.asReady`/`.asError` chain resolves through upstream + // `flutter_solidart` extensions unchanged. Only the offset is recorded + // so SignalBuilder placement wraps the enclosing widget subtree. + _recordTrackedRead(node.offset, node.methodName.name); } super.visitMethodInvocation(node); } + /// True if [node] is a cross-instance `.()` call: a + /// zero-arg `MethodInvocation` with a target (never bare — that shape is + /// [_isQueryShape]'s same-class branch above), not shadowed when the + /// target is a bare identifier, whose receiver's declared type — resolved + /// the same way [_maybeRewriteCrossClass] resolves a `.` + /// prefix — names a class in [_queryNamesForCrossClassName] whose set + /// contains the called method's name. + bool _isCrossClassQueryCall(MethodInvocation node) { + final target = node.target; + if (target == null) return false; + if (node.argumentList.arguments.isNotEmpty) return false; + if (target is SimpleIdentifier && _isShadowed(target.name)) return false; + final receiverType = _resolveReceiverType(target); + final declaredTypeName = + receiverType?.name ?? + (target is SimpleIdentifier ? _environmentFields[target.name] : null); + if (declaredTypeName == null) return false; + final queryNamesOfType = _queryNamesForCrossClassName( + declaredTypeName, + receiverType?.libraryUri, + ); + return queryNamesOfType?.contains(node.methodName.name) ?? false; + } + + /// Query-name resolver for [declaredTypeName] — the query counterpart of + /// [_fieldsForCrossClassName]. Same safety invariant (issue #110): a name + /// NOT in [_classQueryNamesShadowedNames] is served straight from the flat + /// [_classQueryNames]; a FLAGGED name only resolves when [libraryUri] is + /// non-null (tier 1 — a real resolved `staticType`) AND matches one of the + /// origins [_classQueryNamesOrigins] recorded for [declaredTypeName]. See + /// [_fieldsForCrossClassName]'s doc comment for the full invariant this + /// mirrors. + Set? _queryNamesForCrossClassName( + String declaredTypeName, + String? libraryUri, + ) { + if (!_classQueryNamesShadowedNames.contains(declaredTypeName)) { + return _classQueryNames[declaredTypeName]; + } + if (libraryUri == null) return null; + return _classQueryNamesOrigins[declaredTypeName]?[libraryUri]; + } + /// True if [node] is a zero-arg `MethodInvocation` with a bare /// `SimpleIdentifier` target whose name is not shadowed — the structural /// shape of every detection site that consults [_queryNames] or diff --git a/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query/view.dart b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query/view.dart new file mode 100644 index 0000000..608e637 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query/view.dart @@ -0,0 +1,60 @@ +// Pure-consumer `StatelessWidget`: no `@Solid*` annotation of its own, reads +// a cross-file `@SolidQuery` method through a plain constructor-injected +// `viewModel` field. See `view_model.dart` for why this exercises the fix +// in isolation from the (already-working) cross-instance `@SolidState` +// path — `CustomersViewModel` has no `@SolidState` member at all, so +// `classRegistry` stays empty for this file; only the new `classQueryNames` +// registry has anything to offer. +// +// Deliberately does NOT import `solid_annotations` (unlike every annotated +// query fixture) — that import's URI text would flip `builder.dart`'s +// `hasSolidAnnotation` hint true and route this file through the main +// annotated-class path instead of the fast no-annotation PROBE path (see +// `build()`'s `probeQueryNames` short-circuit). This fixture exists +// specifically to exercise that probe path for a pure-query consumer, so +// the `.isLoading`/`.asError`/`.asReady`/`.refresh` source-time stub +// extensions (normally provided by `solid_annotations`) are unavailable +// here; the resulting `undefined_getter`/`undefined_method` diagnostics are +// expected and silenced below rather than worked around. +// ignore_for_file: undefined_getter, undefined_method + +import 'package:flutter/widgets.dart'; + +import 'view_model.dart'; + +// A plain, unrelated widget receiving the read values. Field types are +// ordinary Dart/Flutter types so this class itself needs no stub extension +// to typecheck. +class CustomersList extends StatelessWidget { + const CustomersList({ + required this.isLoading, + required this.error, + required this.totalCount, + required this.onRetry, + super.key, + }); + + final bool isLoading; + final Object? error; + final int totalCount; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +class CustomersScreen extends StatelessWidget { + const CustomersScreen(this.viewModel, {super.key}); + + final CustomersViewModel viewModel; + + @override + Widget build(BuildContext context) { + return CustomersList( + isLoading: viewModel.customers().isLoading, + error: viewModel.customers().asError?.error, + totalCount: viewModel.customers().asReady?.value.length ?? 0, + onRetry: viewModel.customers.refresh, + ); + } +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query/view_model.dart b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query/view_model.dart new file mode 100644 index 0000000..4716e5d --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_pure_consumer_widget_query/view_model.dart @@ -0,0 +1,24 @@ +// Cross-instance `@SolidQuery` consumption fixture: a plain view-model class +// declaring a `@SolidQuery` method, consumed cross-file by a pure-consumer +// `StatelessWidget`'s `build()` (see `view.dart`). Before this fix, +// `@SolidQuery` method names were excluded from every cross-class registry +// — `builder.dart` only ever seeded `classRegistry` (the `@SolidState` +// field/getter map), never a query counterpart — so a widget reading +// `viewModel.customers()` cross-instance got no `SignalBuilder` wrap and no +// `flutter_solidart` import: the read looked fixed but stayed non-reactive. +// +// `CustomersViewModel` owns zero `@SolidState` fields — proving the fix +// does not piggyback on the (already-working) cross-instance `@SolidState` +// path. +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/cross_file_query_qualified_registry_disambiguation/main.dart b/packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/main.dart new file mode 100644 index 0000000..9e27603 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/main.dart @@ -0,0 +1,51 @@ +// Query counterpart of `cross_file_qualified_registry_disambiguation` +// (issue #110 parity): two DIFFERENT classes named `Repo`, one in +// `repo_a.dart` with a REAL `@SolidQuery items()`, the other in +// `repo_b.dart` with an ORDINARY `items()` method of the same name and +// shape (plus an unrelated `@SolidQuery` of its own — see that file's +// comment for why). Both are reached from this PURE CONSUMER +// `StatelessWidget` via plain constructor injection — `repo_b.dart`'s +// `Repo` imported under a prefix so both bare and prefixed references +// coexist without a compile-time ambiguous-import error. +// +// `_populateCrossFileTypes`'s cross-file walk seeds a SINGLE 'Repo' entry +// into `wantedTypes`, then finds BOTH classes across this file's two +// imports — each contributes a distinct origin to +// `classQueryNamesOrigins['Repo']` (`{'items'}` from `repo_a.dart`, +// `{'otherQuery'}` from `repo_b.dart`), so the name is flagged ambiguous in +// `classQueryNamesShadowedNames` and stripped from the flat +// `classQueryNames`. +// +// At rewrite time, `a`'s resolved `staticType` points at `repo_a.dart`'s +// `Repo`; `b`'s points at `repo_b.dart`'s `Repo`. Each receiver's tier-1 +// URI matches ONLY its own class's recorded origin: `a.items()` resolves +// against `{'items'}` (a match — tracked, `SignalBuilder`-wrapped) while +// `b.items()` resolves against `{'otherQuery'}` (`items` is NOT in that +// set — no match, no tracking, no wrap). Never the other way around. +import 'package:flutter/widgets.dart'; + +import 'repo_a.dart'; +import 'repo_b.dart' as repo_b; + +class RepoScreen extends StatelessWidget { + const RepoScreen(this.a, this.b, {super.key}); + + final Repo a; + final repo_b.Repo b; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FutureBuilder>( + future: a.items(), + builder: (context, snapshot) => const SizedBox.shrink(), + ), + FutureBuilder>( + future: b.items(), + builder: (context, snapshot) => const SizedBox.shrink(), + ), + ], + ); + } +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/repo_a.dart b/packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/repo_a.dart new file mode 100644 index 0000000..f67cd21 --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/repo_a.dart @@ -0,0 +1,12 @@ +// One of two DIFFERENT `Repo` classes sharing a simple name across files — +// the `@SolidQuery` counterpart of +// `cross_file_qualified_registry_disambiguation`'s `foo_a.dart`/`foo_b.dart` +// pair, proving `classQueryNames` gets the SAME per-origin disambiguation +// as `classRegistry` (issue #110 parity). This one is the REAL query +// source: `items()` is annotated `@SolidQuery`. +import 'package:solid_annotations/solid_annotations.dart'; + +class Repo { + @SolidQuery() + Future> items() async => const ['a']; +} diff --git a/packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/repo_b.dart b/packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/repo_b.dart new file mode 100644 index 0000000..4bde0de --- /dev/null +++ b/packages/solid_generator/test/golden/inputs/cross_file_query_qualified_registry_disambiguation/repo_b.dart @@ -0,0 +1,22 @@ +// The OTHER `Repo` — same simple name as `repo_a.dart`'s. `items()` here is +// an ORDINARY (non-`@SolidQuery`) method sharing `repo_a.dart`'s method +// name and shape — the false-positive bait `main.dart` calls through `b`. +// +// `otherQuery()` is a real, differently-named `@SolidQuery` method. It is +// NOT exercised from `main.dart` — its only purpose is to give this `Repo` +// an entry of its own in `_registerWantedClassesFrom`'s query-origin +// tracking. Without it, `builder.dart` would never notice this `Repo` +// exists at all (a matched class with ZERO `@SolidQuery` methods +// contributes nothing to `classQueryNamesOrigins`), so the genuine +// two-origin collision on the simple name `Repo` would go undetected and +// `items` would wrongly stay resolvable through the flat, +// origin-blind `classQueryNames` map — exactly the false positive this +// fixture exists to catch. +import 'package:solid_annotations/solid_annotations.dart'; + +class Repo { + Future> items() async => const ['b']; + + @SolidQuery() + Future otherQuery() async => 0; +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query/view.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query/view.g.dart new file mode 100644 index 0000000..334f83d --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query/view.g.dart @@ -0,0 +1,64 @@ +// Pure-consumer `StatelessWidget`: no `@Solid*` annotation of its own, reads +// a cross-file `@SolidQuery` method through a plain constructor-injected +// `viewModel` field. See `view_model.dart` for why this exercises the fix +// in isolation from the (already-working) cross-instance `@SolidState` +// path — `CustomersViewModel` has no `@SolidState` member at all, so +// `classRegistry` stays empty for this file; only the new `classQueryNames` +// registry has anything to offer. +// +// Deliberately does NOT import `solid_annotations` (unlike every annotated +// query fixture) — that import's URI text would flip `builder.dart`'s +// `hasSolidAnnotation` hint true and route this file through the main +// annotated-class path instead of the fast no-annotation PROBE path (see +// `build()`'s `probeQueryNames` short-circuit). This fixture exists +// specifically to exercise that probe path for a pure-query consumer, so +// the `.isLoading`/`.asError`/`.asReady`/`.refresh` source-time stub +// extensions (normally provided by `solid_annotations`) are unavailable +// here; the resulting `undefined_getter`/`undefined_method` diagnostics are +// expected and silenced below rather than worked around. +// ignore_for_file: undefined_getter, undefined_method + +import 'package:flutter/widgets.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'view_model.dart'; + +// A plain, unrelated widget receiving the read values. Field types are +// ordinary Dart/Flutter types so this class itself needs no stub extension +// to typecheck. +class CustomersList extends StatelessWidget { + const CustomersList({ + required this.isLoading, + required this.error, + required this.totalCount, + required this.onRetry, + super.key, + }); + + final bool isLoading; + final Object? error; + final int totalCount; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +class CustomersScreen extends StatelessWidget { + const CustomersScreen(this.viewModel, {super.key}); + + final CustomersViewModel viewModel; + + @override + Widget build(BuildContext context) { + return SignalBuilder( + builder: (context, child) { + return CustomersList( + isLoading: viewModel.customers().isLoading, + error: viewModel.customers().asError?.error, + totalCount: viewModel.customers().asReady?.value.length ?? 0, + onRetry: viewModel.customers.refresh, + ); + }, + ); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query/view_model.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_pure_consumer_widget_query/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/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/cross_file_query_qualified_registry_disambiguation/main.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/main.g.dart new file mode 100644 index 0000000..1b7c227 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/main.g.dart @@ -0,0 +1,55 @@ +// Query counterpart of `cross_file_qualified_registry_disambiguation` +// (issue #110 parity): two DIFFERENT classes named `Repo`, one in +// `repo_a.dart` with a REAL `@SolidQuery items()`, the other in +// `repo_b.dart` with an ORDINARY `items()` method of the same name and +// shape (plus an unrelated `@SolidQuery` of its own — see that file's +// comment for why). Both are reached from this PURE CONSUMER +// `StatelessWidget` via plain constructor injection — `repo_b.dart`'s +// `Repo` imported under a prefix so both bare and prefixed references +// coexist without a compile-time ambiguous-import error. +// +// `_populateCrossFileTypes`'s cross-file walk seeds a SINGLE 'Repo' entry +// into `wantedTypes`, then finds BOTH classes across this file's two +// imports — each contributes a distinct origin to +// `classQueryNamesOrigins['Repo']` (`{'items'}` from `repo_a.dart`, +// `{'otherQuery'}` from `repo_b.dart`), so the name is flagged ambiguous in +// `classQueryNamesShadowedNames` and stripped from the flat +// `classQueryNames`. +// +// At rewrite time, `a`'s resolved `staticType` points at `repo_a.dart`'s +// `Repo`; `b`'s points at `repo_b.dart`'s `Repo`. Each receiver's tier-1 +// URI matches ONLY its own class's recorded origin: `a.items()` resolves +// against `{'items'}` (a match — tracked, `SignalBuilder`-wrapped) while +// `b.items()` resolves against `{'otherQuery'}` (`items` is NOT in that +// set — no match, no tracking, no wrap). Never the other way around. +import 'package:flutter/widgets.dart'; +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'repo_a.dart'; +import 'repo_b.dart' as repo_b; + +class RepoScreen extends StatelessWidget { + const RepoScreen(this.a, this.b, {super.key}); + + final Repo a; + final repo_b.Repo b; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SignalBuilder( + builder: (context, child) { + return FutureBuilder>( + future: a.items(), + builder: (context, snapshot) => const SizedBox.shrink(), + ); + }, + ), + FutureBuilder>( + future: b.items(), + builder: (context, snapshot) => const SizedBox.shrink(), + ), + ], + ); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/repo_a.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/repo_a.g.dart new file mode 100644 index 0000000..b0a43d0 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/repo_a.g.dart @@ -0,0 +1,14 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class Repo implements Disposable { + late final items = Resource>( + () async => const ['a'], + name: 'items', + ); + + @override + void dispose() { + items.dispose(); + } +} diff --git a/packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/repo_b.g.dart b/packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/repo_b.g.dart new file mode 100644 index 0000000..48f4557 --- /dev/null +++ b/packages/solid_generator/test/golden/outputs/cross_file_query_qualified_registry_disambiguation/repo_b.g.dart @@ -0,0 +1,13 @@ +import 'package:flutter_solidart/flutter_solidart.dart'; +import 'package:solid_annotations/solid_annotations.dart'; + +class Repo implements Disposable { + Future> items() async => const ['b']; + + late final otherQuery = Resource(() async => 0, name: 'otherQuery'); + + @override + void dispose() { + otherQuery.dispose(); + } +} diff --git a/packages/solid_generator/test/integration/golden_helpers.dart b/packages/solid_generator/test/integration/golden_helpers.dart index e0ce7bd..506bfe4 100644 --- a/packages/solid_generator/test/integration/golden_helpers.dart +++ b/packages/solid_generator/test/integration/golden_helpers.dart @@ -155,6 +155,8 @@ const List goldenNames = [ 'cross_file_pure_consumer_widget', 'cross_file_pure_consumer_widget_and_static', 'cross_file_pure_consumer_widget_show_restricted_import', + 'cross_file_pure_consumer_widget_query', + 'cross_file_query_qualified_registry_disambiguation', 'cross_file_super_param_bare_pure_consumer', 'cross_file_super_param_bare_no_state', 'cross_file_super_param_bare_chain', From 463d9353d1402c390318f59b48c1e759574b90fe Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Fri, 28 Aug 2026 18:32:39 +0700 Subject: [PATCH 2/2] chore(solid_generator): release 3.0.0-dev.6 --- packages/solid_generator/CHANGELOG.md | 4 ++++ packages/solid_generator/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/solid_generator/CHANGELOG.md b/packages/solid_generator/CHANGELOG.md index c4def6a..9b16d4a 100644 --- a/packages/solid_generator/CHANGELOG.md +++ b/packages/solid_generator/CHANGELOG.md @@ -1,3 +1,7 @@ +## 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. + ## 3.0.0-dev.5 - **FIX**: Bare `super.x` constructor parameters now seed the cross-file registry: the superclass is located syntactically (alias-aware, same-package imports only), the matched constructor parameter's type is resolved (recursing through super-formal chains, mapping generic type parameters to the extends-clause type arguments), and the registry walk also searches the located superclass file's own imports one hop further (#108). diff --git a/packages/solid_generator/pubspec.yaml b/packages/solid_generator/pubspec.yaml index a945046..04d68d3 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.5 +version: 3.0.0-dev.6 homepage: https://solid.mariuti.com repository: https://github.com/nank1ro/solid issue_tracker: https://github.com/nank1ro/solid/issues