Skip to content
Merged
24 changes: 19 additions & 5 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,7 @@ The pattern match covers every Flutter built-in callback (`onPressed`, `onTap`,

For a callback whose parameter name does not begin with `on` (very rare; Flutter and community convention prefixes all interaction callbacks with `on`), the developer opts out explicitly via `untracked(() => ...)` (Section 6.4).

A future SPEC revision paired with the M3 type-resolution pivot may refine this rule to detect void-returning function-typed parameters independent of name, making the `on*` convention unnecessary. Until then, the name-pattern + function-expression rule is the canonical detection mechanism.
A future SPEC revision may refine this rule to detect void-returning function-typed parameters via resolved-type analysis, making the `on*` convention unnecessary. Until then, the name-pattern + function-expression rule is the canonical detection mechanism.

The example below shows one read and one write inside `onPressed`. The read (`if (counter > 10)`) is untracked by this rule. The write (`counter++`) is untracked by Section 6.0 and would be untracked even outside a callback. Neither causes `SignalBuilder` wrapping.

Expand Down Expand Up @@ -1292,9 +1292,21 @@ Rules:
- **State-read rewrite.** The generator detects `<reactiveField>.untracked` as a `PrefixedIdentifier`, replaces the whole expression with `<reactiveField>.untrackedValue` (the runtime primitive on `ReadableSignal<T>`), and excludes the offset from the tracked-read set. No `SignalBuilder` wrap occurs at this position, regardless of structural context — even if the read sits inside an existing `SignalBuilder` from a sibling tracked read, `untrackedValue` bypasses `reactiveSystem.setCurrentSub` and never subscribes.
- **Query-call rewrite.** The generator detects `<queryName>().untracked` as a `PropertyAccess` whose `target` is a zero-argument `MethodInvocation` matching the per-class set of `@SolidQuery` method names (and not shadowed by a local) and whose `propertyName` is `untracked`. The whole `<queryName>().untracked` sub-expression is replaced with `<queryName>.untrackedState` (the upstream non-subscribing `Resource<T>` accessor that returns `ResourceState<T>`). The query-call invocation is bypassed entirely — it does NOT count as a tracked read for SignalBuilder placement (Section 4.8 rule 3) or for `Resource.source:` / Effect / Computed wiring (Section 4.8 rule 5). Subsequent chained members (`.value`, `.when`, `.isReady`, etc.) resolve normally on `ResourceState<T>` via the upstream `ResourceExtensions`.
- **String interpolations.** Only the long form `'${counter.untracked}'` / `'${fetchData().untracked.value}'` expresses the untracked intent. The short form `'$counter.untracked'` parses as `${counter}` followed by a literal `.untracked` string suffix and rewrites as a normal tracked read of `counter`.
- **Detection is name-based** for both read kinds (the M3-05 type-resolution deferral); the existing shadowing guard (Section 5.5) suppresses the rewrite when a local variable shadows the underlying field or query name.
- **Detection is name-based** for both read kinds; the existing shadowing guard (Section 5.5) suppresses the rewrite when a local variable shadows the underlying field or query name.

The function-call form `untracked(() => …)` is rejected at build time with a `CodeGenerationError` directing the user to the extension form (`counter.untracked` for state reads, `fetchData().untracked` for query reads).
The function-call form `untracked(() => …)` is supported for untracking a **write**. The `.untracked` getter only untracks a read; it cannot stop a *write* from subscribing. This matters for collection signals (`MapSignal` / `ListSignal` / `SetSignal`): their element-write operators (`[]=`, `add`, whole-collection reassignment via `setValue`) read the signal's tracked value internally to diff before writing, so a collection write *inside* an `@SolidEffect` subscribes the effect to the very signal it writes — the write then notifies, the effect re-runs, and it writes again (a cyclic reaction / stack overflow). Scalar writes go through a setter that does not read the tracked value, so they don't self-subscribe.

To break the cycle, read the dependencies in separate statements (so they stay tracked) and wrap only the write in `untracked(() => …)`:

```dart
@SolidEffect()
void recordHistory() {
final c = counter; // tracked dependency
untracked(() => history = [...history, c]); // untracked write — does not re-trigger the effect
}
```

`solid_annotations` exports a source-time stub `T untracked<T>(T Function() callback) => callback();` (alongside the `UntrackedExtension` getter) so the source typechecks. The generator detects the target-less `untracked(...)` call and leaves it verbatim, treating its callback as an untracked context — inner reads still receive `.value` (to typecheck) but are NOT recorded as tracked reads, and inner writes do not subscribe. The generated call resolves to `flutter_solidart`'s `untracked`. When a generated file both retains the `solid_annotations` import (a class emits `Disposable` or uses `.environment<T>()`) and calls `untracked`, the import is emitted with `hide untracked` so the call binds to the runtime function.

### 6.5 Everything else is tracked

Expand Down Expand Up @@ -1328,6 +1340,8 @@ A widget subtree needs `SignalBuilder` wrapping if and only if all three hold:
2. The subtree contains at least one **tracked** reactive read (Section 6.5).
3. The subtree is not already inside a `SignalBuilder`.

**Unanchored reads (build-method statement scope).** When a tracked reactive read appears at the `build` method's statement scope rather than inside a widget expression — e.g. `final c = nav.currentChannel; if (c == null) return …;` — there is no candidate subtree for the minimal-subtree rule to pick. The generator synthesizes an outer `SignalBuilder` around the entire build method body. The original body is moved verbatim into the builder closure so all statements, early-returns, and switch expressions evaluate inside the SignalBuilder's tracking window and register their reads as dependencies. Any inner anchored wraps whose name-set is a subset of the unanchored-read names are pruned per §7.5 — the outer body wrap subsumes them.

### 7.2 Minimal-subtree rule (fine-grained)

When multiple candidate subtrees in a build tree contain tracked reactive reads, wrap the **smallest** subtree for each independent read. "Smallest" is defined by the widget-subtree hierarchy: if the tracked read appears inside a `Text('$counter')`, wrap that `Text`, not its `Column` ancestor.
Expand Down Expand Up @@ -1501,7 +1515,7 @@ If the developer has already declared a `dispose()` method, the generator merges

`@SolidEnvironment` is NOT valid on a plain class — see Section 3.6 invalid-targets list.

When the plain class has one or more `@SolidEffect` methods, the generator synthesizes a no-arg constructor whose body reads each Effect field by bare identifier in declaration order — analogue of the State class's `initState()` materialization (§4.7). The synthesized constructor takes the place a widget lifecycle would otherwise serve, activating each `late final` Effect at construction time so its autorun fires once with the initial Signal values and subscribes to subsequent changes. `@SolidQuery` fields are NOT materialized in the synthesized constructor — they are lazy by default and initialize on first thin-accessor invocation (Section 4.8). Plain classes with a user-defined constructor and `@SolidEffect` are not supported in this milestone — the generator rejects with a `CodeGenerationError`.
When the plain class has one or more `@SolidEffect` methods, the generator synthesizes a no-arg constructor whose body reads each Effect field by bare identifier in declaration order — analogue of the State class's `initState()` materialization (§4.7). The synthesized constructor takes the place a widget lifecycle would otherwise serve, activating each `late final` Effect at construction time so its autorun fires once with the initial Signal values and subscribes to subsequent changes. `@SolidQuery` fields are NOT materialized in the synthesized constructor — they are lazy by default and initialize on first thin-accessor invocation (Section 4.8). When the user has declared one or more generative constructors AND there is at least one `@SolidEffect`, the Effect-materialization reads are appended at the END of each generative ctor body per the "Constructor body merge" subsection of §10 — no separate no-arg constructor is synthesized.

### 8.4 StatelessWidget with zero `@SolidState` annotations

Expand Down Expand Up @@ -1762,7 +1776,7 @@ These were open questions during SPEC drafting and have been answered by the dev
3. **`@SolidState` on `final` fields** — rejected with a clear error (wrapping a never-reassigned value in a `Signal` is pointless).
4. **Custom `initState` / `didUpdateWidget` overrides in an existing State class (Section 8.2)** — preserved untouched, with one carve-out: when one or more `@SolidEffect` methods exist on the class, Effect-materialization reads (`<effectName>;`) are spliced into the existing `initState` body immediately after the `super.initState();` call (or after the opening brace if no super call is detected as the first statement). `@SolidQuery` fields are NOT spliced — they are lazy and materialize on first thin-accessor invocation (Section 4.8). `@SolidEnvironment` fields are NOT spliced either — they are lazy and materialize on first read in `build` / Effect / Computed / Query bodies (Section 4.9). If an existing `dispose()` is present, reactive disposals are merged into its body (this part applies to all `Signal` / `Computed` / `Effect` / `Resource` declarations and to any synthesized `_<name>Source` Computed for query auto-tracking; `@SolidEnvironment` injected instances are NOT in the dispose-name list — Section 10).
5. **User-facing packages** — two Solid packages plus two third-party runtime deps. `package:solid_annotations` (runtime dep) hosts the four annotation classes (`@SolidState`, `@SolidEffect`, `@SolidQuery`, `@SolidEnvironment`), the `Disposable` marker interface, the `@SolidQuery` source-time stub extensions, and the `.environment<T>()` extension on `Widget`. `package:solid_generator` (dev_dep) hosts the build_runner builder. There is no `package:solid` umbrella. Consumers run `flutter pub add solid_annotations flutter_solidart provider` and `dart pub add --dev solid_generator build_runner`. `flutter_solidart` and `provider` are listed in the user's own pubspec because the user's source code references their symbols directly (per the `depend_on_referenced_packages` lint). `solid_annotations` itself depends on `flutter` (the source-time stubs return `Widget`) and on `package:provider` (the `.environment<T>()` extension's body wraps in `Provider<T>`). It does NOT depend on `flutter_solidart` — the user's source layer never names solidart primitives directly; those types appear only in lowered `lib/` output where `flutter_solidart` is a separate user-listed runtime dep.
6. **Shadowing rule (Section 5.5)** — handled by type resolution. Because Section 5.1 is type-driven, a shadowed local of a non-`SignalBase` type is never rewritten. A dedicated shadowing test case is required in M1.
6. **Shadowing rule (Section 5.5)** — handled by name-based scope tracking. A local variable, parameter, or function declaration whose name matches a `@SolidState` field/getter suppresses the `.value` rewrite inside its enclosing scope. This matches Dart's natural resolution rule (an inner declaration named `counter` resolves all subsequent `counter` references to the local, regardless of type), so the name-based check IS the language semantic. A dedicated shadowing test case is required in M1.
7. **`const` on the public widget constructor (Section 8.1)** — added by the generator when statically determinable. After the class split removes mutable `@SolidState` fields from the widget, the public widget is const-eligible iff (a) every constructor parameter forwards to a `final` field via `this.<name>` or `super.<name>`, (b) the constructor has no body (or only an empty body), and (c) the initializer list is either absent or contains only const expressions. When all three hold, the generator prefixes the constructor declaration with `const`. Otherwise the constructor is preserved verbatim. Constructors are never otherwise modified — argument lists, default values, named parameters, and super-calls round-trip exactly. Unused-import pruning (Section 9) is performed in-generator under the same rationale: see the §9 rationale paragraph for the CI-divergence reason both clauses retract `dart fix --apply` delegation.

---
Expand Down
Loading
Loading