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
5 changes: 5 additions & 0 deletions packages/solid_generator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 3.0.0-dev.9

- **FIX**: A documentation-comment reference to a reactive field (`/// … [count] …`) is no longer rewritten to `[count.value]` — the value rewriter skips comment references (which resolve as declarations, not runtime reads), so the generated doc comment stays a valid reference. The member body still gets its `.value` append.
- **FIX**: `@SolidEffect` now lowers to a declared `late final Effect <name>;` field assigned at its materialization site (`<name> = Effect(...)` in the synthesized constructor / `initState`) instead of a `late final <name> = Effect(...)` field plus a bare `<name>;` touch. The old touch tripped `unnecessary_statements` in the generated output; the assignment does not. Behaviour is unchanged (same closure, same materialization timing).

## 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`.
Expand Down
16 changes: 8 additions & 8 deletions packages/solid_generator/lib/src/plain_class_rewriter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ RewriteResult rewritePlainClass(

final pieces = <String>[];
final disposeNames = <String>[];
final effectNames = <String>[];
final effects = <EffectModel>[];
// `dispose` emission is deferred until after the walk so the merge sees
// the fully-populated `disposeNames` list. The slot index reserves the
// member's source-order position in `pieces` so a user-declared
Expand All @@ -131,7 +131,7 @@ RewriteResult rewritePlainClass(
MethodDeclaration? disposeMethod;
var disposeSlot = -1;
// User-declared constructors (zero or more) are recorded for a merged
// emit after the walk — the merge needs `effectNames` populated to
// emit after the walk — the merge needs `effects` populated to
// splice in the Effect-materialization reads at the end of each user
// body. Slot indices preserve source-order position. Only generative
// constructors get the merge; factory constructors round-trip verbatim
Expand Down Expand Up @@ -175,10 +175,10 @@ RewriteResult rewritePlainClass(
final effect = effectByName[name]!;
pieces.add(emitEffectField(effect));
disposeNames.add(effect.methodName);
effectNames.add(effect.methodName);
effects.add(effect);
} else if (queryByName.containsKey(name)) {
// Queries are lazy — joining `disposeNames` only, never
// `effectNames`, so the synthesized constructor below skips them.
// `effects`, so the synthesized constructor below skips them.
final query = queryByName[name]!;
emitQueryFields(
query,
Expand Down Expand Up @@ -233,7 +233,7 @@ RewriteResult rewritePlainClass(
// merged result.
final mergedHeaderAndBody = mergeConstructor(
ctor,
effectNames,
effects,
source,
className,
);
Expand Down Expand Up @@ -283,8 +283,8 @@ RewriteResult rewritePlainClass(
} else {
pieces.add(disposeText);
}
if (effectNames.isNotEmpty && userCtors.isEmpty) {
pieces.insert(0, emitConstructor(className, effectNames));
if (effects.isNotEmpty && userCtors.isEmpty) {
pieces.insert(0, emitConstructor(className, effects));
}

final header = _buildHeaderWithDisposable(classDecl, source);
Expand Down Expand Up @@ -319,7 +319,7 @@ RewriteResult rewritePlainClass(
if (hasSetSignalField) 'SetSignal',
if (hasMapSignalField) 'MapSignal',
if (solidGetters.isNotEmpty) 'Computed',
if (effectNames.isNotEmpty) 'Effect',
if (effects.isNotEmpty) 'Effect',
if (solidQueries.isNotEmpty) 'Resource',
// A multi-dep query synthesizes a Record-Computed source field,
// requiring `Computed` in the import set.
Expand Down
134 changes: 73 additions & 61 deletions packages/solid_generator/lib/src/signal_emitter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -168,19 +168,36 @@ String emitComputedField(GetterModel g) {
return ' late final ${g.getterName} = $ctor;';
}

/// Emits one `late final <name> = Effect(<closure>, name: '<debug>');` line.
/// Mirrors [emitComputedField] (same closure shape, same `late final`
/// rationale, same body-text contract); the only differences are the absent
/// type parameter and the `Effect` ctor.
///
/// `Effect(...)` takes a zero-param `void Function()` callback per the
/// upstream `flutter_solidart` API and returns an `Effect` object whose
/// `.dispose()` joins the unified disposal list emitted by [emitDispose].
String emitEffectField(EffectModel e) {
/// Emits the field DECLARATION for one `@SolidEffect` —
/// `late final Effect <name>;` — without an initializer. The `Effect(...)`
/// construction is emitted separately as an assignment at the materialization
/// site ([emitInitState] / [emitConstructor] and their merge variants) via
/// [emitEffectInit].
///
/// Split declaration + assignment (the canonical `flutter_solidart` idiom:
/// declare the field, create the `Effect` in `initState` / the constructor)
/// instead of `late final <name> = Effect(...)` + a bare `<name>;` touch: the
/// touch is a no-value expression statement that trips `unnecessary_statements`
/// in the generated output, whereas an assignment does not. The Effect closure
/// also reads instance members (sibling Signals), which a NON-late field
/// initializer cannot — so the field stays `late final`.
///
/// `Effect` (from `flutter_solidart`) is non-generic; its `.dispose()` joins
/// the unified disposal list emitted by [emitDispose].
String emitEffectField(EffectModel e) => ' late final Effect ${e.methodName};';

/// Emits the materialization ASSIGNMENT for one `@SolidEffect` —
/// `<name> = Effect(<closure>, name: '<debug>');`. Spliced (indented, in
/// source-declaration order) into the synthesized/merged constructor or
/// `initState` by the callers below. Assigning the `late final` field there
/// both runs the `Effect(...)` autorun once and registers dependencies —
/// the plain-class / State-class analogue of the old bare-read touch, minus
/// the `unnecessary_statements` lint. Mirrors [emitEffectField]'s closure
/// shape and body-text contract.
String emitEffectInit(EffectModel e) {
final debugName = e.annotationName ?? e.methodName;
final closure = e.isBlockBody ? '() ${e.bodyText}' : '() => ${e.bodyText}';
final ctor = "Effect($closure, name: '$debugName')";
return ' late final ${e.methodName} = $ctor;';
return "${e.methodName} = Effect($closure, name: '$debugName');";
}

/// Emits one `late final <name> = Resource<T>(<closure>, name: '<debug>');`
Expand Down Expand Up @@ -488,40 +505,36 @@ String mergeDispose(
'${source.substring(lbrace + 1, method.end)}';
}

/// Emits an `initState()` method that materializes every `late final` Effect
/// field by reading it as a bare-identifier statement (`<effectName>;`), in
/// source-declaration order.
///
/// In Dart, `late final field = expr` defers the initializer until the field
/// is first read. Without this synthesized read, the Effect's factory
/// constructor — and its `effect.run()` autorun, which registers reactive
/// dependencies — would never fire during the widget's mounted lifetime. The
/// `dispose()` body's `<effectName>.dispose()` call is the first read, by
/// which point signal mutations have already happened.
///
/// Touching each Effect by name in `initState` triggers the `late final`
/// initializer at mount time, so `Effect(...)`'s autorun runs once with the
/// initial signal values and subscribes to subsequent changes.
///
/// [effectNamesInDeclarationOrder] should mirror the source order of the
/// emitted `late final … = Effect(...)` fields. Caller is responsible for
/// only invoking this when the list is non-empty — the resulting `initState`
/// is otherwise a pure-overhead `super.initState()` no-op.
String emitInitState(List<String> effectNamesInDeclarationOrder) {
/// Emits an `initState()` method that materializes every `late final Effect`
/// field by assigning it (`<name> = Effect(...);`), in source-declaration
/// order.
///
/// The Effect's factory constructor — and its `effect.run()` autorun, which
/// registers reactive dependencies — fires when the field is assigned. Doing
/// that assignment in `initState` runs the autorun once with the initial
/// signal values, at mount time, and subscribes to subsequent changes. The
/// fields are declared uninitialized ([emitEffectField]) precisely so this
/// site owns their construction.
///
/// [effectsInDeclarationOrder] should mirror the source order of the emitted
/// `late final Effect …;` field declarations. Caller is responsible for only
/// invoking this when the list is non-empty — the resulting `initState` is
/// otherwise a pure-overhead `super.initState()` no-op.
String emitInitState(List<EffectModel> effectsInDeclarationOrder) {
final buffer = StringBuffer()
..writeln(' @override')
..writeln(' void initState() {')
..writeln(' super.initState();');
for (final name in effectNamesInDeclarationOrder) {
buffer.writeln(' $name;');
for (final e in effectsInDeclarationOrder) {
buffer.writeln(' ${emitEffectInit(e)}');
}
buffer.write(' }');
return buffer.toString();
}

/// Splices Effect-materialization reads (`<effectName>;`, in declaration
/// order) into an existing `initState()` body immediately after the
/// `super.initState();` call, so Effects subscribe to signals before any
/// Splices Effect-materialization assignments (`<name> = Effect(...);`, in
/// declaration order) into an existing `initState()` body immediately after
/// the `super.initState();` call, so Effects subscribe to signals before any
/// user code in `initState` runs.
///
/// Splice point: the end of the first statement when it is recognised as
Expand All @@ -534,7 +547,7 @@ String emitInitState(List<String> effectNamesInDeclarationOrder) {
/// expression body (`=> …`) — the merge is only well-defined for a block.
String mergeInitState(
MethodDeclaration method,
List<String> effectNamesInDeclarationOrder,
List<EffectModel> effectsInDeclarationOrder,
String source,
String className,
) {
Expand All @@ -553,8 +566,8 @@ String mergeInitState(
} else {
insertAt = body.block.leftBracket.offset + 1;
}
final reads = effectNamesInDeclarationOrder
.map((name) => ' $name;')
final reads = effectsInDeclarationOrder
.map((e) => ' ${emitEffectInit(e)}')
.join('\n');
// Auto-add `@override` if the user omitted it — mirrors mergeDispose. The
// lift path (`StatelessWidget` source has no `initState` to override, so
Expand All @@ -578,38 +591,37 @@ bool _isSuperInitStateCall(Statement stmt) {
return expr.target is SuperExpression && expr.methodName.name == 'initState';
}

/// Emits a no-arg constructor whose body materializes every `late final`
/// Effect field by reading it as a bare-identifier statement
/// (`<effectName>;`), in source-declaration order.
/// Emits a no-arg constructor whose body materializes every `late final Effect`
/// field by assigning it (`<name> = Effect(...);`), in source-declaration
/// order.
///
/// A plain Dart class has no `initState` lifecycle hook, but the same
/// `late final` Effect-materialization rule applies — without a synthesized
/// read, the Effect's factory constructor never runs and the autorun never
/// registers dependencies. Reading each Effect inside the generated
/// constructor body is the plain-class analogue of [emitInitState] for State
/// classes: the Effects activate at construction time, so a `Counter()`
/// instantiation is enough to start the autoruns.
/// materialization rule applies — without the assignment, the Effect is never
/// constructed and its autorun never registers dependencies. Assigning each
/// Effect inside the generated constructor body is the plain-class analogue of
/// [emitInitState] for State classes: the Effects activate at construction
/// time, so a `Counter()` instantiation is enough to start the autoruns.
///
/// Caller is responsible for only invoking this when
/// [effectNamesInDeclarationOrder] is non-empty — otherwise an empty
/// constructor is emitted, which is just noise relative to Dart's implicit
/// default constructor for a Signal-only class (see `rewritePlainClass`).
/// [effectsInDeclarationOrder] is non-empty — otherwise an empty constructor is
/// emitted, which is just noise relative to Dart's implicit default constructor
/// for a Signal-only class (see `rewritePlainClass`).
String emitConstructor(
String className,
List<String> effectNamesInDeclarationOrder,
List<EffectModel> effectsInDeclarationOrder,
) {
final buffer = StringBuffer()..writeln(' $className() {');
for (final name in effectNamesInDeclarationOrder) {
buffer.writeln(' $name;');
for (final e in effectsInDeclarationOrder) {
buffer.writeln(' ${emitEffectInit(e)}');
}
buffer.write(' }');
return buffer.toString();
}

/// Splices Effect-materialization reads (`<effectName>;`, in declaration
/// order) into the END of a user-declared plain-class constructor body,
/// after any user statements. The user's body is preserved verbatim — only
/// the trailing `}` is shifted to make room for the materialization lines.
/// Splices Effect-materialization assignments (`<name> = Effect(...);`, in
/// declaration order) into the END of a user-declared plain-class constructor
/// body, after any user statements. The user's body is preserved verbatim —
/// only the trailing `}` is shifted to make room for the materialization lines.
///
/// Empty bodies (`;` / `{}`) are normalized to `{}` with the
/// materialization lines as the only contents. Initializer lists, factory
Expand All @@ -626,7 +638,7 @@ String emitConstructor(
/// depth guard rather than an expected user path.
String mergeConstructor(
ConstructorDeclaration ctor,
List<String> effectNamesInDeclarationOrder,
List<EffectModel> effectsInDeclarationOrder,
String source,
String className,
) {
Expand All @@ -642,8 +654,8 @@ String mergeConstructor(
// is stripped, not appearances elsewhere in the header (initializer
// list literals etc.).
header = header.replaceFirst(RegExp(r'^\s*const\s+'), '');
final reads = effectNamesInDeclarationOrder
.map((name) => ' $name;')
final reads = effectsInDeclarationOrder
.map((e) => ' ${emitEffectInit(e)}')
.join('\n');
if (body is EmptyFunctionBody) {
// `Foo();` → `Foo() { ... }`.
Expand Down
20 changes: 10 additions & 10 deletions packages/solid_generator/lib/src/state_class_rewriter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,15 @@ RewriteResult rewriteStateClass(
// names, and Query method names interleave in source-declaration order —
// the contract `emitDispose` relies on for reverse-disposal correctness.
final disposeNames = <String>[];
final effectNames = <String>[];
final effects = <EffectModel>[];
final pieces = <String>[];
// Set during the `build` branch when the rewriter emitted at least one
// `SignalBuilder` wrap — drives the `flutter_solidart` import even when
// the class itself has no own `@SolidState` field (the env-only host
// reading through to a sibling class's reactive state).
var buildHasSignalBuilder = false;
// `initState`/`dispose` emission is deferred until after the walk so the
// merge sees the fully-populated `effectNames` / `disposeNames` lists.
// merge sees the fully-populated `effects` / `disposeNames` lists.
// The slot index reserves the member's source-order position in `pieces`
// so it round-trips byte-identical when the user declared one.
MethodDeclaration? initStateMethod;
Expand All @@ -137,7 +137,7 @@ RewriteResult rewriteStateClass(
// Env fields lower to `late final … = context.read<T>();` in source-
// declaration order. They are NOT added to `disposeNames` (the host
// never owns disposal of injected instances) and NOT added to
// `effectNames` (env fields are lazy and need no initState
// `effects` (env fields are lazy and need no initState
// materialization).
pieces.add(emitEnvironmentField(env));
continue;
Expand Down Expand Up @@ -184,10 +184,10 @@ RewriteResult rewriteStateClass(
final effect = effectByName[name]!;
pieces.add(emitEffectField(effect));
disposeNames.add(effect.methodName);
effectNames.add(effect.methodName);
effects.add(effect);
} else if (queryByName.containsKey(name)) {
// Queries are lazy — joining `disposeNames` only, never
// `effectNames` / `initState` materialization. The first reactive
// `effects` / `initState` materialization. The first reactive
// call site triggers the late-final initializer.
final query = queryByName[name]!;
emitQueryFields(
Expand Down Expand Up @@ -226,11 +226,11 @@ RewriteResult rewriteStateClass(
// one iff at least one Effect needs materialization — otherwise skip, so
// Signal-only goldens round-trip byte-identical.
if (initStateMethod != null) {
pieces[initStateSlot] = effectNames.isEmpty
pieces[initStateSlot] = effects.isEmpty
? source.substring(initStateMethod.offset, initStateMethod.end)
: mergeInitState(initStateMethod, effectNames, source, className);
} else if (effectNames.isNotEmpty) {
pieces.add(emitInitState(effectNames));
: mergeInitState(initStateMethod, effects, source, className);
} else if (effects.isNotEmpty) {
pieces.add(emitInitState(effects));
}
if (disposeMethod != null) {
pieces[disposeSlot] = mergeDispose(
Expand Down Expand Up @@ -277,7 +277,7 @@ RewriteResult rewriteStateClass(
if (hasListSignalField) 'ListSignal',
if (hasSetSignalField) 'SetSignal',
if (hasMapSignalField) 'MapSignal',
if (effectNames.isNotEmpty) 'Effect',
if (effects.isNotEmpty) 'Effect',
// Queries emit `Resource<T>(...)` fields; their `<query>().when(...)`
// call sites in `build` are wrapped in `SignalBuilder` by
// `rewriteBuildMethod` when at least one query is present.
Expand Down
Loading
Loading