Skip to content

Fully support multiple nested ContextPlus.root? #26

@passsy

Description

@passsy

We want to use context plus for dependency injection in tests.

We have one MyApp widget and want to inject a either use a ProdApi or a FakeApi class.

// Sample without dependency injection

import 'package:context_plus/context_plus.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

final Ref<Api> apiRef = Ref<Api>();

abstract class Api {
  Future<void> sendHeartbeat();
}

class ProdApi implements Api {
  @override
  Future<void> sendHeartbeat() async {
    // Simulate sending a heartbeat to the production server
    print('Heartbeat sent to production server');
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ContextPlus.root(
      child: Builder(builder: (context) {
        apiRef.bind(context, () => ProdApi());
        return MaterialApp(
          home: HomeScreen(),
        );
      }),
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ElevatedButton(
              onPressed: () {
                apiRef.of(context).sendHeartbeat();
              },
              child: const Text('Send to Server'),
            ),
          ],
        ),
      ),
    );
  }
}

Currently, ContextPlus.root lives within MyApp. This is fine for production, but we can't inject from the outside in our test environment

class FakeApi implements Api {
  @override
  Future<void> sendHeartbeat() async {
    throw Exception('cannot reach server');
  }
}

void main() {
  testWidgets('send heartbeat to server', (tester) async {
    final app = Builder(builder: (context) {
      // Fails with "No ContextRef.root() found. Did you forget to add a ContextRef.root() widget?"
      apiRef.bind(context, () => FakeApi());
      return const MyApp();
    });
    await tester.pumpWidget(app);
  });
}

There are two ways to solve this

Move ContextRef.root

Remove ContextPlus.root and dependencies from MyApp and move it up to the main function

void main() {
  runApp(ContextPlus.root(
    child: Builder(
      builder: (context) {
        apiRef.bind(context, () => ProdApi());
        return MyApp();
      },
    ),
  ));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomeScreen(),
    );
  }
}

Add ContextPlus.root in the test

void main() {
  testWidgets('send heartbeat to server', (tester) async {
    final app = ContextPlus.root(
      child: Builder(builder: (context) {
        apiRef.bind(context, () => FakeApi());
        return const MyApp();
      }),
    );
    await tester.pumpWidget(app);
  });
}

Wrap with another ContextRef.root

Keep ContextPlus.root in MyApp but use bindWhenUnbound instead of bind.
In tests, add another ContextPlus.root to inject fakes.

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ContextPlus.root(
      child: Builder(builder: (context) {
        apiRef.bindWhenUnbound(context, () => ProdApi());
        return MaterialApp(
          home: HomeScreen(),
        );
      }),
    );
  }
}
void main() {
  testWidgets('send heartbeat to server', (tester) async {
    final app = ContextPlus.root(
      child: Builder(builder: (context) {
        apiRef.bind(context, () => FakeApi());
        return const MyApp();
      }),
    );
    await tester.pumpWidget(app);
  });
}
extension BindUnboundRef<T> on Ref<T> {
  /// Binds the Ref if it is not already bound to [context], otherwise returns the existing value from [context].
  T bindWhenUnbound(
    BuildContext context,
    T Function() create, {
    void Function(T value)? dispose,
    Object? key,
  }) {
    try {
      T found = of(context);
      return found;
    } catch (e) {
      // If the Ref is not bound, bind it now
      return bind(context, create, dispose: dispose, key: key);
    }
  }
}

I do prefer wrapping again with ContextPlus.root and it seems to work fine. But I'm not 100% sure about the internals, if this case is accounted for. I couldn't find tests for this case. That's why I'm coming here to ask if anything could go wrong with nested ContextPlus.root.

In case this is completely fine, I suggest:

  • Add test cases for nested ContextPlus.root
  • Add an actual Exception like ProviderNotFoundException which I can catch in bindWhenUnbound.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions