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
2 changes: 1 addition & 1 deletion examples/nexus_studio/app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ void main({
// Connect to LevitDevTools in debug mode
if (kDebugMode && enableDevTools) {
final transport = WebSocketTransport.connect(
'ws://localhost:9200',
'ws://localhost:9200/ws',
appId: 'nexus-studio',
channelBuilder: devToolsChannelBuilder,
);
Expand Down
2 changes: 1 addition & 1 deletion examples/nexus_studio/server/bin/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import 'package:nexus_studio_shared/shared.dart';
void main() async {
// Connect to LevitDevTools in debug mode
final transport = WebSocketTransport.connect(
'ws://localhost:9200',
'ws://localhost:9200/ws',
appId: 'nexus-server',
);

Expand Down
19 changes: 15 additions & 4 deletions packages/core/levit_flutter_core/lib/src/scoped_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class LScopedView<T> extends LView<T> {
this.dependencyFactory,
super.resolver,
super.builder,
super.orElse,
super.autoWatch = true,
this.scopeName,
super.args,
Expand All @@ -36,15 +37,17 @@ class LScopedView<T> extends LView<T> {
Key? key,
dynamic Function(LevitScope scope)? dependencyFactory,
required Widget Function(BuildContext context, T controller) builder,
Widget Function(BuildContext context)? orElse,
bool autoWatch = true,
String? scopeName,
List<Object?>? args,
}) {
return LScopedView<T>(
key: key,
dependencyFactory: dependencyFactory,
resolver: (context) => context.levit.find<T>(key: state),
resolver: (context) => context.levit.findOrNull<T>(key: state),
builder: builder,
orElse: orElse,
autoWatch: autoWatch,
scopeName: scopeName,
args: args,
Expand All @@ -58,6 +61,7 @@ class LScopedView<T> extends LView<T> {
String? tag,
bool permanent = false,
required Widget Function(BuildContext context, T controller) builder,
Widget Function(BuildContext context)? orElse,
bool autoWatch = true,
String? scopeName,
List<Object?>? args,
Expand All @@ -68,8 +72,9 @@ class LScopedView<T> extends LView<T> {
args: args,
dependencyFactory: (s) =>
s.put<T>(create, tag: tag, permanent: permanent),
resolver: (context) => context.levit.find<T>(tag: tag),
resolver: (context) => context.levit.findOrNull<T>(tag: tag),
builder: builder,
orElse: orElse,
autoWatch: autoWatch,
);
}
Expand All @@ -96,11 +101,17 @@ class _LScopedViewState<T> extends State<LScopedView<T>> {
child: Builder(
builder: (context) {
final controller =
widget.resolver?.call(context) ?? context.levit.find<T>();
widget.resolver?.call(context) ?? context.levit.findOrNull<T>();
if (controller == null) {
final fallback = widget.orElse;
if (fallback != null) return fallback(context);
throw StateError(
'LScopedView<$T>: controller not found and no orElse provided.',
);
}
if (widget.autoWatch) {
return LWatch(() => widget.buildView(context, controller));
}

return LScope.runBridged(
context, () => widget.buildView(context, controller));
},
Expand Down
38 changes: 28 additions & 10 deletions packages/core/levit_flutter_core/lib/src/view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,19 @@ part of '../levit_flutter_core.dart';
/// ```
class LView<T> extends StatefulWidget {
/// Resolves the dependency from the context.
final T Function(BuildContext context)? resolver;
///
/// Returns `null` when the dependency is not registered.
final T? Function(BuildContext context)? resolver;

/// Builds the widget tree using the resolved [controller].
final Widget Function(BuildContext context, T controller)? builder;

/// Fallback widget when the controller cannot be resolved.
///
/// If the resolution returns `null` and no [orElse] is provided,
/// a [StateError] is thrown.
final Widget Function(BuildContext context)? orElse;

/// Whether to wrap the view in an [LWatch].
final bool autoWatch;

Expand All @@ -39,6 +47,7 @@ class LView<T> extends StatefulWidget {
super.key,
this.resolver,
this.builder,
this.orElse,
this.autoWatch = true,
this.args,
});
Expand All @@ -48,13 +57,15 @@ class LView<T> extends StatefulWidget {
LevitStore<T> state, {
Key? key,
required Widget Function(BuildContext context, T controller) builder,
Widget Function(BuildContext context)? orElse,
bool autoWatch = true,
List<Object?>? args,
}) {
return LView<T>(
key: key,
resolver: (context) => context.levit.find<T>(key: state),
resolver: (context) => context.levit.findOrNull<T>(key: state),
builder: builder,
orElse: orElse,
autoWatch: autoWatch,
args: args,
);
Expand All @@ -67,6 +78,7 @@ class LView<T> extends StatefulWidget {
String? tag,
bool permanent = false,
required Widget Function(BuildContext context, T controller) builder,
Widget Function(BuildContext context)? orElse,
bool autoWatch = true,
List<Object?>? args,
}) {
Expand All @@ -75,6 +87,7 @@ class LView<T> extends StatefulWidget {
resolver: (context) =>
context.levit.put<T>(create, tag: tag, permanent: permanent),
builder: builder,
orElse: orElse,
autoWatch: autoWatch,
args: args,
);
Expand Down Expand Up @@ -102,7 +115,7 @@ class LView<T> extends StatefulWidget {
}

class _LViewState<T> extends State<LView<T>> {
late T controller;
T? controller;
LevitScope? _boundScope;

@override
Expand Down Expand Up @@ -136,8 +149,8 @@ class _LViewState<T> extends State<LView<T>> {
}
}

T _resolveController() {
return widget.resolver?.call(context) ?? context.levit.find<T>();
T? _resolveController() {
return widget.resolver?.call(context) ?? context.levit.findOrNull<T>();
}

bool _argsMatch(List<Object?>? a, List<Object?>? b) {
Expand All @@ -151,13 +164,18 @@ class _LViewState<T> extends State<LView<T>> {

@override
Widget build(BuildContext context) {
// Single dispatch path supports both builder callback and subclass override.
final c = controller;
if (c == null) {
final fallback = widget.orElse;
if (fallback != null) return fallback(context);
throw StateError(
'LView<$T>: controller not found and no orElse provided.',
);
}
if (widget.autoWatch) {
return LWatch(() => widget.buildView(context, controller));
return LWatch(() => widget.buildView(context, c));
}

return LScope.runBridged(
context, () => widget.buildView(context, controller));
return LScope.runBridged(context, () => widget.buildView(context, c));
}
}

Expand Down
54 changes: 53 additions & 1 deletion packages/core/levit_flutter_core/lib/src/watch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ class LWatch extends Widget {
Element createElement() => _LWatchElement(this);
}

class _LWatchElement extends ComponentElement implements LevitReactiveObserver {
class _LWatchElement extends ComponentElement
implements LevitReactiveObserver, LevitReactiveReadResolver {
_LWatchElement(LWatch super.widget);

@override
Expand Down Expand Up @@ -63,6 +64,53 @@ class _LWatchElement extends ComponentElement implements LevitReactiveObserver {
// Graph-only tracking is not required for widget rebuild subscriptions.
}

@override
LxReactive resolveReactiveRead(LxReactive reactive) {
// Stabilize reads when a getter recreates wrapper reactives every build
// but they still represent the same logical source.
final single = _singleNotifier;
if (_usingSinglePath && single is LxReactive) {
final stable = single as LxReactive;
if (_isSameLogicalReactive(stable, reactive)) {
return stable;
}
}

final nots = _notifiers;
if (nots == null || nots.isEmpty) return reactive;

LxReactive? match;
for (final notifier in nots) {
if (notifier is! LxReactive) continue;
final candidate = notifier as LxReactive;
if (!_isSameLogicalReactive(candidate, reactive)) continue;

if (match != null && !identical(match, candidate)) {
return reactive; // Ambiguous alias, keep the original read.
}
match = candidate;
}

return match ?? reactive;
}

bool _isSameLogicalReactive(LxReactive a, LxReactive b) {
if (identical(a, b)) return true;
if (a.runtimeType != b.runtimeType) return false;

// Keep aliasing conservative to avoid cross-wiring unrelated reactives.
final ownerA = a.ownerId;
final ownerB = b.ownerId;
final nameA = a.name;
final nameB = b.name;

if (ownerA == null || ownerB == null || nameA == null || nameB == null) {
return false;
}

return ownerA == ownerB && nameA == nameB;
}

void _cleanupAll() {
if (_usingSinglePath) {
_singleNotifier?.removeListener(_triggerRebuild);
Expand Down Expand Up @@ -99,6 +147,10 @@ class _LWatchElement extends ComponentElement implements LevitReactiveObserver {
_newNotifiers = null;

final previousProxy = Lx.proxy;
if (identical(previousProxy, this)) {
// Force-reset read dedupe cache for a fresh dependency capture cycle.
Lx.proxy = null;
}
Lx.proxy = this;

final Widget result;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:levit_flutter_core/levit_flutter_core.dart';

void main() {
testWidgets('LevitProvider resolves LevitStore using context.levit.find',
(tester) async {
final store = LevitStore<String>((ref) => 'store_value');
String? resolvedValue;

await tester.pumpWidget(
MaterialApp(
home: LScope(
child: Builder(
builder: (context) {
// This calls LevitProvider.find with key: store
resolvedValue = context.levit.find<String>(key: store);
return Text(resolvedValue ?? 'null');
},
),
),
),
);

expect(resolvedValue, 'store_value');
expect(find.text('store_value'), findsOneWidget);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:levit_flutter_core/levit_flutter_core.dart';

void main() {
testWidgets('LView renders orElse when controller is null', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: LView<String>(
resolver: (context) => null, // Explicitly return null
orElse: (context) => const Text('Fallback UI'),
builder: (context, controller) => Text('Success: $controller'),
),
),
);

expect(find.text('Fallback UI'), findsOneWidget);
expect(find.text('Success'), findsNothing);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:levit_flutter_core/levit_flutter_core.dart';

void main() {
testWidgets('LScopedView renders orElse when controller is null',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: LScopedView<String>(
// No dependency factory, so context.levit.findOrNull<String>() returns null
orElse: (context) => const Text('Scoped Fallback UI'),
builder: (context, controller) => Text('Success: $controller'),
),
),
);

expect(find.text('Scoped Fallback UI'), findsOneWidget);
expect(find.text('Success'), findsNothing);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';

import 'package:flutter_test/flutter_test.dart';
import 'package:levit_flutter_core/levit_flutter_core.dart';

void main() {
testWidgets(
'LScopedView throws StateError when controller is null and no orElse provided',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: LScopedView<String>(
builder: (context, controller) => Text('Success: $controller'),
),
),
);

expect(tester.takeException(), isInstanceOf<StateError>());
});
}
Loading