diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/hot_restart_guard_stub.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/hot_restart_guard_stub.dart new file mode 100644 index 000000000000..7bb61996e100 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/hot_restart_guard_stub.dart @@ -0,0 +1,19 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +String? claimDataConnectWebSocketTransport(String key) => null; + +bool isCurrentDataConnectWebSocketTransport(String key, String? token) => true; + +void releaseDataConnectWebSocketTransport(String key, String? token) {} diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/hot_restart_guard_web.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/hot_restart_guard_web.dart new file mode 100644 index 000000000000..1fdd9cc37718 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/hot_restart_guard_web.dart @@ -0,0 +1,50 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +String? claimDataConnectWebSocketTransport(String key) { + final token = + '${DateTime.now().microsecondsSinceEpoch}-${identityHashCode(Object())}'; + globalContext.setProperty(key.toJS, token.toJS); + return token; +} + +bool isCurrentDataConnectWebSocketTransport(String key, String? token) { + if (token == null) { + return true; + } + + final currentToken = globalContext.getProperty(key.toJS); + if (currentToken == null) { + return false; + } + + try { + return (currentToken as JSString).toDart == token; + } catch (_) { + return false; + } +} + +void releaseDataConnectWebSocketTransport(String key, String? token) { + if (token == null) { + return; + } + + if (isCurrentDataConnectWebSocketTransport(key, token)) { + globalContext.delete(key.toJS); + } +} diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/transport_library.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/transport_library.dart index 44e0391dd6c7..eac7aa996126 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/transport_library.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/transport_library.dart @@ -18,10 +18,13 @@ import 'dart:developer' as developer; import 'dart:math'; import 'package:firebase_app_check/firebase_app_check.dart'; import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../common/common_library.dart'; import '../dataconnect_version.dart'; +import 'hot_restart_guard_stub.dart' + if (dart.library.js_interop) 'hot_restart_guard_web.dart'; import 'stream_protocol.dart'; part 'transport_stub.dart'; diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/websocket_transport.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/websocket_transport.dart index c2514dbb69c1..f087e17d18b1 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/websocket_transport.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/websocket_transport.dart @@ -128,11 +128,47 @@ class WebSocketTransport implements DataConnectTransport { bool _isReconnecting = false; int _reconnectAttempts = 0; bool _isExpectedDisconnect = false; + String? _hotRestartKey; + String? _hotRestartToken; + + bool get _shouldUseHotRestartGuard => kIsWeb && kDebugMode; + + String get _webSocketTransportKey { + return 'flutterfire_dataconnect_ws_${appId}_${options.projectId}_' + '${options.location}_${options.serviceId}_${options.connector}_$_url'; + } + + bool get _isCurrentWebSocketTransport { + if (!_shouldUseHotRestartGuard) { + return true; + } + + final key = _hotRestartKey; + if (key == null) { + return true; + } + return isCurrentDataConnectWebSocketTransport(key, _hotRestartToken); + } + + void _claimWebSocketTransport() { + if (!_shouldUseHotRestartGuard) { + return; + } + + _hotRestartKey = _webSocketTransportKey; + _hotRestartToken = claimDataConnectWebSocketTransport(_hotRestartKey!); + } + + void _closeStaleWebSocketTransport() { + _isExpectedDisconnect = true; + _disconnect(); + } void _checkIdleAndDisconnect() { if (_streamListeners.isEmpty && _unaryListeners.isEmpty) { _isExpectedDisconnect = true; _disconnect(); + _releaseWebSocketTransport(); _clearState(); } } @@ -192,6 +228,8 @@ class WebSocketTransport implements DataConnectTransport { final headers = _buildHeaders(authToken, appCheckToken); + _claimWebSocketTransport(); + _channel = WebSocketChannel.connect(Uri.parse(_url)); _channelSubscription = _channel?.stream.listen( _onMessage, @@ -207,6 +245,7 @@ class WebSocketTransport implements DataConnectTransport { } catch (e) { developer.log('WebSocket connection failed to become ready: $e'); _channel = null; + _releaseWebSocketTransport(); throw DataConnectError( DataConnectErrorCode.other, 'WebSocket connection failed: $e'); } @@ -221,6 +260,11 @@ class WebSocketTransport implements DataConnectTransport { // called when a message is received from the stream void _onMessage(dynamic message) { + if (!_isCurrentWebSocketTransport) { + _closeStaleWebSocketTransport(); + return; + } + try { var bodyString = ''; if (message is List) { @@ -307,6 +351,10 @@ class WebSocketTransport implements DataConnectTransport { Timer? _reconnectTimer; void _scheduleReconnect() { + if (!_isCurrentWebSocketTransport) { + _closeStaleWebSocketTransport(); + return; + } if (_isReconnecting || _isExpectedDisconnect) return; if (_streamListeners.isEmpty && _unaryListeners.isEmpty) return; _isReconnecting = true; @@ -393,6 +441,11 @@ class WebSocketTransport implements DataConnectTransport { } Future _performReconnect() async { + if (!_isCurrentWebSocketTransport) { + _closeStaleWebSocketTransport(); + return; + } + _channel?.sink.close(); _channel = null; _reconnectAttempts++; @@ -415,6 +468,10 @@ class WebSocketTransport implements DataConnectTransport { } void _onError(dynamic error) { + if (!_isCurrentWebSocketTransport) { + _closeStaleWebSocketTransport(); + return; + } if (_channel == null) return; developer.log('WebSocket error: $error'); _channel = null; @@ -432,9 +489,14 @@ class WebSocketTransport implements DataConnectTransport { void disconnect() { _isExpectedDisconnect = true; _disconnect(); + _releaseWebSocketTransport(); } void _onDone() { + if (!_isCurrentWebSocketTransport) { + _closeStaleWebSocketTransport(); + return; + } if (_channel == null) return; _channel = null; _isReconnecting = false; @@ -443,6 +505,20 @@ class WebSocketTransport implements DataConnectTransport { } } + void _releaseWebSocketTransport() { + if (!_shouldUseHotRestartGuard) { + return; + } + + final key = _hotRestartKey; + if (key == null) { + return; + } + releaseDataConnectWebSocketTransport(key, _hotRestartToken); + _hotRestartKey = null; + _hotRestartToken = null; + } + @override Future invokeQuery( String operationId,