Skip to content

sembast_web: BroadcastChannel crash when tabs have different databases open #408

@lukasnevosad

Description

@lukasnevosad

Bug Description

JdbFactoryWeb.start() in web_interop.dart line 35 uses a force-unwrap (!) on a map lookup that can return null:

var list = databases[notificationRevision.name]!;

The databases map (defined in JdbFactoryIdb) is local to each tab's JdbFactoryIdb instance. When Tab B writes to a database, sembast broadcasts the revision via BroadcastChannel('sembast_web_storage_revision'). Tab A receives it, but if Tab A hasn't opened that same database, the map lookup returns null and the ! crashes.

Steps to Reproduce

  1. Open the app in Tab 1, open database "alpha" (databaseFactoryWeb.openDatabase('alpha'))
  2. Open the same app in Tab 2, open database "beta" (databaseFactoryWeb.openDatabase('beta'))
  3. In Tab 2, write a record to database "beta"
  4. Tab 1 crashes with: Null check operator used on a null value at web_interop.dart:35

Tab 1 only has "alpha" in its databases map but receives a BroadcastChannel notification for "beta".

Minimal Reproducible Example

pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  sembast_web: ^2.4.4
  web: ^1.1.1

lib/main.dart:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sembast_web/sembast_web.dart';

void main() {
  runZonedGuarded(() {
    WidgetsFlutterBinding.ensureInitialized();
    FlutterError.onError = (details) {
      debugPrint('*** FlutterError: ${details.exception}');
    };
    runApp(const MyApp());
  }, (error, stackTrace) {
    debugPrint('*** Unhandled error: $error');
    debugPrint('*** Stack trace: $stackTrace');
  });
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'sembast_web BroadcastChannel Bug',
      home: const BugReproPage(),
    );
  }
}

class BugReproPage extends StatefulWidget {
  const BugReproPage({super.key});
  @override
  State<BugReproPage> createState() => _BugReproPageState();
}

class _BugReproPageState extends State<BugReproPage> {
  Database? _dbAlpha;
  Database? _dbBeta;
  final _logs = <String>[];

  void _log(String msg) {
    if (!mounted) return;
    setState(() => _logs.add('[${DateTime.now().toIso8601String().substring(11, 19)}] $msg'));
  }

  Future<void> _openAlpha() async {
    _dbAlpha = await databaseFactoryWeb.openDatabase('alpha');
    _log('Opened "alpha"');
  }

  Future<void> _openBeta() async {
    _dbBeta = await databaseFactoryWeb.openDatabase('beta');
    _log('Opened "beta"');
  }

  Future<void> _writeBeta() async {
    if (_dbBeta == null) { _log('Open beta first!'); return; }
    final store = StoreRef<String, String>.main();
    await store.record('key_${DateTime.now().millisecondsSinceEpoch}').put(_dbBeta!, 'value');
    _log('Wrote to "beta" — check other tab for crash');
  }

  @override
  void dispose() { _dbAlpha?.close(); _dbBeta?.close(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(children: [
        Wrap(spacing: 8, children: [
          ElevatedButton(onPressed: _openAlpha, child: const Text('Open Alpha')),
          ElevatedButton(onPressed: _openBeta, child: const Text('Open Beta')),
          ElevatedButton(onPressed: _writeBeta, child: const Text('Write Beta')),
        ]),
        Expanded(child: SelectableText(_logs.join('\n'))),
      ]),
    );
  }
}

Error Output

*** Unhandled error: Unexpected null value.
*** Stack trace:
package:sembast_web/src/web_interop.dart 35:54    <fn>
dart:async/zone.dart 1410:47                       _rootRunUnary
...

Suggested Fix

Replace the force-unwrap with a null-safe lookup and skip unknown databases:

@override
void start() {
  stop();
  _revisionSubscription = notificationRevisionStream.listen((
    notificationRevision,
  ) {
    var list = databases[notificationRevision.name];
    if (list == null) return; // Database not open in this tab, ignore
    for (var jdbDatabase in list) {
      jdbDatabase.addRevision(notificationRevision.revision);
    }
  });
}

Environment

  • sembast_web: 2.4.4
  • Flutter: 3.32.4 (web)
  • Dart: 3.10.3
  • Browsers: Chrome, Safari, Firefox (all affected)

Real-World Impact

In our production app (~102 errors/24h), this happens when a user has the app open in two tabs. Each tab opens different sembast databases (e.g., different cache databases). A write in one tab triggers a BroadcastChannel notification that crashes the other tab.

In dart2js production builds, the ! assertion is optimized away, so instead of a clear "Null check operator" error, it manifests as TypeError: Cannot read properties of null (reading 'length') on the for loop iterating the null list.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions